为了不被喷得太惨,给标题加了这么多的限制定语也是相当不容易的了。此文讨论的是我所处的环境下对JavaScript构建的一些简单探索,因此有相当多的前提限制。
首先,何为大型。从我们的系统来看,20多个业务模块,近100个页面组成的单页系统,对应的业务源码代码量如下:
对应的依赖库,除underscore
和moment
外均为公司内部库,代码量为:
其次,所谓的“模块化”指我们使用AMD进行构建,使用符合社区AMD标准的Loader进行模块的加载。
而“PC 端单页式商业内容管理系统”则代表着系统的不少特性:
第一个问题是,AMD有自然的按需加载的属性,按需加载也是一直被提倡的一种模式。那么,如果不进行任何的构建,让模块自然地按需加载,是否可行?
如果看了这个图,你还相信按需加载的话,可以停止此文的阅读了:
简单来说,按需加载与构建并不冲突,我们不能将所有资源最细粒度地使用按需加载进行管理,必要的构建来减少资源请求是必要的。
随之而来的,我们会考虑标准的代码合并方案。相当多的站点会将所有的JavaScript合并为一个文件,这也是最简单粗暴有效的方案。
但是对于大型的单页系统而言,所有JavaScript合并后生成的文件会非常之巨大,其体积在浏览器单线程的下载模式下已经成为系统的性能瓶颈。因此我们需要一些更好的策略,让系统的启动性能得以优化。
最后,常用于业界的还有一种方案,即自动化的运行时合并。通过在服务器端配置一个处理程序,可以运行时检测需要文件的依赖,进行依赖打包并响应至客户端。
这种方案有其成本小、透明化等多方面的优势,但在精益求精的场景下仍旧略有不足。其最大的缺点是当有2个以上模块依赖同一个模块时,被依赖模块可能会被重复打包到多份.js
文件中,造成不必要的网络传输。
当然有很多的方法解决这一问题,诸如在Session中记录用户已经拥有的模块,或由客户端记录并提供已有模块列表,来保证打包过程不会加入无用的模块。但这些方法会提升一定的开发成本,同时前后端合作才可以完成的方案往往在推进上会遇到一些小阻碍。
基于这些原因,从前端静态化的构建入手,在构建阶段实现较为优化的打包方案,是现阶段我们采取的策略。
从系统运行时来分析,对于JavaScript的构建,可以提出以下的原则。
“请求段”是一个很模糊的概念,简单来说,在一个带宽足够的环境下,我们并不看重运行时产生了多少个请求,而是看重这些请求在瀑布图中被分为几段。由于浏览器并行加载的特性,系统真正的可用时间是由段的数量和每一个段的时间来决定的。
对于常见的浏览器,其并行加载的请求个数为4-6个,也即一个段可以加入4-6个的请求。从分段越少越好的角度来考虑,我们规划的系统启动分为3个段:
.css
文件可以在这个阶段加载,以避免影响后面更重量级的.js
的加载效率。段的用时 = 段内加载总大小 / 并发数
这一公式考虑,应尽可能将模块打包为浏览器可接受的最大并发数个文件。从我们的系统来看,仅看JavaScript的资源,很明显地分为3段进行加载(红线分隔):
从经验值来看,单个资源的大小尽量控制在未Gzip前500KB以内,过大的文件会成为瓶颈,除非你可以很好地规划一个HTTP请求段,使得一个大文件加载的用时内浏览器会利用另一个TCP链接加载多个小文件。
Gzip对纯文本文件的压缩率一般在16%左右,文件的形式和内容不会对此比率造成特别大的影响。如果使用Linux或OSX系统,可以简单地使用以下命令来看一个文件Gzip后的近似大小:
1 | gzip -c {file} | wc -c |
同时,由于短板效应的存在,一个段的加载时间会由这个段内最大的资源决定,因此尽量使得各资源的大小相近。
并不是所有的文件都需要合并在一起,也不是所有资源都可以按需加载,对于请求数量的控制并不仅仅体现在系统启动时,也贯穿整个系统的使用流程,来提供用户一致的性能体验。
我对一个大型CMS系统的请求数量的总结可以概括为3点:
基于以上的这些原则,我在系统中有进一步的实践。
将JavaScript文件分为多个“启动脚本”,一个启动脚本中会包含一系列的模块,现有系统中我将启动脚本分割为4个,分别为:
ECharts
之类的图表库。需要特别注意的是,各启动脚本间应该保持完美的正交,即不应该有任何一个模块被重复合并到多个脚本中。
除了启动脚本外,前面也有提到单一页面应该尽可能只加载一个资源,因此页面相关会被打包在一起,比较典型的是将Controller、Model和View合并到Controller对应的文件中。
由于二级页面并不能确定哪一个会被先访问,因此各页面打包文件中是会存在一定的模块重复的,经典如util
模块就会同时被列表、表单、只读等页使用。这会导致加载多个页面时部分资源被重复加载,但是此类资源通常体积很小,产生的副作用在可控范围内。
在文件加载这一方向上,需要有一个特别的处理。由于AMD的依赖管理和运行时依赖分析功能,通过Loader的require
函数加载一个模块的化,Loader会自动分析依赖并通过零碎的HTTP请求去请求相关的资源,而无视这些资源是否可能被下一个脚本打包在一起。
用一个实例来说明,我们的依赖关系为a -> b
以及c -> b
,即b
模块是一个通用模块,被两边所依赖。当我们将a
和c
分开打包为2个文件时,b
会出现在其中一个中(为了实现完美正交)。假设b
被打包在a.js
中,那么当c.js
被Loader加载时,如果a.js
还未就位,就会产生一个单独的HTTP请求b.js
。由于并行下载时,谁先完成是不可预知的,就有很大的可能性产生无意义的零碎请求。
解决这一问题的方法是,使用