【Smashing专栏】案例研究:改善 Smashing Magazine 的性能

今天 Smashing Magazine 八岁了。八年在网络上是很长一段时间,但我们一点不觉得这是个漫长的旅程。八年间我们一直在改变、在进化、在前进,我们心怀感激地迎接一个又一个新的挑战。为了纪念这个特别的小日子,我们想分享一些我们在过去一年里所学到的关于这个网站的性能挑战以及我们最近完成的工作。如果你想制作一个快速响应的网站,你可能会发现一些值得考虑的有趣的点。

改进是一个稳定的、持续迭代的过程。当我们在2012年重新设计网站时,我们的主要目标是建立可靠的品牌来反映杂志的编辑特色。我们专注于创造愉快的阅读体验。多年来我们的重点一直没有改变,然而,帮助我们建立品牌的最重要的财富,却成了最大的性能瓶颈

Good Old-Fashioned Website Decay(典型的网站朽坏)

回顾我们重新设计的早期,我们的一些决定似乎是快速解决的,而不是健全的长期解决方案。 我们的广告限制迫使我们妥协。旧浏览器让我们太依赖(相对来说) JavaScript 库。技术架构决定了我们只能高度定制 WordPress 插件,写复杂的 PHP 逻辑。每增加一个新功能,我的技术债就增加一些,我们的 CSS、HTML、JavaScript 没有任何精简。

看起来很熟悉?诚然,响应式网页设计一直被诟病代码膨胀难以维护(不是说不采用响应式设计的网站就没有这些问题,但是这不是本文的重点)。在实践中,响应式网站的所有资源都会出现在所有版本中:缓慢的智能手机,奇怪的平板电脑或者配备Retina屏幕的精巧笔记本。因为媒体查询只提供了响应屏幕尺寸的能力,并没有局部的、独立的作用域,导致添加新功能和调整阅读体验的时候,为了防止不一致或者修复布局问题 ,可能要潜在地修改每一个媒体查询。

“移动优先”表示“始终移动优先”

当涉及在网站上设置内容和功能的优先级时,“移动优先”是一个困难但非常强大的约束,帮助你专注于真正重要的事情,并确定您的网站的关键组件。我们发现设计移动优先是一回事,实现是另一回事。在我们的案例中,设计和开发都是移动优先,这让我们专注于内容和内容的展示。设计过程相当直接,但是开发实现功能却困难重重。

因为整个网站都是移动优先开发的,我们很快意识到,每一次添加、修改页面组件(无论大小)都要走完整个移动优先的流程。我们为移动界面设计一个组件,然后为更多空间的情况设计一个“扩展”视图。这通常意味着要调整每一个媒体查询,更意味着出现问题时,要在 CSS 和 HTML 中增加更多的内容来修补。

图像说明文字

SmashingMag redesign 上线后不久就遇到了性能问题, Tim Kadlec 在2012年撰写的一篇文章记述了这个问题

我们陷入了一个困境:开发和维护花费了非常多的时间,代码库充满了大大小小的补丁,基础架构越来越慢。结果,在重新设计之前,代码就已经变得膨胀——实际上已经非常臃肿了。

性能问题

在2013年中,我们首页重达 1.4 MB,并且有 90 个 HTTP 请求。性能很不好,我们想创造卓越的阅读体验,保证文字不会闪动(FOUT),所以在头部加载字体,这样就阻塞了内容的渲染(实际上,根据规范,这是正确的行为,旨在避免多次重绘和回流)。显示广告需要 jQuery,一些 JavaScript 也依赖 jQuery,也因此阻碍了渲染。为了保证广告尽快展示,广告的加载和显示都在内容展现之前。

我们的广告合作伙伴提供的图片通常较重并且未优化,这样又会降低网页的展示速度。我们还加载了 Respond.js 和 Modernizr 来处理旧浏览器并增强新浏览器的体验。结果在慢速网络下,文章页几乎无法访问,移动端最好的情况下开始渲染的时间也让人失望。

不仅是前端过时了,后端也没好到哪儿去。在 2012 年我们的理念是每个部分完全独立,每个部分都是独立安装的 WordPress,只管自己的部分。随着时间的推移自己不断进化,但又导致自定义的功能和内容类型不能在各部分之间共享。

图像说明文字

调查显示的用户群让我们很欣慰,所以 IE8 的优化不用操心了。

因为当时的 WordPress 不支持多实例,我们最终安装了6个独立的 WordPress,6 套独立的样式,链接了6×2个数据库(媒体服务器和静态内容服务器)。我们陷入了一些进退两难的问题。比如,如果一个作者写了两部分的内容,怎样在作者的简介页面同时展示这两部分的文章?总之,我们需要以某种方式从两个 WordPress 中拉取文章,给作者的主页加上重定向,到一个统一的页面,或者我们应该使用其中的一个页面作为“主机”?嗯,你现在明白我们的问题了:增加了复杂性,增加了维护成本。最终,独立进化并没有发生,至少在内容上没有,因为我们已经定制化开发了每个部分,让 CSS 和 PHP 变得很复杂了。

(我们有外包的 WordPress 任务,一些插件会互相依赖。如果我们禁用一个,可能导致另外两三个不能使用。修复的过程中,还必须以特定的顺序重新启动,甚至每个 WordPress 安装中,PHP 生成的 HTML 也不一样,比如类名和 ID。不奇怪这种安排让开发人员很沮丧。)

流量停滞不前,读者不断抱怨网站的性能,只有很少一部分用户访问超过 2 个页面。网站可以看但是不能及时展示,这种延迟已经把读者从网站赶到 Instapaper 和 Pocket 了,无论是移动端还是PC端。我们知道是因为我们问过用户了,反馈非常清楚(有点沮丧)。

是时候好好重视大量的重构代码库了。我们仔细查看了底层,发现了一些很可怕(和很讨厌)的事情,并开始逐一解决。我们花了相当多的时间把事情做对,在这个过程中我们也学习到了很多。

切换工具

直到 2013 年中,我们没有使用过 CSS 预处理工具,也没有任何构建工具。好的、长期的解决方案都需要打好地基,因此我们解决的第一个问题是工具和代码库的组织方式。多年来,因为许多人一直在一个代码库上工作,我们发现很多相当神秘的,至少可以说是相当有挑战性的事情。

我们从一个代码清单开始,彻底检查了 每一个 Class、ID 和 CSS 选择器。当然,我们希望构建一个模块化的组件,所以我们第一个任务是将我们的 7 个大型 CSS 文件转换为可维护、文档化、易于阅读的模块。我们的前端工程师 Marco 开始重写 CSS 并开始构建一个模块化、可扩展的架构。我们选择了 LESS,没有特别的原因,当然我们也可以用 Sass,但是 Marco 相对于 LESS 感觉很舒服。

使用新的 CSS 架构,用 Grunt 作为构建工具,还有一些节省时间Grunt 任务,这使维护整个代码库变得更加容易。我们建立了一个全新的测试环境,用 GitHub 同步所有东西,分配角色和权限,并开始挖掘问题。我们重写了选择器,重构、优化了JavaScript 代码。是的,我们花了相当长的时间做这些。如果我们不是有很多不同的样式表的话,就不会这么困难了。

后端大清洁

随着 WordPress 对多站点的支持,把 6 个安装变成一个安装,成了我们的朋友 Inpsyde (译注:承接Smashing mag WordPress 外包工作的公司)很有必要的工作了。在过去的 5 个月中,Christian Brückner 和 Thomas Herzog 清理了 PHP 模板,将不必要的插件摒弃,必须保留的插件重写,有需要的地方添加新插件。他们清除了所有旧插件创建的混乱的数据库。其中一个数据库有 70GB(这里没打错,确实是千兆字节),将所有这些数据库合并为一个,然后创建一个全新的,最重要的是,可维护的WordPress Multisite 站点。

这些优化速度提升显著。我们通过避免子域名重定向,统一基础代码和后端代码,提升了 400 ~ 500 ms的性能。这些重定向确实是影响性能的罪魁祸首。避免重定向是明显提升性能的技术之一,避免了完整了 DNS 查询,提高了首字节时间,减少了网络上往返的时间。

Thomas 和 Christian 也根据自己主题的编码标准,重构了我们的整个 WordPress 主题,这是一种高级的,符合 WordPress 编码标准的架构。他们写了自定义插件,用来在特定位置展示内容。根据 WordPress 官方 API 写 PHP 感觉就像从马车换成赛车。所有的修改都没有涉及 WordPress 的核心,多美好啊,再也不怕更新 WordPress 了。

图像说明文字

我们还标记了数百万垃圾评论,同时没有将它们导入到新的安装中。

我们在2014年4月中旬的一个漫长的周末,迁移了这些安装。

这是一个艰巨的任务,我们的服务器在此过程中也遇到了一些挫折。我们汇集了 2500 多篇文章,其中包括大约 15 000张图片,分布在 6 个数据库中,这些数据库也有很大的不一致。首先很多重定向必须重新建立,缓存的问题也开始在服务器堆积,一些文章在新安装过程中丢失了。虽然这是一个艰难的开始,但是结果还是很值得的。

我们的编辑小组,主要是 IrisMelanie Markus,通过使用谷歌网站管理员工具,努力分析我们的404页面重新找回这些丢失的文章。我们花了好几个周末,确保每一篇文章都被恢复,能正常访问。丢失文章,包括它们的评论都是不能接受的。

我们知道发表一篇好文章需要多长时间,所以我们对作者及其作品都非常尊重。确保文章能被访问到,是对作品的尊重问题。这些事情花了我们几个星期,虽然不全是愉快的经历,但是我们利用这个机会,对我们的信息架构引入了更多一致性,并适当地调整标签和分类。(啊,如果你发现有文章丢失了,请联系我们,我们会马上解决,谢谢!)

前端优化

2014年4月,新系统就位并运行了几天后,我们就为剩下的安装重写了 LESS 文件。精简文章和页面的类选择器,删除所有不需要的 ID,通过降低选择器的特异性来缩短选择器,删除 CSS 中所有不需要的内容,把文件大小从 91 KB 降到 45 KB。

确定好 CSS 代码的基础,有了整洁、结构良好的代码库,是时候重新考虑资源如何在页面上加载。鉴于之前在后端遇到的噩梦,您可能觉得提高性能会是一个长期、复杂的任务,但实际上更容易一些。基本上,这只是一个通过优化关键渲染路径来设定正确优先级的问题。

提高性能的关键就是关注最重要的事情:内容,以及读者真正开始在其设备上阅读我们的文章最快的方式。所以在这几个月的过程中,我们不断重新优化。每一次小的优化,我们都基于一个非常简单的原则:优化内容的加载,推迟其余的一切,没有任何妥协。

我们的优化受到了Scott Jehl,以及卫报BBC团队(他们都开源了)的工作的重大影响。Scott 一直在分享 Filament Group 使用的前端技术见解。BBC 和卫报团队帮助我们定义了网站的核心体验,并将其作为基准。我们的目标都是尽可能快地,将内容传送给尽可能多的人,无论他们的设备和网络能力如何,并对有能力的浏览器渐进增强。

我们网站上并没有很多的 JavaScript 或复杂的交互,所以我们觉得没有必要使用 JavaScript 预加载器,引入复杂的加载逻辑。然而作为一个内容为中心的网站,我们确实希望尽可能缩短显示文章所需的时间。

性能预算:速度指数 <= 1000

多快算足够快?这是一个很难回答的问题。一般来说性能很难可视化,而且很难解释,为什么每一毫秒都很重要,除非你有硬数据。在过去最常提到的性能度量指标是平均加载时间。但是平均加载时间本身用处不是很大,因为它不会告诉你,用户实际上什么时间可以开始使用网站。这就是为什么说“足够快”很棘手。

图像说明文字

一种可视化性能的好方法是使用 WebPageTest 生成实际的页面加载视频,对两个网站运行对比测试。 此外,实际证明速度指标非常有用。

不同的部分需要不同的加载时间,但页面的一些部分比其他部分更重要。比如页脚内容就不需要快速加载。以最快的速度加载可见部分是个好主意。你知道这里指的是什么:我们当然是说头版内容。就像 Ilya Grigorik 曾经说过的“我们不需要一秒钟就加载整个页面,只第一屏的内容就够了”。为了实现这一点,根据 Scott 的研究和谷歌的测试结果,我们制定了野心勃勃的性能目标:

  • WebPageTest 上,目标是速度指数低于 1000 。
  • 确保所有的 HTML、CSS 和 JavaScript 在第一个 14KB 中。

这是什么意思,为什么它们这么重要呢?根据 HCI 的研究,“让一个应用感觉到及时,对用户输入的可感知响应,必须在几百毫秒内提供。到了一秒钟或者更长的时间,用户的心情,或者对当前任务的参与感就会受到破坏”。我们的第一个目标,就是努力确保我们的网站及时响应。也就是所谓的开始渲染时间的速度指标——根据页面可见部分开始显示或者可用的平均时间计算。所以基本上是说页面要在 1000ms 内开始渲染,这是一个非常大的挑战。

图像说明文字

Ilya Grigorik的书(注:中文版《Web性能权威指南》)是一个非常有用的指南,其中有很多有用的网站加速的指南和建议。它也是一本免费的 HTML 书。

第二个目标可以帮助实现第一个目标。14KB 的值是由 Google 的经验得出的,而且也是蜂窝网络上、信号塔和客户端之间交换的第一个包的阈值。不需要在 14KB 内包含图片,但是需要提供结构、样式和显示可见部分所需要的 JavaScript。当然在实践中,这个结果只能通过 gzip 来实现。

通过这两个目标,我们基本上定义了网站的性能预算——也就是一个可以接受的阈值。我们承认,我们并没有关注在不同的网络上,不同的设备上的开始渲染时间。主要是我们真的想尽可能多地推迟所有不是渲染首屏必须的东西。因此理想的情况是,在所有的配置和网络中,不管网络稳定不稳定,速度快慢,速度指标都低于我们的性能与预算,越低越好。听起来可能很幼稚,但我们想要弄清楚我们可以有多快,而不是我们应该有多快。我们测量了页面开始加载的时间,后续页面的加载。优化完成以后,我们做了更多测量,时刻关注前端问题。

我们下一步会将 Perf-Budget Grunt task 纳入构建过程。这样,每次提交都测试性能分数。如果没有通过,我们就知道新功能减缓页面了,所以我们可能需要重新考虑实现方式以适应我们的预算,或者至少我们知道了现在的处境,可以有目的地讨论新功能对性能的影响。

优先级与关注点分离

如果你在关注卫报团队最近的工作,应该会熟悉他们在2013年改版中对关注目标的拆分。

  • 核心内容: 基本的 HTML 和 CSS,不依赖 JavaScript 的可用体验。

  • 增强内容: JavaScript、地理位置、触摸支持、增强CSS、网络字体、图像和小挂件。

  • 其他内容: 统计、广告和第三方内容。

图像说明文字

严格分离关注点,或者说加载的优先级,由卫报团队定义。

一旦你定义、确认、同意这些优先级,可以将性能优化推动得相当远。只要详尽地了解每种类型的内容,并清楚定义核心内容是什么,就可以尽快加载核心内容,然后在页面开始呈现后(DOMContendLoaded事件触发后)加载增强内容,然后,页面完全呈现(load事件触发后)再加载其他内容。

这里的主要原则是,在这三个阶段严格区分资源的加载,确保核心内容不会被增强资源和其他资源阻塞(我们还没有完美地分离,但我们正在努力)。换句话说,缩短核心渲染路径,是通过尽可能快地推送内容并推迟其他一切的实现。

我们也遵循同样的原则,对我们的内容进行了分组,确定了什么是核心内容,什么是重要内容,什么是次要内容。在我们的例子中,我们以这种方式识别和分离内容:

  • 核心内容: 只有基本的 HTML 和 CSS。

  • 增强内容: JavaScript、代码高亮、全部的 CSS、网络字体和评论评分。

  • 其他内容: 统计、广告和头像。

一旦你有了这个简单的内容/功能的优先级列表,提高性能只需要增加一些代码片段,正确反映这些优先级。即使你的服务器逻辑强制所有设备都加载全部的资源,专注于内容传输优先,也可以保证内容被快速地访问到。从战略角度来看,该列表还反映了你的技术债,以及可能拖慢速度的关键问题。事实上,这方面我们有很多问题需要处理,所以它很快被转换为优先级列表。一个非常棘手的问题就在列表的最上端:Web 字体。

延迟 Web 字体

尽管我们杂志的读者在移动设备上使用的比例一直不是很大(仅为15%左右,主要是由于文章的篇幅),我们从来没有将移动端作为事后才考虑的问题,当然,我们也没有专门为移动端优化用户体验。当我们谈论移动用户体验时,我们主要是谈速度,因为排版从第一天起就设计得很好。

在2012年重新设计期间,我们讨论了如何处理字体,始终找不到一个让所有人都满意的解决方案。内容的视觉外观很重要,因为新的 Smashing 杂志都是美观、丰富的排版,移动端不加载网络字体完全不能接受。

根据当时的设计,我们的标题用 Skolar 字体,内容用 Proxima Nova 字体,字体由 Frontdeck 提供。每种字体有普通、斜体、粗体三种字型,共有 6 个字体文件通过网络传输。虽然我们的朋友提炼优化了字体,资源依然很重,总共超过了300KB。并且因为我们想避免未定义样式的文字闪动,我们在每个页面的页头都加载了。一开始我们觉得字体会很好地保存在 HTTP 缓存中,不会在每个页面都加载。然而事实证明,HTTP 缓存相当不可靠,无论是在桌面端还是在移动端,字体会时不时地出现在瀑布加载图中,也没有什么明显的原因。

最大的问题当然是字体会阻塞渲染,即使 HTML、CSS 和 JavaScript 已经完全加载,字体加载和渲染完成前,内容也不会出现。所以你就有了这种美好的体验,首先看到链接的下划线,然后几个加粗的关键字零星分散在各处,然后副标题出现在页面的中间,最终其他文字开始展示出来。在某些情况下,当 Frontdeck 服务器遇到问题时,内容根本就不会出现,即使它已经位于 DOM 中等待显示。

图像说明文字

在这篇文章《Web字体和关键路径》中, Ian Feather 提供了 FOUT 问题的详细概述和多个字体加载方案,我们都做了测试。

我们尝试了几个解决方案,最后选择了一个最终证明是最困难的方案。一开始我们尝试了使用 Typekit 和谷歌的 WebFontLoader,这是一个异步脚本,可以更好地控制加载字体时页面上显示的内容。基本上是用脚本向body 元素添加了几个类,这允许你在加载过程中和加载字体后,指定 CSS 中内容的样式。因此在切换成网络字体前,你可以准确地了解内容使用了哪个候选字体显示。

我们添加了后备字体的声明,iOS 字体、Android 字体、Windows Phone 字体,还有谷歌的网络安全字体,最终写出了非常冗长的 CSS 字体列表——我们今天仍在使用这些字体列表。例如:我们用下面的样式定义主标题字体(这反映了我们统计的移动操作系统的受欢迎程度)。

h2 {
   font-family: "Skolar Bold",
   AvenirNext-Bold, "Avenir Bold",
   "Roboto Slab", "Droid Serif",
   "Segoe UI Bold",
   Georgia, "Times New Roman", Times, serif;
}

因此读者会先看到一个移动操作系统的字体(或者其他的后备字体),这个字体很可能就是,他们非常熟悉当前设备的字体。然后一旦网络字体加载完成,你会看到一个切换,由 WebFontLoader 触发。然而用这种方法以后,我们会频繁发现页面闪动,HTTP 缓存不可靠,每次都从后备字体切换到网络字体,相当讨厌,从根本上破坏了阅读体验。

所以我们寻找替代方案。一个方案是通过将 @font-face 指令包含在媒体查询中,只在大屏幕展示(如果在媒体查询中声明网络字体,他们只会在媒体查询匹配屏幕尺寸时加载,所以没有体验问题)。显然这样会立即解决移动端的问题,但是我们觉得“简化”移动端的阅读体验是不对的,所以也没有采用这个方案。

我们还能怎么做呢?唯一的另一个选择就是改进字体的缓存。对 HTTP 缓存我们不能做什么,还有一个选项我们没有研究过:缓存字体到 AppCache 或者 LocalStorage,Jake Archibald 关于 AppCache 复杂性的文章让我们放弃 AppCache,开始研究 LocalStorage,这也是卫报团队当时正在使用的一种技术

现在,离线缓存有一个重要的需求:你需要有实际的字体文件,以便在浏览器本地缓存。而且你不能缓存很多,因为 LocalStorage 的空间非常有限,有时每个域只有 5MB 可用。幸运的是,Fontdeck 的家伙给了我们很大帮助,并向我们承诺,即使字体传输服务通常需要加载文件,并且有一个同步或异步的回调来计算展示次数,他们完全同意我们从谷歌浏览器的缓存中抓取字体文件,根据历史记录中的网页展示次数定价。

所以我们抓取了 WOFF 文件,并将它们嵌入到单个 CSS 文件中,从 6 个 HTTP 请求(每个文件大约 50KB)变成最多一个 HTTP 请求(大约 400KB 的CSS)。显然我们不希望每次访问都加载这个文件。因此,如果 LocalStorage 可用,我们将整个 CSS 文件存储在 LocalStorage 中,设置 Cookie,并从后备字体切换到 Web 字体。因此切换通常只发生一次,因为后续访问,我们会检查是否已经设置 Cookie,如果是,就从 LocalStorage 取字体(会导致大约50毫秒延迟),并立即用Web字体显示内容。是的,读写LocalStorage 比从HTTP缓存取文件慢得多,但是在我们的测试中,这样更可靠。

图像说明文字

是的,localStorage 比 HTTP 缓存慢得多,但它更可靠。 在 localStorage 中存储字体不是完美的解决方案,但它可以帮助我们大幅度提高性能。

如果浏览器不支持 LocalStorage,就使用老式的外链字体,我们只能抱着最好的期望,希望字体被正确地缓存,并且持久地存在于用户浏览器缓存中。对于不支持 WOFF 的浏览器,我们提供由 Fontdeck 托管的旧格式字体的链接。

现在,如果 LocalStorage 可用,我们仍然不希望它阻止内容的呈现。我们也不希望在用户每次加载页面时都看到 FOUT。这就是为什么我们在 body 之前的 header 有一段 JavaScript 代码:他检查是否已经设置了 Cookie,如果没有设置 Cookie,页面开始渲染之后异步加载 Web 字体。当然我们也可以不切换,只是在第一次访问时,在 LocalStorage 存储字体,并且在第一次访问期间不切换。但我们决定一次切换是可以接受的,因为我们的排版,对我们的形象很重要。

这段代码由我的朋友 Horia Dragomir 编写,测试,文档说明,当然,也可以在 gist 看到:

<script type="text/javascript">
    (function () {
      "use strict";
      // once cached, the css file is stored on the client forever unless
      // the URL below is changed. Any change will invalidate the cache
      var css_href = './web-fonts.css';
      // a simple event handler wrapper
      function on(el, ev, callback) {
        if (el.addEventListener) {
          el.addEventListener(ev, callback, false);
        } else if (el.attachEvent) {
          el.attachEvent("on" + ev, callback);
        }
      }

      // if we have the fonts in localStorage or if we've cached them using the native browser cache
      if ((window.localStorage && localStorage.font_css_cache) || document.cookie.indexOf('font_css_cache') > -1){
        // just use the cached version
        injectFontsStylesheet();
      } else {
       // otherwise, don't block the loading of the page; wait until it's done.
        on(window, "load", injectFontsStylesheet);
      }

      // quick way to determine whether a css file has been cached locally
      function fileIsCached(href) {
        return window.localStorage && localStorage.font_css_cache && (localStorage.font_css_cache_file === href);
      }

      // time to get the actual css file
      function injectFontsStylesheet() {
       // if this is an older browser
        if (!window.localStorage || !window.XMLHttpRequest) {
          var stylesheet = document.createElement('link');
          stylesheet.href = css_href;
          stylesheet.rel = 'stylesheet';
          stylesheet.type = 'text/css';
          document.getElementsByTagName('head')[0].appendChild(stylesheet);
          // just use the native browser cache
          // this requires a good expires header on the server
          document.cookie = "font_css_cache";

        // if this isn't an old browser
        } else {
           // use the cached version if we already have it
          if (fileIsCached(css_href)) {
            injectRawStyle(localStorage.font_css_cache);
          // otherwise, load it with ajax
          } else {
            var xhr = new XMLHttpRequest();
            xhr.open("GET", css_href, true);
            on(xhr, 'load', function () {
              if (xhr.readyState === 4) {
                // once we have the content, quickly inject the css rules
                injectRawStyle(xhr.responseText);
                // and cache the text content for further use
                // notice that this overwrites anything that might have already been previously cached
                localStorage.font_css_cache = xhr.responseText;
                localStorage.font_css_cache_file = css_href;
              }
            });
            xhr.send();
          }
        }
      }

      // this is the simple utitily that injects the cached or loaded css text
      function injectRawStyle(text) {
        var style = document.createElement('style');
        style.innerHTML = text;
        document.getElementsByTagName('head')[0].appendChild(style);
      }

    }());</script>

测试期间,我们发现了一些意外的问题。因为缓存在 WebView 中不是持久的,所以字体在诸如 Tweetdeck 和 Facebook 之类的应用中会异步加载,但是一旦窗口关闭,他们就不会保留在缓存中。换句话说,每次访问 WebView 时,字体都会重新下载。一些旧的 Blackberry 设备似乎会清除 Cookie,并在电池电量耗尽时删除缓存。根据设备的配置,有时字体在手机版 Safari 中也不会持久存在。

总之这段代码上线以后,文章开始渲染变得更快。通过延迟加载 Web 字体,并将其存储在 LocalStorage 中,我们减少了大约 700ms 的延迟,从而显著缩短了关键渲染路径。对于未缓存页面的第一次加载,结果非常出色,对多次访问更加出色,因为我们将 Web 字体造成的延迟减少到 40~50ms 。如果我们对网站性能的改进只提一点的话,推迟 Web 字体是迄今为止最有效的。

当前我们甚至还没有考虑使用新的 WOFF2 格式字体。目前只有在 Chrome 和 Opera 支持,它能更好地压缩字体文件,而且已经显示出了显著的成果。实际上,卫报团队通过切换到新字体,能够减少 200ms 的延迟和 50KB 的文件大小,我们打算尽快切换到 WOFF2。

当然了,你不一定能用抓取字体文件这种方法,了解我们的问题以及知道了怎样在本地缓存字体也不会有害处。另外 WebFontLoader 配合 Typekit 和 Fontdeck 的方案也值得一试。

处理JavaScript

为了从关键渲染路径中移除所有不必要的资源,我们决定处理的第二个目标是 JavaScript。并不是因为特别的原因我们不喜欢 JavaScript,而是我们更倾向于用非 JavaScript 的方案。如果我们可以避免 JavaScript,或者可以用 CSS 方案替换,我们会去探索这个选项。

回到2012年,我们的页面上并没有太多脚本,展示广告依赖于 jQuery。这样就有很多立即可用的懒加载插件。当时我们还在旧浏览器中用 Response.js 模拟响应式行为。然而,在2012~2014年,IE8的使用量大幅下降。在重新设计时是4.7%,现在则是1.43%,每个月都有下降趋势。因此我们决定为这些用户提供一个单独的 IE8.css 样式表,固定宽度布局,完全删除了 Response.js 。

作为一个战略决策,我们决定推迟所有 JavaScript 的加载,直到页面开始渲染,我们研究使用轻量级的模块化 JavaScript 组件替换 jQuery 。

jQuery 与广告紧紧地绑在一起,而广告应该尽可能快地开始显示,所以我们不得不首先处理广告。延迟加载广告的决定并不容易取得一致,但我们设法拿出了可信的论据,即更好的性能会提高点击率,因为用户会更快地看到内容。也就是说,在每一页上,读者都会被高质量的内容吸引,然后广告突然展示,就会更注意边栏中的那些方块。

我们的广告合作伙伴 Florian Sander 重写了我们的横幅广告脚本,在内容开始渲染之后才开始加载,然后再将广告展示到对应位置。Florian 去除了两个由广告脚本生成的阻塞渲染的 HTTP 请求,用纯 JavaScript 重写,也去掉了对 jQuery 的依赖。

因为侧边栏的广告是动态生成的,文档树构建完成后才开始渲染,我们开始看到回流了(页面构建过程中还会继续发生)。因为以前我们在内容之前加载广告,因此整个页面几乎所有内容都是一次加载完成。现在我们已经转移到更加模块化的结构,将页面的特定部分组合在一起,让他们排队加载。显然这样使得网站的整体体验有点差,因为,不时地会有些地方发生跳跃,比如侧边栏、评论,还有页脚。这是我们所做的一个妥协。我们正在研究一个解决方案,为跳跃的元素预留空间,以避免页面加载时的回流。

推迟非关键的JavaScript

当移除 jQuery 成为现实的长期目标时,我们开始逐步从库中解耦 jQuery 依赖。我们重写了生成打印脚注样式的脚本(后来我们换成了 PHP 方案)。重写了评论评分功能,并且重写了其他的脚本。实际上,凭借我们聪明的用户群和稳定的智能浏览器份额,我们能很迅速地转向纯 JavaScript。此外,我们把从页头到页脚的脚本都移除,以避免阻塞 DOM 树的构造。在7月中旬,我们从代码库中完全删除了 jQuery。

我们想要完全控制页面上加载的内容和加载时间。具体来说,我们希望确保没有任何 JavaScript 阻碍页面渲染。我们使用脚本异步加载技术,在 DOM 和 CSSOM 都构建完成,页面绘制之后,在 Load 事件中注入加载脚本。以下是我们网站使用的代码片段(在 Load事件之后异步加载)。

function downloadJSAtOnload() {
   var element = document.createElement("script");
   element.src = "defer.js";
   document.body.appendChild(element);
}
if (window.addEventListener)
   window.addEventListener("load", downloadJSAtOnload, false);
else if (window.attachEvent)
   window.attachEvent("onload", downloadJSAtOnload);
else
   window.onload = downloadJSAtOnload;

然而,由脚本注入的异步脚本被认为是有害的,并且速度缓慢(它们阻止浏览器的推测解析器),我们可能会换用经典的 deferasync 属性。 在过去,我们不能对所有脚本使用异步,因为我们需要 jQuery 加载其依赖项; 所以我们使用 defer,它遵守脚本的加载顺序。 不使用 jQuery 以后,我们现在可以异步加载脚本,并且速度更快了。 实际上当你阅读这篇文章的时候,我们可能已经在使用async了。

基本上,我们推迟了之前提到过的所有 JavaScript,语法高亮、评论打分等,并且为 HTML 和 CSS 清理了 header。

内联关键CSS

虽然性能提高了,但是还不够好。即使做了所有这些优化,我们的速度指数依然没有降到神奇的数值 1000 以下。我们在讨论谷歌推荐的内联 CSS 和首屏 CSS 时,研究了更激进的加快速度的方案。为了避免在加载 CSS 时出现 HTTP 请求,我们测试了如果我们将关键 CSS 内联,然后在页面渲染后加载 CSS 的其余部分,网站的速度变化。

图像说明文字

Scott Jehl的 文章解释了如何提取和内联关键 CSS。

究竟什么才是关键CSS?怎样从代码库中提取它们?正如 Scott Jehl指出的 ,关键 CSS 是 CSS 的子集,渲染页面顶部所有元素需要的样式。这意味着什么呢?你需要决定一个高度,把这部分内容当作头版内容,它可能是600、800、1200像素或者其他高度,你需要获取指定这部分内容展示的所有样式,还要考虑各种屏幕宽度。

然后你在网页头部内联这些样式,从而在一个 HTTP 请求中,就给浏览器渲染第一屏所需的所有内容。到现在你应该已经听过几次了:首屏以外的内容都延迟加载。减少了一次 HTTP 请求,再异步加载完整的 CSS,这样用户滚动页面时完整的 CSS 已经(希望)加载完成。

在视觉上,内容将会呈现得更快,但也会有更多的回流和跳动。所以如果用户跟随一个链接直接打开首屏下面的评论部分,就会看到几次回流。因为网站正在构建中,页面是使用关键 CSS 渲染的(这部分内容只能放进去 14KB),然后页面会根据完整的 CSS 进行调整。当然内敛的样式是不缓存的,所以如果关键 CSS 和完整的 CSS 都加载了,可以设置一个 Cookie 标记,内联样式就不用每次都加载。缺点就是可能有重复的样式,因为你将样式定义在关键 CSS 和完整的 CSS 中,除非你能将它们完全分开。

因为我们刚刚重构了我们的 CSS 代码库,识别关键 CSS 并不是很困难。显然有一些智能 工具,分析结构和样式,识别关键的 CSS,并在构建过程中将它们导出到一个单独的文件,但是我们手动做就可以了。同样要记住,14KB 是你的结构和样式的预算,所以最终我们不得不处重命名许多类并压缩 CSS 。

我们分析了第一个800像素,检查了所需要的 CSS 选择器,并将我们的样式分成两个文件,这样就基本上完成了。其中一个文件 above-the-fold.css 被精简压缩,把其中的内容内联到页面头部尽可能靠上的位置,不阻塞渲染。另一个文件,我们完整的样式文件,在页面加载完成后跟 JavaScript 一起加载。还有如果 JavaScript 由于某种原因不可用,或者用户是在旧版浏览器,我们把完整的 CSS 文件放到页头 noscript 标签中,用户就不会看到无样式的页面了。

这样做值得吗

因为我们刚刚实施了这些优化,因此我们无法衡量对流量的影响,我们稍后会发布这些结果。我们很明显注意到一个非常显著的技术改进,通过推迟和缓存 Web 字体,内联和优化第一个 14KB 的关键渲染路径,我们加载时间有显著改进。在3G网络未缓存页面,开始渲染时间在 1s 左右,后续访问的加载时间、包括延迟才耗时约 700ms 。

图像说明文字

我们一直在使用 WebPageTest 做测试,我们的瀑布图随着时间的推移变得更好,反映了我们之前定义的优先级。

平均来说,Smashing Magazine 首页有 45 个 HTTP 请求,并且第一次未缓存的访问,加载 440KB 。由于我们对广告之外的所有内容都进行了高度的缓存,因此后续访问有大约 15 个 HTTP 请求和 180KB 的流量。第一个字节时间仍然是 300~600ms(确实很多),但是启动渲染时间在DSL链接上是 0.7s(第一次,未缓存的访问),在慢速的 3G 低于 1.7s 在高速宽带 0.8s 开始渲染,;快速的 3G 在 1.1s 以内。显然,结果很大程度上取决于第一个字节时间,在写本文的现在,我们还不能改进。这是唯一一个不可预测,但是对整体性能有决定影响的因素。

只遵从我们同事提出的上述指南和谷歌的建议,我们就能在桌面设备和移动设备上,得到 Google PageSpeed 的97~99分。分数根据侧边栏现实的广告资源质量和优化级别浮动。然后,现在的主要问题就是服务器相应时间了,虽然,这个问题也不会持续太长时间。

图像说明文字

几项优化后,移动端在 Google PageSpeed 得分为 99

图像说明文字

桌面端在 Google PageSpeed 也得分为 99

顺便说一下,Scott Jehl 还发表了一篇技术文章,关于 FilamentGroup 提取关键CSS、内联加载,然后再加载完整的 CSS 来减少负载。Patrick Hamann 的演讲解释了卫报正在使用的技术,让 SpeedIndex 达到 1000 大关。绝对值得阅读和观看,与我们在这个网站上的实践非常类似。

余下的工作

虽然我们已经对取得的成果相当满意,但仍有许多工作要做。例如,我们还没有优化图片的传输,为了加快移动端的加载速度,我们正在调整我们的编辑过程,用 Picturefill 2.1.0 给图片加上新的 srcset/sizes。目前只是简单地将所有的图片固定宽度为 500 像素,在较小的界面都是缩小显示。图片都有优化和压缩,但是没有为不同设备提供不同的图片,是的,我们没有任何 Retina 图片。这就是马上就要做的修改。

虽然 Smashing Magazine 的主页经过了优化,但是一些页面和文章页的表现仍然不佳。评论多的文章页非常慢,因为我们使用 Gravatar 头像。因为每个 Gravatar 链接都是唯一的,每个评论都有一个 HTTP 请求,所以减慢了整个网页的速度。我们会用 WordPress 插件推迟头像加载,并本地缓存。看到这篇文章的时候可能已经做了。

我们正在使用 DNS 预查和 HTML5 预加载来提前查找 DNS(例如,Gravatar 和广告)。但是我们在这里很谨慎、很犹豫,因为我们不想为用户提前加载慢速或者昂贵的链接。此外我们还增加了第三方 meta data,让分享文章更容易。因此如果你分享文章到 Facebook,Facebook 会从我们的元数据中拉取优化的图片、描述和标题,每篇文章都是独立的内容。我们还高兴的注意到,即使图片和广告相对有些大,文章页面还是以 60fps 平滑滚动。

图像说明文字

是的,我们只需要安装 NginxApache 模块,SPDY 就可以用了,这是我们下一步要做的。

虽然我们做了这么多优化,但主要问题还没有解决:非常慢的服务器和第一个字节响应时间。我们在使用当前的服务器设置和架构方面遇到了困难,但是我们签的是长期的合同。我们很快会迁移到新的服务器上,会利用这次机会迁移到 SPDY。顺便说一句,这也是 HTTP 2.0 的前身,在主流浏览器中得到了很好的支持,我们也在计划使用 CDN。

性能优化策略

总而言之,优化 Smashing Magzine 还是做出了相当大的努力,同时很多优化是可以很快实现的,特别是当你对优先级有共同的理解的时候,前端的优化就会很容易立竿见影。是的,我说的优先级就是:优化内容传输,推迟其他一切。

从战略上来说,以下可以作为你的性能优化路线图。

  • 从页头删除阻塞的脚本。
  • 找到并推迟非关键的 CSS 和 JavaScript。
  • 找到关键 CSS 并内联到 head 中,然后在开始渲染后加载完整的 CSS(请务必设置 Cookie,以防止每次都加载内联样式)。
  • 所有关键的 HTML 和 CSS 保持在 14KB 以下,并且速度指数低于 1000。
  • 延迟加载 Web 字体并将其存储在 LocalStorage 或 AppCache 中。
  • 考虑使用 WOFF2 来进一步减少 Web 字体的延迟和文件大小。
  • 使用精简的 JavaScript 模块替代 JavaScript 库。
  • 避免不必要的库,有选择地删除 Respond.js 和 Modernizr,例如对浏览器分组区别对待,旧版浏览器可能用固定布局,SVG 也有巧妙的回退方案。

基本上就是这些了。遵循这些指南,可以使你的响应式网站非常非常快。

结论

是的,为了找到让这个网站变快的正确策略,我们经历了大量的实验、心血、汗水,甚至咒骂。但我们的讨论继续要围绕下一步怎么做、哪些组件是关键的、哪些是不重要,不断地循环。有时我们不得不“三步一回头”,以便转向不同的方向。但是在这过程中我们学习了很多,现在我们对应该走向何方,以及最重要的——如何到达那里,有了非常清楚的想法。

所以才有了这篇文章——关于这个小网站过去一年发生的小故事。如果你注意到任何问题,请告诉我们,Twitter 联系 @smashingmag ,我们会妥善解决。

还有,感谢这么多年的阅读。这对我们意义重大。你应该知道,你真的很棒。


非常感谢 Patrick Hamann 和 Jake Archibald,感谢你们的技术审查,以及Andy Hume 和 Tim Kadlec 多年来梦幻般的支持。还有大大的感谢送给我们的前端工程师 Marco,对我文章的帮助以及彻底的不知疲倦的前端工作,一路上做了许多成功的、失败的实验。还要感谢 Inpsyde 团队和 Florian Sander 做技术实现。

最后感谢 Iris、Melanie,、Cosima 和 Markus,始终注意那些讨厌的错误、查看网站的内容。没有你们,这个网站不会存在。感谢你们一直以来对我的支持。我尊重和珍视你们的每一点工作。你们太酷了。

本文由Smashing Magazine独家授权异步社区发布简体中文版。版权所有 禁止转载或建立镜像。

0 推荐
  • yukun 2016-12-14 16:44

    Ilya Grigorik的书《High Performance Browser Networking》中文版已经在2014年由人民邮电出版社出版,中译名《Web性能权威指南》。

    0
异步社区走刀口
异步社区走刀口 V4

北京 北京

902经验值

相关文章