每个参与过开发企业级web应用的前端工程师或许都曾思考过前端性能优化方面的问题。我们有雅虎14条性能优化原则,还有两本很经典的性能优化指导书:《高性能网站建设指南》、《高性能网站建设进阶指南》。经验丰富的工程师对于前端性能优化方法耳濡目染,基本都能一一列举出来。这些性能优化原则大概是在7年前提出的,对于web性能优化至今都有非常重要的指导意义。
然而,对于构建大型web应用的团队来说,要坚持贯彻这些优化原则并不是一件十分容易的事。因为优化原则中很多要求是与工程管理相违背的,比如 把css放在头部 和 把js放在尾部 这两条原则,我们不能让团队的工程师在写样式和脚本引用的时候都去修改一个相同的页面文件。这样做会严重影响团队成员间并行开发的效率,尤其是在团队有版本管理的情况下,每天要花大量的时间进行代码修改合并,这项成本是难以接受的。因此在前端工程界,总会看到周期性的性能优化工作,辛勤的前端工程师们每到月圆之夜就会倾巢出动根据优化原则做一次性能优化。
性能优化是一个工程问题
本文将从一个全新的视角来思考web性能优化与前端工程之间的关系,揭示前端性能优化在前端架构及开发工具设计层面的实现思路。
性能优化原则及分类po主先假设本文的读者是有前端开发经验的工程师,并对企业级web应用开发及性能优化有一定的思考,因此我不会重复介绍雅虎14条性能优化原则。如果您没有这些前续知识,请移步 这里 来学习。
首先,我们把雅虎14条优化原则,《高性能网站建设指南》以及《高性能网站建设进阶指南》中提到的优化点做一次梳理,按照优化方向分类,可以得到这样一张表格:
优化方向优化手段
请求数量 合并脚本和样式表,css sprites,拆分初始化负载,划分主域
请求带宽 开启gzip,精简javascript,移除重复脚本,图像优化
缓存利用 使用cdn,使用外部javascript和css,添加expires头,
减少dns查找,配置etag,使ajax可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出
代码校验 避免css表达式,避免重定向
目前大多数前端团队可以利用 yui compressor 或者 google closure compiler 等压缩工具很容易做到 精简javascript这条原则;同样的,也可以使用图片压缩工具对图像进行压缩,实现 图像优化 原则。这两条原则是对单个资源的处理,因此不会引起任何工程方面的问题。很多团队也通过引入代码校验流程来确保实现 避免css表达式 和 避免重定向 原则。目前绝大多数互联网公司也已经开启了服务端的gzip压缩,并使用cdn实现静态资源的缓存和快速访问;一些技术实力雄厚的前端团队甚至研发出了自动css sprites工具,解决了css sprites在工程维护方面的难题。使用“查找-替换”思路,我们似乎也可以很好的实现 划分主域 原则。
我们把以上这些已经成熟应用到实际生产中的优化手段去除掉,留下那些还没有很好实现的优化原则。再来回顾一下之前的性能优化分类:
优化方向优化手段
请求数量 合并脚本和样式表,拆分初始化负载
请求带宽 移除重复脚本
缓存利用 添加expires头,配置etag,使ajax可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出
有很多顶尖的前端团队可以将上述还剩下的优化原则也都一一解决,但业界大多数团队都还没能很好的解决这些问题。因此,本文将就这些原则的解决方案做进一步的分析与讲解,从而为那些还没有进入前端工业化开发的团队提供一些基础技术建设意见,也借此机会与业界顶尖的前端团队在工业化工程化方向上交流一下彼此的心得。
静态资源版本更新与缓存缓存利用 分类中保留了 添加expires头 和 配置etag 两项。或许有些人会质疑,明明这两项只要配置了服务器的相关选项就可以实现,为什么说它们难以解决呢?确实,开启这两项很容易,但开启了缓存后,我们的项目就开始面临另一个挑战: 如何更新这些缓存?
相信大多数团队也找到了类似的答案,它和《高性能网站建设指南》关于“添加expires头”所说的原则一样——修订文件名。即:
最有效的解决方案是修改其所有链接,这样,全新的请求将从原始服务器下载最新的内容。
思路没错,但要怎么改变链接呢?变成什么样的链接才能有效更新缓存,又能最大限度避免那些没有修改过的文件缓存不失效呢?
先来看看现在一般前端团队的做法:
<h1>hello world</h1>
<script type=text/javascript src=a.js?t=201404231123></script>
<script type=text/javascript src=b.js?t=201404231123></script>
<script type=text/javascript src=c.js?t=201404231123></script>
<script type=text/javascript src=d.js?t=201404231123></script>
<script type=text/javascript src=e.js?t=201404231123></script>ps: 也有团队采用构建版本号为静态资源请求添加query,它们在本质上是没有区别的,在此就不赘述了。
接下来,项目升级,比如页面上的html结构发生变化,对应还要修改 a.js 这个文件,得到的构建结果如下:
<header>hello world</header>
<script type=text/javascript src=a.js?t=201404231826></script>
<script type=text/javascript src=b.js?t=201404231826></script>
<script type=text/javascript src=c.js?t=201404231826></script>
<script type=text/javascript src=d.js?t=201404231826></script>
<script type=text/javascript src=e.js?t=201404231826></script>为了触发用户浏览器的缓存更新,我们需要更改静态资源的url地址,如果采用构建信息(时间戳、版本号等)作为url修改的依据,如上述代码所示,我们只修改了一个a.js文件,但再次构建会让所有请求都更改了url地址,用户再度访问页面那些没有修改过的静态资源的(b.js,b.js,c.js,d.js,e.js)的浏览器缓存也一同失效了。
使用构建信息作为静态资源更新标记会导致每次构建发布后所有静态资源都被迫更新,浏览器缓存利用率降低,给性能带来伤害。
此外,采用添加query的方式来清除缓存还有一个弊端,就是 覆盖式发布 的上线问题。
采用query更新缓存的方式实际上要覆盖线上文件的,index.html和a.js总有一个先后的顺序,从而中间出现一段或大或小的时间间隔。尤其是当页面是后端渲染的模板的时候,静态资源和模板是部署在不同的机器集群上的,上线的过程中,静态资源和页面文件的部署时间间隔可能会非常长,对于一个大型互联网应用来说即使在一个很小的时间间隔内,都有可能出现新用户访问。在这个时间间隔中,访问了网站的用户会发生什么情况呢?
如果先覆盖index.html,后覆盖a.js,用户在这个时间间隙访问,会得到新的index.html配合旧的a.js的情况,从而出现错误的页面。如果先覆盖a.js,后覆盖index.html,用户在这个间隙访问,会得到旧的index.html配合新的a.js的情况,从而也出现了错误的页面。这就是为什么大型web应用在版本上线的过程中经常会较集中的出现前端报错日志的原因,也是一些互联网公司选择加班到半夜等待访问低峰期再上线的原因之一。
对于静态资源缓存更新的问题,目前来说最优方案就是 基于文件内容的hash版本冗余机制 了。也就是说,我们希望项目源码是这么写的:
<script type=text/javascript src=a.js></script>发布后代码变成
<script type=text/javascript src=a_8244e91.js></script>也就是a.js发布出来后被修改了文件名,产生一个新文件,并不是覆盖已有文件。其中”_82244e91”这串字符是根据a.js的文件内容进行hash运算得到的,只有文件内容发生变化了才会有更改。由于将文件发布为带有hash的新文件,而不是同名文件覆盖,因此不会出现上述说的那些问题。同时,这么做还有其他的好处:
上线的a.js不是同名文件覆盖,而是文件名+hash的冗余,所以可以先上线静态资源,再上线html页面,不存在间隙问题;