/ JavaScript

PC端大型单页式商业内容管理系统的JS模块化构建探索

前提

为了不被喷得太惨,给标题加了这么多的限制定语也是相当不容易的了。此文讨论的是我所处的环境下对JavaScript构建的一些简单探索,因此有相当多的前提限制。

首先,何为大型。从我们的系统来看,20多个业务模块,近100个页面组成的单页系统,对应的业务源码代码量如下:

对应的依赖库,除underscoremoment外均为公司内部库,代码量为:

其次,所谓的“模块化”指我们使用AMD进行构建,使用符合社区AMD标准的Loader进行模块的加载。

而“PC端单页式商业内容管理系统”则代表着系统的不少特性:

  • 使用是相对强制的,对用户来说这是一项工作,而不是爱用不用的用户产品。
  • 商业公司通常拥有较好的网络环境,面向PC设计更使得带宽不是一个需要着重考虑的因素。
  • 单页系统使得所有功能被包含在一个HTML页面内,不存在页间的跳转,因此资源不以页面为单位进行切分。

为何要构建

第一个问题是,AMD有自然的按需加载的属性,按需加载也是一直被提倡的一种模式。那么,如果不进行任何的构建,让模块自然地按需加载,是否可行?

如果看了这个图,你还相信按需加载的话,可以停止此文的阅读了:

简单来说,按需加载与构建并不冲突,我们不能将所有资源最细粒度地使用按需加载进行管理,必要的构建来减少资源请求是必要的。

随之而来的,我们会考虑标准的代码合并方案。相当多的站点会将所有的JavaScript合并为一个文件,这也是最简单粗暴有效的方案。

但是对于大型的单页系统而言,所有JavaScript合并后生成的文件会非常之巨大,其体积在浏览器单线程的下载模式下已经成为系统的性能瓶颈。因此我们需要一些更好的策略,让系统的启动性能得以优化。

最后,常用于业界的还有一种方案,即自动化的运行时合并。通过在服务器端配置一个处理程序,可以运行时检测需要文件的依赖,进行依赖打包并响应至客户端。

这种方案有其成本小、透明化等多方面的优势,但在精益求精的场景下仍旧略有不足。其最大的缺点是当有2个以上模块依赖同一个模块时,被依赖模块可能会被重复打包到多份.js文件中,造成不必要的网络传输。

当然有很多的方法解决这一问题,诸如在Session中记录用户已经拥有的模块,或由客户端记录并提供已有模块列表,来保证打包过程不会加入无用的模块。但这些方法会提升一定的开发成本,同时前后端合作才可以完成的方案往往在推进上会遇到一些小阻碍。

基于这些原因,从前端静态化的构建入手,在构建阶段实现较为优化的打包方案,是现阶段我们采取的策略。

准则

从系统运行时来分析,对于JavaScript的构建,可以提出以下的原则。

控制请求段的数量

“请求段”是一个很模糊的概念,简单来说,在一个带宽足够的环境下,我们并不看重运行时产生了多少个请求,而是看重这些请求在瀑布图中被分为几段。由于浏览器并行加载的特性,系统真正的可用时间是由段的数量和每一个段的时间来决定的。

对于常见的浏览器,其并行加载的请求个数为4-6个,也即一个段可以加入4-6个的请求。从分段越少越好的角度来考虑,我们规划的系统启动分为3个段:

  1. 加载必要的前置条件,其中最为主要的是AMD Loader。.css文件可以在这个阶段加载,以避免影响后面更重量级的.js的加载效率。
  2. 主要的JavaScript模块的加载,在此段通过对并行数的控制,期望在一个段内加载完所有必要的模块。同时从段的用时 = 段内加载总大小 / 并发数这一公式考虑,应尽可能将模块打包为浏览器可接受的最大并发数个文件。
  3. 一些动态的信息,如用户登录信息、系统常量表等,这些信息是易变或动态的,因此从缓存的角度考虑不适合进行打包。

从我们的系统来看,仅看JavaScript的资源,很明显地分为3段进行加载(红线分隔):

文件大小

从经验值来看,单个资源的大小尽量控制在未Gzip前500KB以内,过大的文件会成为瓶颈,除非你可以很好地规划一个HTTP请求段,使得一个大文件加载的用时内浏览器会利用另一个TCP链接加载多个小文件。

Gzip对纯文本文件的压缩率一般在16%左右,文件的形式和内容不会对此比率造成特别大的影响。如果使用Linux或OSX系统,可以简单地使用以下命令来看一个文件Gzip后的近似大小:

gzip -c {file} | wc -c

同时,由于短板效应的存在,一个段的加载时间会由这个段内最大的资源决定,因此尽量使得各资源的大小相近。

请求控制

并不是所有的文件都需要合并在一起,也不是所有资源都可以按需加载,对于请求数量的控制并不仅仅体现在系统启动时,也贯穿整个系统的使用流程,来提供用户一致的性能体验。

我对一个大型CMS系统的请求数量的总结可以概括为3点:

  1. 在导航通过一次操作可到达的页面内,不应该产生额外的请求,即系统启动过程中这些模块都应当就位。
  2. 在一级页面中进行下探才可以到达的页面,尽量控制一个页面仅产生一个请求。考虑到单个页面的资源不会很大,因此再行拆分并行加载反而可能因为TCP链接、网络延迟等因素有负面的效果。
  3. 对于特别大的页面,或者页面中一定条件下才会访问的区域,相关资源使用按需加载的策略配置。

基于以上的这些原则,我在系统中有进一步的实践。

实践

文件合并

将JavaScript文件分为多个“启动脚本”,一个启动脚本中会包含一系列的模块,现有系统中我将启动脚本分割为4个,分别为:

  1. 特别大的库单独拥有自己的一个脚本,比如ECharts之类的图表库。
  2. UI控件库,包含基础UI控件和业务UI控件,合并为一个脚本。
  3. MVC框架、页面基类、工具类、系统通用功能层等业务无关的逻辑合并为一个脚本。
  4. 一级页面的业务模块合并为一个脚本。

需要特别注意的是,各启动脚本间应该保持完美的正交,即不应该有任何一个模块被重复合并到多个脚本中。

除了启动脚本外,前面也有提到单一页面应该尽可能只加载一个资源,因此页面相关会被打包在一起,比较典型的是将Controller、Model和View合并到Controller对应的文件中。

由于二级页面并不能确定哪一个会被先访问,因此各页面打包文件中是会存在一定的模块重复的,经典如util模块就会同时被列表、表单、只读等页使用。这会导致加载多个页面时部分资源被重复加载,但是此类资源通常体积很小,产生的副作用在可控范围内。

文件加载

在文件加载这一方向上,需要有一个特别的处理。由于AMD的依赖管理和运行时依赖分析功能,通过Loader的require函数加载一个模块的化,Loader会自动分析依赖并通过零碎的HTTP请求去请求相关的资源,而无视这些资源是否可能被下一个脚本打包在一起。

用一个实例来说明,我们的依赖关系为a -> b以及c -> b,即b模块是一个通用模块,被两边所依赖。当我们将ac分开打包为2个文件时,b会出现在其中一个中(为了实现完美正交)。假设b被打包在a.js中,那么当c.js被Loader加载时,如果a.js还未就位,就会产生一个单独的HTTP请求b.js。由于并行下载时,谁先完成是不可预知的,就有很大的可能性产生无意义的零碎请求。

解决这一问题的方法是,使用<script>标签来引入这些打过包的脚本,而不要让Loader去做加载。由于<script>标签的执行过程中并不会有依赖分析,也就不会产生额外的请求。

需要注意的是,能够使用<script>标签来实现这一功能,需要对应的Loader有延迟执行AMD模块的factory函数的功能,即遵循CMD规范。现有业界RequireJS和百度的esl都有这一功能,可放心使用。

硬编码预加载

除去打包这一比较常规的步骤之上的微创新外,前端在资源加载方向上还有一个很重要的方向就是“预加载”。所谓预加载,即在用户尚未使用某一个功能的时候预先加载相关的资源并缓存,便于用户使用时能够快速获得资源。预加载的探索上,有几个指标至关重要:

  1. 加载的时机。在保证用户需要时已经加载完毕的前提下,越晚加载越有利于系统整体的性能。
  2. 加载的精度。预加载一个用户最终都没有用到的资源是一种浪费的行为。

从这两点出发,预加载可以衍生出相当多的子话题,包括:

  1. 用户行为预测,如用户停留在某一个列表页面时时,根据其以往的行为进行分析,很有可能进入新建的操作。
  2. 临近加载,如计算用户的鼠标轨迹来预测后续可能点击的按钮等。
  3. 空闲加载,通过统一的请求管理,寻找网络空闲的时候来加载需要的资源,从而不影响急需的资源的加载进度。

由于在这一块还没有深入的探索和相关的产出,因此不再赘述。

版本管理

在构建完成后,由于HTTP缓存的特性,我们希望可以达到静态资源永久缓存的前提下又可以准确地进行缓存过期。在这一方向上,最为普遍的方法即使用一定的算法为资源生成版本号,从而使得新旧资源的URL不同,来强制浏览器加载新的资源。

在版本号的算法选择中,MD5大致是当前最优的方案。但由于MD5的计算和HTML中资源链接的替换都相对成本较高,在某种情况下使用简单的递增或SVN Revision作为版本号都是可以考虑的。这些相关的话题都有过很深入的讨论,也不作为本文详细描述的重点。

在版本号控制缓存的规划之中,需要注意的是对脚本的“变化频率”进行预测,我们应当尽可能地将下次变化时间相同/相近的资源打包在一起,以避免因为一个资源变化导致大量资源要同时失去缓存的场景出现。

在这种控制上,简单的策略有2种:

  1. 通过分层设计,将系统自下而上,自基础至业务分为多层,越接近下层(基础)的代码变动理应越少,而越接近上层(业务)的代码则理应有更频繁的变动。通过分层可以简单地区分这些,上文提到的脚本分割策略也一定程度上基于这一理论。
  2. 对于易变的内容,可以进一步通过策略等模式,抽取出不变和可变的部分,将可变的部分打包在一起的同时,提供一些接口来允许覆盖这些“可变量”。随后通过一种“灰度脚本”,即临时性执行的脚本来覆盖这些可变量,达到在一定时期内运行新的策略,但不至于打包的脚本缓存过期的目的。当打包脚本确实需要过期时,则再将新策略内联到脚本中。

总结

由于系统的私有性,以及我们团队使用的工具相对并不流行,因此本文也不再放出实例代码来了。

以上是我在系统中进行的构建相关的初步探索,得到的只是一个静态简单的构建模型,远未达到我预期中的最佳。

至于最佳的构建会是怎么样的,考虑到我还想用这些产出去升个职什么的,便不再透露了,等到实际有了成果后再总结成文共享。