每个参与过开发企业级 web 应用的前端工程师或许都曾思考过前端性能优化方面的问题。我们有雅虎 14 条性能优化原则,还有两本很经典的性能优化指导书:《高性能网站建设指南》、《高性能网站建设进阶指南》。经验丰富的工程师对于前端性能优化方法耳濡目染,基本都能一一列举出来。这些性能优化原则大概是在 7 年前提出的,对于 web 性能优化至今都有非常重要的指导意义。
然而,对于构建大型 web 应用的团队来说,要坚持贯彻这些优化原则并不是一件十分容易的事。因为优化原则中很多要求是与工程管理相违背的,比如“把 css 放在头部”和“把 js 放在尾部”这两条原则,我们不能让团队的工程师在写样式和脚本引用的时候都去修改一个相同的页面文件。这样做会严重影响团队成员间并行开发的效率,尤其是在团队有版本管理的情况下,每天要花大量的时间进行代码修改合并,这项成本是难以接受的。因此在前端工程界,总会看到周期性的性能优化工作,辛勤的前端工程师们每到月圆之夜就会倾巢出动根据优化原则做一次性能优化。
本文从一个全新的视角来思考 web 性能优化与前端工程之间的关系,通过解读百度前端集成解决方案小组(F.I.S)在打造高性能前端架构并统一百度 40 多条前端产品线的过程中所经历的技术尝试,揭示前端性能优化在前端架构及开发工具设计层面的实现思路。
性能优化原则及分类
笔者先假设本文的读者是有前端开发经验的工程师,并对企业级 web 应用开发及性能优化有一定的思考,因此我不会重复介绍雅虎 14 条性能优化原则。如果您没有这些前续知识,请移步这里来学习。
首先,我们把雅虎14 条优化原则,《高性能网站建设指南》以及《高性能网站建设进阶指南》中提到的优化点做一次梳理,按照优化方向分类,可以得到这样一张表格:
优化方向
优化手段
请求数量
合并脚本和样式表,CSS Sprites,拆分初始化负载,划分主域
请求带宽
开启GZip,精简JavaScript,移除重复脚本,图像优化
缓存利用
使用CDN,使用外部JavaScript 和CSS,添加Expires 头,减少DNS 查找,配置ETag,使AjaX 可缓存
页面结构
将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出
代码校验
避免CSS 表达式,避免重定向
表格1 性能优化原则分类
目前大多数前端团队可以利用 yui compressor 或者 google closure compiler 等压缩工具很容易做到“精简 Javascript”这条原则;同样的,也可以使用图片压缩工具对图像进行压缩,实现“图像优化”原则。这两条原则是对单个资源的处理,因此不会引起任何工程方面的问题。很多团队也通过引入代码校验流程来确保实现“避免 css 表达式”和“避免重定向”原则。目前绝大多数互联网公司也已经开启了服务端的 Gzip 压缩,并使用 CDN 实现静态资源的缓存和快速访问;一些技术实力雄厚的前端团队甚至研发出了自动 CSS Sprites 工具,解决了 CSS Sprites 在工程维护方面的难题。使用“查找 - 替换”思路,我们似乎也可以很好的实现“划分主域”原则。
我们把以上这些已经成熟应用到实际生产中的优化手段去除掉,留下那些还没有很好实现的优化原则。再来回顾一下之前的性能优化分类:
优化方向
优化手段
请求数量
合并脚本和样式表,拆分初始化负载
请求带宽
移除重复脚本
缓存利用
添加 Expires 头,配置 ETag,使 Ajax 可缓存
页面结构
将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出
表格 2 较难实现的优化原则
现在有很多顶尖的前端团队可以将上述还剩下的优化原则也都一一解决,但业界大多数团队都还没能很好的解决这些问题。因此,本文将就这些原则的解决方案做进一步的分析与讲解,从而为那些还没有进入前端工业化开发的团队提供一些基础技术建设意见,也借此机会与业界顶尖的前端团队在工业化工程化方向上交流一下彼此的心得。
静态资源版本更新与缓存
如表格 2 所示,“缓存利用”分类中保留了“添加 Expires 头”和“配置 ETag”两项。或许有些人会质疑,明明这两项只要配置了服务器的相关选项就可以实现,为什么说它们难以解决呢?确实,开启这两项很容易,但开启了缓存后,我们的项目就开始面临另一个挑战:如何更新这些缓存。
相信大多数团队也找到了类似的答案,它和《高性能网站建设指南》关于“添加 Expires 头”所说的原则一样——修订文件名。即:
最有效的解决方案是修改其所有链接,这样,全新的请求将从原始服务器下载最新的内容。
思路没错,但要怎么改变链接呢?变成什么样的链接才能有效更新缓存,又能最大限度避免那些没有修改过的文件缓存不失效呢?
先来看看现在一般前端团队的做法:
或者
大家会采用添加 query 的形式修改链接。这样做是比较直观的解决方案,但在访问量较大的网站,这么做可能将面临一些新的问题。
通常一个大型的 web 应用几乎每天都会有迭代和更新,发布新版本也就是发布新的静态资源和页面的过程。以上述代码为例,假设现在线上运行着 index.html 文件,并且使用了线上的 a.js 资源。index.html 的内容为:
这次我们更新了页面中的一些内容,得到一个 index.html 文件,并开发了新的与之匹配的 a.js 资源来完成页面交互,新的 index.html 文件的内容因此而变成了:
好了,现在要开始将两份新的文件发布到线上去。可以看到,index.html 和 a.js 的资源实际上是要覆盖线上的同名文件的。不管怎样,在发布的过程中,index.html 和 a.js 总有一个先后的顺序,从而中间出现一段或大或小的时间间隔。对于一个大型互联网应用来说即使在一个很小的时间间隔内,都有可能出现新用户访问。在这个时间间隔中,访问了网站的用户会发生什么情况呢?
- 如果先覆盖 index.html,后覆盖 a.js,用户在这个时间间隙访问,会得到新的 index.html 配合旧的 a.js 的情况,从而出现错误的页面。
- 如果先覆盖 a.js,后覆盖 index.html,用户在这个间隙访问,会得到旧的 index.html 配合新的 a.js 的情况,从而也出现了错误的页面。
这就是为什么大型 web 应用在版本上线的过程中经常会较集中的出现前端报错日志的原因,也是一些互联网公司选择加班到半夜等待访问低峰期再上线的原因之一。此外,由于静态资源文件版本更新是“覆盖式”的,而页面需要通过修改 query 来更新,对于使用 CDN 缓存的 web 产品来说,还可能面临 CDN 缓存攻击的问题。我们再来观察一下前面说的版本更新手段:
我们不难预测,a.js 的下一个版本是“1.0.1”,那么就可以刻意构造一串这样的请求“a.js?v=1.0.1”、“a.js?v=1.0.2”、……让 CDN 将当前的资源缓存为“未来的版本”。这样当这个页面所用的资源有更新时,即使更改了链接地址,也会因为 CDN 的原因返回给用户旧版本的静态资源,从而造成页面错误。即便不是刻意制造的攻击,在上线间隙出现访问也可能导致区域性的 CDN 缓存错误。
此外,当版本有更新时,修改所有引用链接也是一件与工程管理相悖的事,至少我们需要一个可以“查找 - 替换”的工具来自动化的解决版本号修改的问题。
对付这个问题,目前来说最优方案就是基于文件内容的hash版本冗余机制了。也就是说,我们希望工程师源码是这么写的:
但是线上代码是这样的:
其中”_82244e91”这串字符是根据 a.js 的文件内容进行 hash 运算得到的,只有文件内容发生变化了才会有更改。由于版本序列是与文件名写在一起的,而不是同名文件覆盖,因此不会出现上述说的那些问题。同时,这么做还有其他的好处:
- 线上的 a.js 不是同名文件覆盖,而是文件名 +hash 的冗余,所以可以先上线静态资源,再上线 html 页面,不存在间隙问题;
- 遇到问题回滚版本的时候,无需回滚 a.js,只须回滚页面即可;
- 由于静态资源版本号是文件内容的 hash,因此所有静态资源可以开启永久强缓存,只有更新了内容的文件才会缓存失效,缓存利用率大增;
- 修改静态资源后会在线上产生新的文件,一个文件对应一个版本,因此不会受到构造 CDN 缓存形式的攻击
虽然这种方案是相比之下最完美的解决方案,但它无法通过手工的形式来维护,因为要依靠手工的形式来计算和替换 hash 值,并生成相应的文件。这将是一项非常繁琐且容易出错的工作,因此我们需要借助工具。我们下面来了解一下 fis 是如何完成这项工作的。
首先,之所以有这种工具需求,完全是由 web 应用运行的根本机制决定的:web 应用所需的资源是以字面的形式通知浏览器下载而聚合在一起运行的。这种资源加载策略使得 web 应用从本质上区别于传统桌面应用的版本更新方式。为了实现资源定位的字面量替换操作,前端构建工具理论上需要识别所有资源定位的标记,其中包括:
- css 中的 @import url(path)、background:url(path)、backgournd-image:url(path)、filter 中的 src
- js 中的自定义资源定位函数,在 fis 中我们将其规定为 __uri(path)。
- html 中的
评论