ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
静态资源管理与模块化框架 解决了静态资源缓存问题之后,让我们再来看看前面的优化原则表还剩些什么: 优化方向 优化手段 请求数量 合并脚本和样式表,拆分初始化负载 请求带宽 移除重复脚本 缓存利用 使Ajax可缓存 页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出 很不幸,剩下的优化原则都不是使用工具就能很好实现的。或许有人会辩驳:“我用某某工具可以实现脚本和样式表合并”。嗯,必须承认,使用工具进行资源合并并替换引用或许是一个不错的办法,但在大型web应用,这种方式有一些非常严重的缺陷,来看一个很熟悉的例子 : 某个web产品页面有A、B、C三个资源 工程师根据“减少HTTP请求”的优化原则合并了资源 产品经理要求C模块按需出现,此时C资源已出现多余的可能 C模块不再需要了,注释掉吧!代码1秒钟搞定,但C资源通常不敢轻易剔除 不知不觉中,性能优化变成了性能恶化…… 这个例子来自 Facebook静态网页资源的管理和优化@Velocity China 2010 事实上,使用工具在线下进行静态资源合并是无法解决资源按需加载的问题的。如果解决不了按需加载,则必会导致资源的冗余;此外,线下通过工具实现的资源合并通常会使得资源加载和使用的分离,比如在页面头部或配置文件中写资源引用及合并信息,而用到这些资源的html组件写在了页面其他地方,这种书写方式在工程上非常容易引起维护不同步的问题,导致使用资源的代码删除了,引用资源的代码却还在的情况。因此,在工业上要实现资源合并至少要满足如下需求: 确实能减少HTTP请求,这是基本要求(合并) 在使用资源的地方引用资源(就近依赖),不使用不加载(按需) 虽然资源引用不是集中书写的,但资源引用的代码最终还能出现在页面头部(css)或尾部(js) 能够避免重复加载资源(去重) 将以上要求综合考虑,不难发现,单纯依靠前端技术或者工具处理是很难达到这些理想要求的。 接下来我会讲述一种新的模板架构设计,用以实现前面说到那些性能优化原则,同时满足工程开发和维护的需要,这种架构设计的核心思想就是: 基于依赖关系表的静态资源管理系统与模块化框架设计 考虑一段这样的页面代码: <html><head> <title>page</title> <link rel="stylesheet" type="text/css" href="a.css"/> <link rel="stylesheet" type="text/css" href="b.css"/> <link rel="stylesheet" type="text/css" href="c.css"/></head><body> <div> content of module a </div> <div> content of module b </div> <div> content of module c </div></body></html> 根据资源合并需求中的第二项,我们希望资源引用与使用能尽量靠近,这样将来维护起来会更容易一些,因此,理想的源码是: <html><head> <title>page</title></head><body> <link rel="stylesheet" type="text/css" href="a.css"/> <div> content of module a </div> <link rel="stylesheet" type="text/css" href="b.css"/> <div> content of module b </div> <link rel="stylesheet" type="text/css" href="c.css"/> <div> content of module c </div></body></html> 当然,把这样的页面直接送达给浏览器用户是会有严重的页面闪烁问题的,所以我们实际上仍然希望最终页面输出的结果还是如最开始的截图一样,将css放在头部输出。这就意味着,页面结构需要有一些调整,并且有能力收集资源加载需求,那么我们考虑一下这样的源码(以php为例): <html><head> <title>page</title> <!--[ CSS LINKS PLACEHOLDER ]--></head><body> <?php require_static('a.css'); ?> <div> content of module a </div> <?php require_static('b.css'); ?> <div> content of module b </div> <?php require_static('c.css'); ?> <div> content of module c </div></body></html> 在页面的头部插入一个html注释 <!--[CSS LINKS PLACEHOLDER]--> 作为占位,而将原来字面书写的资源引用改成模板接口 require_static 调用,该接口负责收集页面所需资源。 requirestatic接口实现非常简单,就是准备一个数组,收集资源引用,并且可以去重。最后在页面输出的前一刻,我们将requirestatic在运行时收集到的 a.css、b.css、c.css 三个资源拼接成html标签,替换掉注释占位 <!--[CSS LINKS PLACEHOLDER]-->,从而得到我们需要的页面结构。 经过实践总结,可以发现模板层面只要实现三个开发接口,就可以比较完美的实现目前遗留的大部分性能优化原则,这三个接口分别是: requirestatic(resid):收集资源加载需求的接口,参数是静态资源id。 loadwidget(wigetid):加载拆分成小组件模板的接口。你可以叫它为widget、component或者pagelet之类的。总之,我们需要一个接口把一个大的页面模板拆分成一个个的小部分来维护,最后在原来的页面中以组件为单位来加载这些小部件。 script(code):收集写在模板中的js脚本,使之出现的页面底部,从而实现性能优化原则中的 将js放在页面底部 原则。 实现了这些接口之后,一个重构后的模板页面的源代码可能看起来就是这样的了: <html><head> <title>page</title> <?php require_static('jquery.js'); ?> <?php require_static('bootstrap.css'); ?> <?php require_static('bootstrap.js'); ?> <!--[ CSS LINKS PLACEHOLDER ]--></head><body> <?php load_widget('a'); ?> <?php load_widget('b'); ?> <?php load_widget('c'); ?> <!--[ SCRIPTS PLACEHOLDER ]--></body></html> 而最终在模板解析的过程中,资源收集与去重、页面script收集、占位符替换操作,最终从服务端发送出来的html代码为: <html><head> <title>page</title> <link rel="stylesheet" type="text/css" href="bootstrap.css"/> <link rel="stylesheet" type="text/css" href="a.css"/> <link rel="stylesheet" type="text/css" href="b.css"/> <link rel="stylesheet" type="text/css" href="c.css"/></head><body> <div> content of module a </div> <div> content of module b </div> <div> content of module c </div> <script type="text/javascript" src="jquery.js"></script> <script type="text/javascript" src="bootstrap.js"></script> <script type="text/javascript" src="a.js"></script> <script type="text/javascript" src="b.js"></script> <script type="text/javascript" src="c.js"></script></body></html> 不难看出,我们目前已经实现了 按需加载,将脚本放在底部,将样式表放在头部 三项优化原则。 前面讲到静态资源在上线后需要添加hash戳作为版本标识,那么这种使用模板语言来收集的静态资源该如何实现这项功能呢? 答案是:静态资源依赖关系表。 考虑这样的目录结构: project - widget - a - a.css - a.js - a.php - b - b.css - b.js - b.php - c - c.css - c.js - c.php - jquery.js - bootstrap.css - bootstrap.js - index.php 如果我们可以使用工具扫描整个project目录,然后创建一张资源表,同时记录每个资源的部署路径,得到这样的一张表: { "res" : { "widget/a/a.css" : "/widget/a/a_1688c82.css", "widget/a/a.js" : "/widget/a/a_ac3123s.js", "widget/b/b.css" : "/widget/b/b_52923ed.css", "widget/b/b.js" : "/widget/b/b_a5cd123.js", "widget/c/c.css" : "/widget/c/c_03cab13.css", "widget/c/c.js" : "/widget/c/c_bf0ae3f.js", "jquery.js" : "/jquery_9151577.js", "bootstrap.css" : "/bootstrap_f5ba12d.css", "bootstrap.js" : "/bootstrap_a0b3ef9.js" }, "pkg" : {}} 基于这张表,我们就很容易实现 require_static(file_id),load_widget(widget_id) 这两个模板接口了。以load_widget为例: function load_widget($id){ //从json文件中读取资源表 $map = load_map(); //查找静态资源 $filename = 'widget/' . $id . '/' . $id; //查找js文件 $js = $filename . '.js'; if(isset($map['res'][$js])) { //如果有对应的js资源,就收集起来 collect_js_static($map['res'][$js]); } //查找css文件 $css = $filename . '.css'; if(isset($map['res'][$css])) { //如果有对应的css资源,就收集起来 collect_css_static($map['res'][$css]); } include $filename . '.php';} 利用查表来解决md5戳的问题,这样,我们的页面最终送达给用户的结果就是这样的: <html><head> <title>page</title> <link rel="stylesheet" type="text/css" href="/bootstrap_f5ba12d.css"/> <link rel="stylesheet" type="text/css" href="/widget/a/a_1688c82.css"/> <link rel="stylesheet" type="text/css" href="/widget/b/b_52923ed.css"/> <link rel="stylesheet" type="text/css" href="/widget/c/c_03cab13.css"/></head><body> <div> content of module a </div> <div> content of module b </div> <div> content of module c </div> <script type="text/javascript" src="/jquery_9151577.js"></script> <script type="text/javascript" src="/bootstrap_a0b3ef9.js"></script> <script type="text/javascript" src="/widget/a/a_ac3123s.js"></script> <script type="text/javascript" src="/widget/b/b_a5cd123.js"></script> <script type="text/javascript" src="/widget/c/c_bf0ae3f.js"></script></body></html> 接下来,我们讨论基于表的设计思想上是如何实现静态资源合并的。或许有些团队使用过combo服务,也就是我们在最终拼接生成页面资源引用的时候,并不是生成多个独立的link标签,而是将资源地址拼接成一个url路径,请求一种线上的动态资源合并服务,从而实现减少HTTP请求的需求,比如前面的例子,稍作调整即可得到这样的结果: <html><head> <title>page</title> <link rel="stylesheet" type="text/css" href="/??bootstrap_f5ba12d.css,widget/a/a_1688c82.css,widget/b/b_52923ed.css,widget/c/c_03cab13.css"/></head><body> <div> content of module a </div> <div> content of module b </div> <div> content of module c </div> <script type="text/javascript" src="/??jquery_9151577.js,bootstrap_a0b3ef9.js,widget/a/a_ac3123s.js,widget/b/b_a5cd123.js,widget/c/c_bf0ae3f.js"></script></body></html> 这个 /??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,就是资源合并生成的新资源,表的结构会变成: { "res" : { "widget/a/a.css" : "/widget/a/a_1688c82.css", "widget/a/a.js" : "/widget/a/a_ac3123s.js", "widget/b/b.css" : "/widget/b/b_52923ed.css", "widget/b/b.js" : "/widget/b/b_a5cd123.js", "widget/c/c.css" : "/widget/c/c_03cab13.css", "widget/c/c.js" : "/widget/c/c_bf0ae3f.js", "jquery.js" : "/jquery_9151577.js", "bootstrap.css" : "/bootstrap_f5ba12d.css", "bootstrap.js" : "/bootstrap_a0b3ef9.js" }, "pkg" : { "p0" : { "url" : "/pkg/lib_cef213d.js", "has" : [ "jquery.js", "bootstrap.js" ] }, "p1" : { "url" : "/pkg/lib_afec33f.css", "has" : [ "bootstrap.css" ] }, "p2" : { "url" : "/pkg/widgets_22feac1.js", "has" : [ "widget/a/a.js", "widget/b/b.js", "widget/c/c.js" ] }, "p3" : { "url" : "/pkg/widgets_af23ce5.css", "has" : [ "widget/a/a.css", "widget/b/b.css", "widget/c/c.css" ] } }} 相比之前的表,可以看到新表中多了一个pkg字段,并且记录了打包后的文件所包含的独立资源。这样,我们重新设计一下 requirestatic、loadwidget 这两个模板接口,实现这样的逻辑: 在查表的时候,如果一个静态资源有pkg字段,那么就去加载pkg字段所指向的打包文件,否则加载资源本身。 比如执行require_static('bootstrap.js'),查表得知bootstrap.js被打包在了p1中,因此取出p1包的url /pkg/lib_cef213d.js,并且记录页面已加载了 jquery.js 和 bootstrap.js 两个资源。这样一来,之前的模板代码执行之后得到的html就变成了: <html><head> <title>page</title> <link rel="stylesheet" type="text/css" href="/pkg/lib_afec33f.css"/> <link rel="stylesheet" type="text/css" href="/pkg/widgets_af23ce5.css"/></head><body> <div> content of module a </div> <div> content of module b </div> <div> content of module c </div> <script type="text/javascript" src="/pkg/lib_cef213d.js"></script> <script type="text/javascript" src="/pkg/widgets_22feac1.js"></script></body></html> 虽然这种策略请求有4个,不如combo形式的请求少,但可能在统计上是性能更好的方案。由于两个lib打包的文件修改的可能性很小,因此这两个请求的缓存利用率会非常高,每次项目发布后,用户需要重新下载的静态资源可能要比combo请求节省很多带宽。 性能优化既是一个工程问题,又是一个统计问题。优化性能时如果只关注一个页面的首次加载是很片面的。还应该考虑全站页面间跳转、项目迭代后更新资源等情况下的优化策略。 此时,我们又引入了一个新的问题:如何决定哪些文件被打包? 从经验来看,项目初期可以采用人工配置的方式来指定打包情况,比如: { "pack" : { "lib.js" : [ "jquery.js", "bootstrap.js" ], "lib.css" : "bootstrap.css", "widgets.js" : "widget/**.js", "widgets.css" : "widget/**.css" }} 但随着系统规模的增大,人工配置会带来非常高的维护成本,此时需要一个辅助系统,通过分析线上访问日志和静态资源组合加载情况来自动生成这份配置文件,系统设计如图: 至此,我们通过基于表的静态资源管理系统和三个模板接口实现了几个重要的性能优化原则,现在我们再来回顾一下前面的性能优化原则分类表,剔除掉已经做到了的,看看还剩下哪些没做到的: 优化方向 优化手段 请求数量 拆分初始化负载 缓存利用 使Ajax可缓存 页面结构 尽早刷新文档的输出 拆分初始化负载 的目标是将页面一开始加载时不需要执行的资源从所有资源中分离出来,等到需要的时候再加载。工程师通常没有耐心去区分资源的分类情况,但我们可以利用组件化框架接口来帮助工程师管理资源的使用。还是从例子开始思考,如果我们有一个js文件是用户交互后才需要加载的,会怎样呢: <html><head> <title>page</title> <?php require_static('jquery.js'); ?> <?php require_static('bootstrap.css'); ?> <?php require_static('bootstrap.js'); ?> <!--[ CSS LINKS PLACEHOLDER ]--></head><body> <?php load_widget('a'); ?> <?php load_widget('b'); ?> <?php load_widget('c'); ?> <?php script('start'); ?> <script> $(document.body).click(function(){ require.async('dialog.js', function(dialog){ dialog.show('you catch me!'); }); }); </script> <?php script('end'); ?> <!--[ SCRIPTS PLACEHOLDER ]--></body></html> 很明显,dialog.js 这个文件我们不需要在初始化的时候就加载,因此它应该在后续的交互中再加载,但文件都加了md5戳,我们如何能在浏览器环境中知道加载的url呢? 答案就是:把静态资源表的一部分输出在页面上,供前端模块化框架加载静态资源。 我就不多解释代码的执行过程了,大家看到完整的html输出就能理解是怎么回事了: <html><head> <title>page</title> <link rel="stylesheet" type="text/css" href="/pkg/lib_afec33f.css"/> <link rel="stylesheet" type="text/css" href="/pkg/widgets_af23ce5.css"/></head><body> <div> content of module a </div> <div> content of module b </div> <div> content of module c </div> <script type="text/javascript" src="/pkg/lib_cef213d.js"></script> <script type="text/javascript" src="/pkg/widgets_22feac1.js"></script> <script> //将静态资源表输出在前端页面中 require.config({ res : { 'dialog.js' : '/dialog_fa3df03.js' } }); </script> <script> $(document.body).click(function(){ //require.async接口查表确定加载资源的url require.async('dialog.js', function(dialog){ dialog.show('you catch me!'); }); }); </script></body></html> 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可缓存的优化原则了。由于篇幅关系,就不在此展开了,后续还会撰文详细解读这两项技术。 总结 其实在前端开发工程管理领域还有很多细节值得探索和挖掘,提升前端团队生产力水平并不是一句空话,它需要我们能对前端开发及代码运行有更深刻的认识,对性能优化原则有更细致的分析与研究。在前端工业化开发的所有环节均有可节省的人力成本,这些成本非常可观,相信现在很多大型互联网公司也都有了这样的共识。 本文只是将这个领域中很小的一部分知识的展开讨论,抛砖引玉,希望能为业界相关领域的工作者提供一些不一样的思路。 来源:http://div.io/topic/371