webpack源码学习系列之二:code-splitting(代码切割)

@youngwind 2017-02-10 06:52:03发表于 youngwind/blog webpackwebpack 源码

前言

继上一篇 #99 之后,我们今天来看看如何实现 webpack 的代码切割(code-splitting)功能,最后实现的代码版本请参考这里。至于什么是 code-splitting ,为什么要使用它,请直接参考官方文档

目标

一般说来,code-splitting 有两种含义:

  1. 将第三方类库单独打包成 vendor.js ,以提高缓存命中率。(这一点我们不作考虑)
  2. 将项目本身的代码分成多个 js 文件,分别进行加载。(我们只研究这一点)

换句话说,我们的目标是:将原先集中到一个 output.js 中的代码,切割成若干个 js 文件,然后分别进行加载。 也就是说:原先只加载 output.js ,现在把代码分割到3个文件中,先加载 output.js ,然后 output.js 又会自动加载 1.output.js 和 2.output.js 。

切割点的选择

既然要将一份代码切割成若干份代码,总得有个切割点的标志吧,从哪儿开始切呢?
答案:webpack 使用require.ensure作为切割点。

然而,我用 nodeJS 也挺长时间了,怎么不知道还有require.ensure这种用法?而事实上 nodeJS 也是不支持的,这个问题我在CommonJS 的标准中找到了答案:虽然 CommonJS 通俗地讲是一个同步模块加载规范,但是其中是包含异步加载相关内容的。只不过这条内容只停留在 PROPOSAL (建议)阶段,并未最终进入标准,所以 nodeJS 没有实现它也就不奇怪了。只不过 webpack 恰好利用了这个作为代码的切割点。

ok,现在我们已经明白了为什么要选择require.ensure作为切割点了。接下来的问题是:如何根据切割点对代码进行切割? 下面举个例子。

例子

// example.js
var a = require("a");
var b = require("b");
a();
require.ensure(["c"], function(require) {
    require("b")();
    var d = require("d");
    var c = require('c');
    c();
    d();
});

require.ensure(['e'], function (require) {
   require('f')();
});

假设这个 example.js 就是项目的主入口文件,模块 a ~ f 是简简单单的模块(既没有进一步的依赖,也不包含require.ensure)。那么,这里一共有2个切割点,这份代码将被切割为3部分。也就说,到时候会产生3个文件:output.js ,1.output.js ,2.output.js

识别与处理切割点

程序如何识别require.ensure呢?答案自然是继续使用强大的 esprima 。关键代码如下:

// parse.js
if (expression.callee && expression.callee.type === 'MemberExpression'
    && expression.callee.object.type === 'Identifier' && expression.callee.object.name === 'require'
    && expression.callee.property.type === 'Identifier' && expression.callee.property.name === 'ensure'
    && expression.arguments && expression.arguments.length >= 1) {

    // 处理require.ensure的依赖参数部分
    let param = parseStringArray(expression.arguments[0])
    let newModule = {
        requires: [],
        namesRange: expression.arguments[0].range
    };
    param.forEach(module => {
        newModule.requires.push({
            name: module
        });
    });

    module.asyncs = module.asyncs || [];
    module.asyncs.push(newModule);

    module = newModule;

    // 处理require.ensure的函数体部分
    if(expression.arguments.length > 1) {
        walkExpression(module, expression.arguments[1]);
    }
}

观察上面的代码可以看出,识别出require.ensure之后,会将其存储到 asyncs 数组中,且继续遍历其中所包含的其他依赖。举个例子,example.js 模块最终解析出来的数据结构如下图所示:
image

module 与 chunk

我在刚刚使用 webpack 的时候,是分不清这两个概念的。现在我可以说:“在上面的例子中,有3个 chunk,分别对应 output.js、1.output.js 、2.output.js;有7个 module,分别是 example 和 a ~ f。

所以,module 和 chunk 之间的关系是:1个 chunk 可以包含若干个 module。
观察上面的例子,得出以下结论:

  1. chunk0(也就是主 chunk,也就是 output.js)应该包含 example 本身和 a、b 三个模块。
  2. chunk1(1.output.js)是从 chunk0 中切割出来的,所以 chunk0 是 chunk1 的 parent。
  3. 本来 chunk1 应该是包含模块 c、b 和 d 的,但是由于 b 已经被其 parent-chunk(也就是 chunk1)包含,所以,必须将 b 从 chunk1 中移除,这样方能避免代码的冗余。
  4. chunk2(2.output.js)是从 chunk0 中切割出来的,所以 chunk0 也是 chunk2 的 parent。
  5. chunk2 包含 e 和 f 两个模块。

好了,下面进入重头戏。

构建 chunks

在对各个模块进行解析之后,我们能大概得到以下这样结构的 depTree。
image

下面我们要做的就是:如何从8个 module 中构建出3个 chunk 出来。 这里的代码较长,我就不贴出来了,想看的到这里的 buildDep.js

其中要重点注意是:前文说到,为了避免代码的冗余,需要将模块 b 从 chunk1 中移除,具体发挥作用的就是函数removeParentsModules,本质上无非就是改变一下标志位。最终生成的chunks的结构如下:
image

拼接 output.js

经历重重难关,我们终于来到了最后一步:如何根据构建出来的 chunks 拼接出若干个 output.js 呢?
此处的拼接与上一篇最后提到的拼接大同小异,主要不同点有以下2个:

  1. 模板的不同。原先是一个 output.js 的时候,用的模板是 templateSingle 。现在是多个 chunks 了,所以要使用模板 templateAsync。其中不同点主要是 templateAsync 会发起 jsonp 的请求,以加载后续的 x.output.js,此处就不加多阐述了。仔细 debug 生成的 output.js 应该就能看懂这一点。
  2. 模块名字替换为模块 id 的算法有所改进。原先我直接使用正则进行匹配替换,但是如果存在重复的模块名的话,比如此例子中 example.js 出现了2次模块 b,那么简单的匹配就会出现错乱。因为 repalces 是从后往前匹配,而正则本身是从前往后匹配的。webpack 原作者提供了一种非常巧妙的方式,具体的代码可以参考这里

后话

其实关于 webpack 的代码切割还有很多值得研究的地方。比如本文我们实现的例子仅仅是将1个文件切割成3个,并未就其加载时机进行控制。比如说,如何支持在单页面应用切换 router 的时候再加载特定的 x.output.js?

-------- EOF -----------