前端工程和性能优化
每个参与过开发企业级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头”所说的原则一样——修订文件名。即:
思路没错,但要怎么改变链接呢?变成什么样的链接才能有效更新缓存,又能最大限度避免那些没有修改过的文件缓存不失效呢? 先来看看现在一般前端团队的做法:
接下来,项目升级,比如页面上的html结构发生变化,对应还要修改 a.js 这个文件,得到的构建结果如下:
为了触发用户浏览器的缓存更新,我们需要更改静态资源的url地址,如果采用构建信息(时间戳、版本号等)作为url修改的依据,如上述代码所示,我们只修改了一个a.js文件,但再次构建会让所有请求都更改了url地址,用户再度访问页面那些没有修改过的静态资源的(b.js,b.js,c.js,d.js,e.js)的浏览器缓存也一同失效了。
此外,采用添加query的方式来清除缓存还有一个弊端,就是 覆盖式发布 的上线问题。
采用query更新缓存的方式实际上要覆盖线上文件的,index.html和a.js总有一个先后的顺序,从而中间出现一段或大或小的时间间隔。尤其是当页面是后端渲染的模板的时候,静态资源和模板是部署在不同的机器集群上的,上线的过程中,静态资源和页面文件的部署时间间隔可能会非常长,对于一个大型互联网应用来说即使在一个很小的时间间隔内,都有可能出现新用户访问。在这个时间间隔中,访问了网站的用户会发生什么情况呢?
这就是为什么大型web应用在版本上线的过程中经常会较集中的出现前端报错日志的原因,也是一些互联网公司选择加班到半夜等待访问低峰期再上线的原因之一。 对于静态资源缓存更新的问题,目前来说最优方案就是 基于文件内容的hash版本冗余机制 了。也就是说,我们希望项目源码是这么写的:
也就是a.js发布出来后被修改了文件名,产生一个新文件,并不是覆盖已有文件。其中”_82244e91”这串字符是根据a.js的文件内容进行hash运算得到的,只有文件内容发生变化了才会有更改。由于将文件发布为带有hash的新文件,而不是同名文件覆盖,因此不会出现上述说的那些问题。同时,这么做还有其他的好处:
虽然这种方案是相比之下最完美的解决方案,但它无法通过手工的形式来维护,因为要依靠手工的形式来计算和替换hash值,并生成相应的文件,将是一项非常繁琐且容易出错的工作,因此我们需要借助工具来处理。 用grunt来实现md5功能是非常困难的,因为grunt只是一个task管理器,而md5计算需要构建工具具有递归编译的能,而不是简单的任务调度。考虑这样的例子:
由于我们的资源版本号是通过对文件内容进行hash运算得到,如上图所示,index.html中引用的a.css文件的内容其实也包含了a.png的hash运算结果,因此我们在修改index.html中a.css的引用时,不能直接计算a.css的内容hash,而是要先计算出a.png的内容hash,替换a.css中的引用,得到了a.css的最终内容,再做hash运算,最后替换index.html中的引用。
grunt等task-based的工具是很难在task之间协作处理这样的需求的。 在解决了基于内容hash的版本更新问题之后,我们可以将所有前端静态资源开启永久强缓存,每次版本发布都可以首先让静态资源全量上线,再进一步上线模板或者页面文件,再也不用担心各种缓存和时间间隙的问题了! 静态资源管理与模块化框架 解决了静态资源缓存问题之后,让我们再来看看前面的优化原则表还剩些什么: 优化方向优化手段请求数量合并脚本和样式表,拆分初始化负载 请求带宽移除重复脚本 缓存利用使Ajax可缓存 页面结构将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出 很不幸,剩下的优化原则都不是使用工具就能很好实现的。或许有人会辩驳:“我用某某工具可以实现脚本和样式表合并”。嗯,必须承认,使用工具进行资源合并并替换引用或许是一个不错的办法,但在大型web应用,这种方式有一些非常严重的缺陷,来看一个很熟悉的例子 :
某个web产品页面有A、B、C三个资源
工程师根据“减少HTTP请求”的优化原则合并了资源
产品经理要求C模块按需出现,此时C资源已出现多余的可能
C模块不再需要了,注释掉吧!代码1秒钟搞定,但C资源通常不敢轻易剔除
不知不觉中,性能优化变成了性能恶化……
事实上,使用工具在线下进行静态资源合并是无法解决资源按需加载的问题的。如果解决不了按需加载,则必会导致资源的冗余;此外,线下通过工具实现的资源合并通常会使得资源加载和使用的分离,比如在页面头部或配置文件中写资源引用及合并信息,而用到这些资源的html组件写在了页面其他地方,这种书写方式在工程上非常容易引起维护不同步的问题,导致使用资源的代码删除了,引用资源的代码却还在的情况。因此,在工业上要实现资源合并至少要满足如下需求:
将以上要求综合考虑,不难发现,单纯依靠前端技术或者工具处理是很难达到这些理想要求的。 接下来我会讲述一种新的模板架构设计,用以实现前面说到那些性能优化原则,同时满足工程开发和维护的需要,这种架构设计的核心思想就是:
考虑一段这样的页面代码:
在页面的头部插入一个html注释 <!--[CSS LINKS PLACEHOLDER]--> 作为占位,而将原来字面书写的资源引用改成模板接口 require_static 调用,该接口负责收集页面所需资源。 require_static接口实现非常简单,就是准备一个数组,收集资源引用,并且可以去重。最后在页面输出的前一刻,我们将require_static在运行时收集到的 a.css、b.css、c.css 三个资源拼接成html标签,替换掉注释占位 <!--[CSS LINKS PLACEHOLDER]-->,从而得到我们需要的页面结构。 经过实践总结,可以发现模板层面只要实现三个开发接口,就可以比较完美的实现目前遗留的大部分性能优化原则,这三个接口分别是:
实现了这些接口之后,一个重构后的模板页面的源代码可能看起来就是这样的了:
不难看出,我们目前已经实现了 按需加载,将脚本放在底部,将样式表放在头部 三项优化原则。 前面讲到静态资源在上线后需要添加hash戳作为版本标识,那么这种使用模板语言来收集的静态资源该如何实现这项功能呢?
考虑这样的目录结构:
这个 /??file1,file2,file3,… 的url请求响应就是动态combo服务提供的,它的原理很简单,就是根据url找到对应的多个文件,合并成一个文件来响应请求,并将其缓存,以加快访问速度。 这种方法很巧妙,有些服务器甚至直接集成了这类模块来方便的开启此项服务,这种做法也是大多数大型web应用的资源合并做法。但它也存在一些缺陷:
对于上述第二条缺陷,可以举个例子来看说明:
很明显,如果combo服务能聪明的知道A页面使用的资源引用为 /??a,b 和 /??c,d,而B页面使用的资源引用为 /??a,b 和 /??e,f就好了。这样当用户在访问A页面之后再访问B页面时,只需要下载B页面的第二个combo文件即可,第一个文件已经在访问A页面时缓存好了的。 基于这样的思考,我们在资源表上新增了一个字段,取名为 pkg,就是资源合并生成的新资源,表的结构会变成:
相比之前的表,可以看到新表中多了一个pkg字段,并且记录了打包后的文件所包含的独立资源。这样,我们重新设计一下 require_static、load_widget 这两个模板接口,实现这样的逻辑:
比如执行require_static('bootstrap.js'),查表得知bootstrap.js被打包在了p1中,因此取出p1包的url/pkg/lib_cef213d.js,并且记录页面已加载了 jquery.js 和 bootstrap.js 两个资源。这样一来,之前的模板代码执行之后得到的html就变成了:
虽然这种策略请求有4个,不如combo形式的请求少,但可能在统计上是性能更好的方案。由于两个lib打包的文件修改的可能性很小,因此这两个请求的缓存利用率会非常高,每次项目发布后,用户需要重新下载的静态资源可能要比combo请求节省很多带宽。
此时,我们又引入了一个新的问题:如何决定哪些文件被打包? 从经验来看,项目初期可以采用人工配置的方式来指定打包情况,比如:
但随着系统规模的增大,人工配置会带来非常高的维护成本,此时需要一个辅助系统,通过分析线上访问日志和静态资源组合加载情况来自动生成这份配置文件,系统设计如图:
至此,我们通过基于表的静态资源管理系统和三个模板接口实现了几个重要的性能优化原则,现在我们再来回顾一下前面的性能优化原则分类表,剔除掉已经做到了的,看看还剩下哪些没做到的: 优化方向优化手段请求数量拆分初始化负载 缓存利用使Ajax可缓存 页面结构尽早刷新文档的输出 拆分初始化负载 的目标是将页面一开始加载时不需要执行的资源从所有资源中分离出来,等到需要的时候再加载。工程师通常没有耐心去区分资源的分类情况,但我们可以利用组件化框架接口来帮助工程师管理资源的使用。还是从例子开始思考,如果我们有一个js文件是用户交互后才需要加载的,会怎样呢:
很明显,dialog.js 这个文件我们不需要在初始化的时候就加载,因此它应该在后续的交互中再加载,但文件都加了md5戳,我们如何能在浏览器环境中知道加载的url呢?
我就不多解释代码的执行过程了,大家看到完整的html输出就能理解是怎么回事了:
dialog.js不会在页面以script src的形式输出,而是变成了资源注册,这样,当页面点击触发require.async执行的时候,async函数才会查表找到资源的url并加载它,加载完毕后触发回调函数。 到目前为止,我们又以架构的形式实现了一项优化原则(拆分初始化负载),回顾我们的优化分类表,现在仅有两项没能做到了: 优化方向优化手段缓存利用使Ajax可缓存 页面结构尽早刷新文档的输出 剩下的两项优化原则要做到并不容易,真正可缓存的Ajax在现实开发中比较少见,而 尽早刷新文档的输出原则facebook在2010年的velocity上 提到过,就是BigPipe技术。当时facebook团队还讲到了Quickling和PageCache两项技术,其中的PageCache算是比较彻底的实现Ajax可缓存的优化原则了。由于篇幅关系,就不在此展开了,后续还会撰文详细解读这两项技术。 总结 其实在前端开发工程管理领域还有很多细节值得探索和挖掘,提升前端团队生产力水平并不是一句空话,它需要我们能对前端开发及代码运行有更深刻的认识,对性能优化原则有更细致的分析与研究。在前端工业化开发的所有环节均有可节省的人力成本,这些成本非常可观,相信现在很多大型互联网公司也都有了这样的共识。 |