简单-前端工程与性能优化

性能优化方向分类

  • 请求数量:
  • 合并脚本和样式表,
  • CSS Sprites,
  • 拆分初始化负载,
  • 划分主域(使用“查找-替换”思路,我们似乎也可以很好的实现 划分主域
    原则)
  • 请求带宽:
  • 开启GZip (开启了服务端的Gzip压缩)
  • 精简JavaScript(利用 yui compressor 或者 google closure compiler 等压缩工具很容易做到 ),
  • 移除重复脚本,
  • 图像优化(也可以使用图片压缩工具对图像进行压缩,实现 图像优化
    原则)
  • 缓存利用:
  • 使用CDN(实现静态资源的缓存和快速访问),
  • 使用外部Javascript和Css,
  • 添加Expires,
  • 减少DNS查找,
  • 配置ETag,
  • 使用Ajax
  • 页面结构:
  • 将样式表放在顶部,
  • 尽早刷新文档的输出
  • 代码校验:
  • 避免CSS表达式(一些技术实力雄厚的前端团队甚至研发出了自动CSS Sprites工具,解决了CSS Sprites在工程维护方面的难题),
  • 避免重定向(通过引入代码校验流程来确保实现 避免css表达式和 避免重定向原则)

名词解释

CSS Sprites【在国内很多人叫css精灵,是一种网页图片应用处理方式。它允许你将一个页面涉及到的所有零星图片都包含到一张大图中去,这样一来,当访问该页面时,载入的图片就不会像以前那样一幅一幅地慢慢显示出来了。对于当前网络流行的速度而言,不高于200KB的单张图片的所需载入时间基本是差不多的,所以无需顾忌这个问题】

把以上这些已经成熟应用到实际生产中的优化手段去除掉,留下那些还没有很好实现的优化原则。再来回顾一下之前的性能优化分类:

  • 请求数量: 合并脚本和样式表,拆分初始化负载
  • 请求带宽 :移除重复脚本
  • 缓存利用:添加Expries头,配置ETag,使用Ajax可缓存
  • 页面结构: 将样式表放在头部,将脚本放在底部,尽早刷新文档的输出

静态资源版本更新与缓存

添加Expires头 和 配置ETag两项只要配置了服务器的相关选项就可以实现但是问题在于开启缓存后如何更新思路:最有效的解决方案是修改其所有链接,这样,全新的请求将从原始服务器下载最新的内容
但要怎么改变链接呢?变成什么样的链接才能有效更新缓存,又能最大限度避免那些没有修改过的文件缓存不失效呢?

先来看看现在一般前端团队的做法:

hello world

也有团队采用构建版本号为静态资源请求添加query,它们在本质上是没有区别的

接下来,项目升级,比如页面上的html结构发生变化,对应还要修改 a.js 这个文件,得到的构建结果如下:

hello world

为了触发用户浏览器的缓存更新,我们需要更改静态资源的url地址,如果采用构建信息(时间戳、版本号等)作为url修改的依据,如上述代码所示,我们只修改了一个a.js文件,但再次构建会让所有请求都更改了url地址,用户再度访问页面那些没有修改过的静态资源的(b.js,b.js,c.js,d.js,e.js)的浏览器缓存也一同失效了。使用构建信息作为静态资源更新标记会导致每次构建发布后所有静态资源都被迫更新,浏览器缓存利用率降低,给性能带来伤害

此外,采用添加query的方式来清除缓存还有一个弊端,就是 覆盖式发布的上线问题

简单-前端工程与性能优化_第1张图片
覆盖式发布

采用query更新缓存的方式实际上要覆盖线上文件的,index.html和a.js总有一个先后的顺序,从而中间出现一段或大或小的时间间隔。尤其是当页面是后端渲染的模板的时候,静态资源和模板是部署在不同的机器集群上的,上线的过程中,静态资源和页面文件的部署时间间隔可能会非常长,对于一个大型互联网应用来说即使在一个很小的时间间隔内,都有可能出现新用户访问。在这个时间间隔中,访问了网站的用户会发生什么情况呢?

  • 如果先覆盖index.html,后覆盖a.js,用户在这个时间间隙访问,会得到新的index.html配合旧的a.js的情况,从而出现错误的页面。
  • 如果先覆盖a.js,后覆盖index.html,用户在这个间隙访问,会得到旧的index.html配合新的a.js的情况,从而也出现了错误的页面。

这就是为什么大型web应用在版本上线的过程中经常会较集中的出现前端报错日志的原因,也是一些互联网公司选择加班到半夜等待访问低峰期再上线的原因之一。

对于静态资源缓存更新的问题,目前来说最优方案就是 基于文件内容的hash版本冗余机制
了。也就是说,我们希望项目源码是这么写的:


发布后代码变成


也就是a.js发布出来后被修改了文件名,产生一个新文件,并不是覆盖已有文件。其中”_82244e91”这串字符是根据a.js的文件内容进行hash运算得到的,只有文件内容发生变化了才会有更改。由于将文件发布为带有hash的新文件,而不是同名文件覆盖,因此不会出现上述说的那些问题。同时,这么做还有其他的好处:

  • 上线的a.js不是同名文件覆盖,而是文件名+hash的冗余,所以可以先上线静态资源,再- 上线html页面,不存在间隙问题;
  • 遇到问题回滚版本的时候,无需回滚a.js,只须回滚页面即可;
    由于静态资源版本号是文件内容的hash,因此所有静态资源可以开启永久强缓存,只有更新了内容的文件才会缓存失效,缓存利用率大增

以文件内容的hash值为依据生产新文件的非覆盖式发布策略是解决静态资源缓存更新最有效的手段。####

虽然这种方案是相比之下最完美的解决方案,但它无法通过手工的形式来维护,因为要依靠手工的形式来计算和替换hash值,并生成相应的文件,将是一项非常繁琐且容易出错的工作,因此我们需要借助工具来处理。
用grunt来实现md5功能是非常困难的,因为grunt只是一个task管理器,而md5计算需要构建工具具有递归编译的能,而不是简单的任务调度。考虑这样的例子:

简单-前端工程与性能优化_第2张图片
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中的引用。
计算index.html中引用的a.css文件的url过程:

  • 压缩a.png后计算其内容的md5值
  • 将a.png的md5写入a.css,再压缩a.css,计算其内容的md5值
  • 将a.css的md5值写入到index.html中

grunt等task-based的工具是很难在task之间协作处理这样的需求的。在解决了基于内容hash的版本更新问题之后,我们可以将所有前端静态资源开启永久强缓存,每次版本发布都可以首先让静态资源全量上线,再进一步上线模板或者页面文件,再也不用担心各种缓存和时间间隙的问题了!

静态资源管理与模块化框架
剩余问题:

  • 请求数量: 合并脚本和样式表,拆分初始化负载
  • 请求带宽 :移除重复脚本
  • 缓存利用:使用Ajax可缓存
  • 页面结构: 将样式表放在头部,将脚本放在底部,尽早刷新文档的输出

剩下的优化原则都不是使用工具就能很好实现的,使用工具进行资源合并并替换引用或许是一个不错的办法,但在大型web应用,这种方式有一些非常严重的缺陷,来看一个很熟悉的例子 :

简单-前端工程与性能优化_第3张图片
第一天

某个web产品页面有A、B、C三个资源
简单-前端工程与性能优化_第4张图片
第二天

工程师根据“减少HTTP请求”的优化原则合并了资源
简单-前端工程与性能优化_第5张图片
第三天

产品经理要求C模块按需出现,此时C资源已出现多余的可能
简单-前端工程与性能优化_第6张图片
第四天

C模块不再需要了,注释掉吧!代码1秒钟搞定,但C资源通常不敢轻易剔除
简单-前端工程与性能优化_第7张图片
后来

不知不觉中,性能优化变成了性能恶化……
这个例子来自 Facebook静态网页资源的管理和优化@Velocity China 2010

事实上,使用工具在线下进行静态资源合并是无法解决资源按需加载的问题的。如果解决不了按需加载,则必会导致资源的冗余;此外,线下通过工具实现的资源合并通常会使得资源加载和使用的分离,比如在页面头部或配置文件中写资源引用及合并信息,而用到这些资源的html组件写在了页面其他地方,这种书写方式在工程上非常容易引起维护不同步的问题,导致使用资源的代码删除了,引用资源的代码却还在的情况。因此,在工业上要实现资源合并至少要满足如下需求:

  • 确实能减少HTTP请求,这是基本要求(合并)
  • 在使用资源的地方引用资源(就近依赖),不使用不加载(按需)虽然资源引用不是集中书写的,但资源引用的代码最终还能出现在页面头部(css)或尾部(js)
    能够避免重复加载资源(去重)
    将以上要求综合考虑,不难发现,单纯依靠前端技术或者工具处理是很难达到这些理想要求的。

接下来我会讲述一种新的模板架构设计,用以实现前面说到那些性能优化原则,同时满足工程开发和维护的需要,这种架构设计的核心思想就是:
基于依赖关系表的静态资源管理系统与模块化框架设计

考虑一段这样的页面代码:

 
page 
 

 
 
content of module a
content of module b
content of module c

根据资源合并需求中的第二项,我们希望资源引用与使用能尽量靠近,这样将来维护起来会更容易一些,因此,理想的源码是:



 page

 

 
content of module a
content of module b
content of module c

当然,把这样的页面直接送达给浏览器用户是会有严重的页面闪烁问题的,所以我们实际上仍然希望最终页面输出的结果还是如最开始的截图一样,将css放在头部输出。这就意味着,页面结构需要有一些调整,并且有能力收集资源加载需求,那么我们考虑一下这样的源码(以php为例):



 page 


 
 
content of module a
content of module b
content of module c

在页面的头部插入一个html注释 作为占位,而将原来字面书写的资源引用改成模板接口 require_static 调用,该接口负责收集页面所需资源。require_static接口实现非常简单,就是准备一个数组,收集资源引用,并且可以去重。最后在页面输出的前一刻,我们将require_static在运行时收集到的 a.css、b.css,c.css 三个资源拼接成html标签,替换掉注释占位
,从而得到我们需要的页面结构。

经过实践总结,可以发现模板层面只要实现三个开发接口,就可以比较完美的实现目前遗留的大部分性能优化原则,这三个接口分别是:

  • require_static(res_id):收集资源加载需求的接口,参数是静态资源id。
  • load_widget(wiget_id):加载拆分成小组件模板的接口。你可以叫它为widget,component或者pagelet之类的。总之,我们需要一个接口把一个大的页面模板拆分成一个个的小部分来维护,最后在原来的页面中以组件为单位来加载这些小部件。
  • script(code):收集写在模板中的js脚本,使之出现的页面底部,从而实现性能优化原则中的 将js放在页面底部 原则。

实现了这些接口之后,一个重构后的模板页面的源代码可能看起来就是这样的了:

 
page
  

  


 

  

 


而最终在模板解析的过程中,资源收集与去重、页面script收集、占位符替换操作,最终从服务端发送出来的html代码为:

 
page 
 
 

 


 
content of module a
content of module b
content of module c

不难看出,我们目前已经实现了 按需加载,将脚本放在底部,将样式表放在头部 三项优化原则。
前面讲到静态资源在上线后需要添加hash戳作为版本标识,那么这种使用模板语言来收集的静态资源该如何实现这项功能呢?

答案是:静态资源依赖关系表。###

考虑这样的目录结构:

![]CI6%_0FBW4.png](http://upload-images.jianshu.io/upload_images/1058258-e4067324e4a4c04e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

如果我们可以使用工具扫描整个project目录,然后创建一张资源表,同时记录每个资源的部署路径,得到这样的一张表:

简单-前端工程与性能优化_第8张图片

基于这张表,我们就很容易实现 require_static(file_id),load_widget(widget_id)
这两个模板接口了。以load_widget为例:

简单-前端工程与性能优化_第9张图片

利用查表来解决md5戳的问题,这样,我们的页面最终送达给用户的结果就是这样的:

简单-前端工程与性能优化_第10张图片

接下来,我们讨论基于表的设计思想上是如何实现静态资源合并的。或许有些团队使用过combo服务,也就是我们在最终拼接生成页面资源引用的时候,并不是生成多个独立的link标签,而是将资源地址拼接成一个url路径,请求一种线上的动态资源合并服务,从而实现减少HTTP请求的需求,比如前面的例子,稍作调整即可得到这样的结果:

简单-前端工程与性能优化_第11张图片

这个 /??file1,file2,file3,… 的url请求响应就是动态combo服务提供的,它的原理很简单,就是根据url找到对应的多个文件,合并成一个文件来响应请求,并将其缓存,以加快访问速度。
这种方法很巧妙,有些服务器甚至直接集成了这类模块来方便的开启此项服务,这种做法也是大多数大型web应用的资源合并做法。但它也存在一些缺陷:

  • 浏览器有url长度限制,因此不能无限制的合并资源。
  • 如果用户在网站内有公共资源的两个页面间跳转访问,由于两个页面的combo的url不一样导致用户不能利用浏览器缓存来加快对公共资源的访问速度。
  • 如果combo的url中任何一个文件发生改变,都会导致整个url缓存失效,从而导致浏览器缓存利用率降低。

对于上述第二条缺陷,可以举个例子来看说明:

  • 假设网站有两个页面A和B

  • A页面使用了a,b,c,d四个资源

  • B页面使用了a,b,e,f四个资源

  • 如果使用combo服务,我们会得:

  • A页面的资源引用为:/??a,b,c,d

  • B页面的资源引用为:/??a,b,e,f

  • 两个页面引用的资源是不同的url,因此浏览器会请求两个合并后的资源文件,跨页面访问没能很好的利用a、b这两个资源的缓存。

很明显,如果combo服务能聪明的知道A页面使用的资源引用为 /??a,b
和 /??c,d
,而B页面使用的资源引用为 /??a,b
和 /??e,f
就好了。这样当用户在访问A页面之后再访问B页面时,只需要下载B页面的第二个combo文件即可,第一个文件已经在访问A页面时缓存好了的。基于这样的思考,我们在资源表上新增了一个字段,取名为 pkg,就是资源合并生成的新资源,表的结构会变成:

简单-前端工程与性能优化_第12张图片

相比之前的表,可以看到新表中多了一个pkg字段,并且记录了打包后的文件所包含的独立资源。这样,我们重新设计一下 require_static、load_widget 这两个模板接口,实现这样的逻辑:
在查表的时候,如果一个静态资源有pkg字段,那么就去加载pkg字段所指向的打包文件,否则加载资源本身。

比如执行require_static('bootstrap.js'),查表得知bootstrap.js被打包在了p1中,因此取出p1包的url /pkg/lib_cef213d.js,并且记录页面已加载了 jquery.js 和 bootstrap.js 两个资源。这样一来,之前的模板代码执行之后得到的html就变成了:

![]6PSM{F1%%UED4R.png](http://upload-images.jianshu.io/upload_images/1058258-8bc134c681a0d7f2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

虽然这种策略请求有4个,不如combo形式的请求少,但可能在统计上是性能更好的方案。由于两个lib打包的文件修改的可能性很小,因此这两个请求的缓存利用率会非常高,每次项目发布后,用户需要重新下载的静态资源可能要比combo请求节省很多带宽。

性能优化既是一个工程问题,又是一个统计问题。优化性能时如果只关注一个页面的首次加载是很片面的。还应该考虑全站页面间跳转、项目迭代后更新资源等情况下的优化策略。###

此时,我们又引入了一个新的问题:如何决定哪些文件被打包?
从经验来看,项目初期可以采用人工配置的方式来指定打包情况,比如:


简单-前端工程与性能优化_第13张图片

但随着系统规模的增大,人工配置会带来非常高的维护成本,此时需要一个辅助系统,通过分析线上访问日志和静态资源组合加载情况来自动生成这份配置文件,系统设计如图:

简单-前端工程与性能优化_第14张图片

至此,我们 通过基于表的静态资源管理系统和三个模板接口实现了几个重要的性能优化原则,现在我们再来回顾一下前面的性能优化原则分类表,剔除掉已经做到了的,看看还剩下哪些没做到的:

  • 请求数量: 拆分初始化负载
  • 缓存利用:使用Ajax可缓存
  • 页面结构:尽早刷新文档的输出

拆分初始化负载 的目标是将页面一开始加载时不需要执行的资源从所有资源中分离出来,等到需要的时候再加载。工程师通常没有耐心去区分资源的分类情况,但我们可以利用组件化框架接口来帮助工程师管理资源的使用。还是从例子开始思考,如果我们有一个js文件是用户交互后才需要加载的,会怎样呢:

 
page 
 

 
 


  
 
 

 
 
 


很明显,dialog.js 这个文件我们不需要在初始化的时候就加载,因此它应该在后续的交互中再加载,但文件都加了md5戳,我们如何能在浏览器环境中知道加载的url呢?

答案就是:把静态资源表的一部分输出在页面上,供前端模块化框架加载静态资源。

我就不多解释代码的执行过程了,大家看到完整的html输出就能理解是怎么回事了:

 page
 
 <
/head>
 
content of module a
content of module b
content of module c

dialog.js不会在页面以script src的形式输出,而是变成了资源注册,这样,当页面点击触发require.async执行的时候,async函数才会查表找到资源的url并加载它,加载完毕后触发回调函数。以上框架示例我实现了一个java-jsp版的,有兴趣的同学请看这里:https://github.com/fouber/fis-java-jsp

到目前为止,我们又以架构的形式实现了一项优化原则(拆分初始化负载),回顾我们的优化分类表,现在仅有两项没能做到了:

  • 缓存利用:使用Ajax可缓存
  • 页面结构:尽早刷新文档的输出

剩下的两项优化原则要做到并不容易,真正可缓存的Ajax在现实开发中比较少见,而 尽早刷新文档的输出原则facebook在2010年的velocity上 提到过,就是BigPipe技术。当时facebook团队还讲到了Quickling和PageCache两项技术,其中的PageCache算是比较彻底的实现Ajax可缓存的优化原则了。由于篇幅关系,就不在此展开了,后续还会撰文详细解读这两项技术。
总结
其实在前端开发工程管理领域还有很多细节值得探索和挖掘,提升前端团队生产力水平并不是一句空话,它需要我们能对前端开发及代码运行有更深刻的认识,对性能优化原则有更细致的分析与研究。在前端工业化开发的所有环节均有可节省的人力成本,这些成本非常可观,相信现在很多大型互联网公司也都有了这样的共识。
问题
1.每个文件改动后生产md5后缀,多次上线后线上会产生:

···
a_xxx1.js
a_xxx2.js
a_xxx3.js
···

也许是我要洁癖,但是这样循环N次后,上线的全量包会越来约大,如何处理这个的?

  • 每次上线,只有修改过的文件才会出现新的md5戳,所以文件冗余没有想象中的那么多
    比较频繁修改的业务模块大概每年会产生100m左右的冗余,预计每3年有必要清理一次
  • 清理的时候,写一个脚本,根据文件名规则找到最后访问的文件然后删除其他的。活着干脆某次上线把发布后的文件之外的其他文件都清理一次,总之这个不成问题

** 2.HTML是后端们JAVA写的动态页面,前端们只写JS,css,然后静态资源发布后,生成了新的md5,那么JAVA写的页面里怎么去获取这个新的MD5,以保证加载正确的静态资源。是要在前端静态文件服务器上搞个监控,把新的MD5存某个地方,然后JAVA那边每次请求页面都要获取下新的MD5,替换生成新的链接?**

java写动态页面不是?不要让他们在java的模板中写这样的代码:


改成写这样的代码:


这个 fis:require
的标签,是扩展了jsp的自定义标签。然后,构建工具扫描前端写的js、css,建立一个map资源表,内容大概是:

{ "a.js" : { 
"url": "/static/js/a_0fa0c3b.js", 
"deps": [ "b.js" ] },
 "b.js" : { 
"url": "/static/js/b_4cb04f9.js" 
}
}

然后,我们把这个资源表和java的动态页面放在一起。前面提到的模板中的那个 fis:require 标签,在模板解释执行的时候,会去查这个map表,根据 a.js 这个资源id找到它的带md5戳的url就是“/static/js/a_0fa0c3b.js”,同时还知道这个文件依赖了 b.js
就顺便把b.js的url也收集起来。
最后,在java动态页面生成html之前,把收集到的两个js标签用字符串替换的方式生成script标签插入到页面上,得到:



有一个项目展示了这个思路的整个实现过程: https://github.com/fouber/fis-java-jsp
**
这个 资源表(map)和fis:require标签是解决这个问题的重点,map是构建工具生成的,通过静态扫描整个前端工程代码得到。map的作用是记录资源的依赖关系和部署路径,然后交给资源管理框架去决定资源加载策略,因此我们最终要把map跟java动态语言部署在一起。fis:require是运行在后端动态模板语言中的资源管理框架,它依赖map表的数据信息,你可以把它理解成一个写在模板引擎中的requirejs。设计这个框架的目的是彻底替

你可能感兴趣的:(简单-前端工程与性能优化)