「 面试三板斧 」之 代码分割(中)

2020-12-22 11:21:17 浏览数 (1)

背景

接上一篇文章:「 面试三板斧 」之 代码分割(上)

中, 我们分析了配置 splitChunks 不同值后的行为。

本篇我们将深入分析代码分割背后的运行机制。

今天的主要内容包括:

  • 代码分割的思路及方法
  • 切割点的选择
  • 代码分割实例分析
  • 识别与处理切割点
  • 构建 chunks
  • 拼接 output.js
  • 相关资料推荐

文中部分内容参考了网上一些优秀实践以及相关的分析。引用部分将会在文中标注, 文末也将给出具体链接。

正文

代码分割是 webpack 中最引人注目的特性之一。

此特性能够把代码分离到不同的 bundle 中,然后可以按需加载并行加载这些文件。

代码分割的思路及方法

代码分割可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。

且异步 chunk 为懒加载的——执行到 require.ensure 时才拉取并执行。

大体思路如下:

  1. 通过 require.ensure 标识新 chunk
  2. 依赖收集时,单独标识异步依赖
  3. 执行 require.ensure 时,拉取新 chunk
  4. 新 chunk 设计为一个 jsonp 函数,由 webpackJsonp 函数包裹
  5. 实现 webpackJsonp 函数,其会将新拉下来的 chunk 中的模块添加到主 modules 上,随后执行 require.ensure 的回调

通常,有三种常用的代码分离方法:

  1. 入口起点:使用 entry 配置手动地分离代码。
  2. 防止重复:使用 CommonsChunkPlugin 去重和分离 chunk。
  3. 动态导入:通过模块的内联函数调用来分离代码。

前2种是通过配置实现,第3种是通过代码动态分析实现。

动态导入也有2种写法:

  • 一种是基于ESMA提案的import()方法
  • 另一种是webpack提供的require.ensure方法。

「1」 一般说来,code-splitting 有两种含义

  1. 将第三方类库单独打包成 vendor.js ,以提高缓存命中率。
  2. 将项目本身的代码分成多个 js 文件,分别进行加载。

换句话说,我们的目标是:

将原先集中到一个 output.js 中的代码,切割成若干个 js 文件,然后分别进行加载。

也就是说:原先只加载 output.js。

现在把代码分割到 3 个文件中,先加载 output.js ,然后 output.js 又会自动加载 1.output.js 和 2.output.js 。

现在问题来了:

如何确定分割点呢?

切割点的选择

既然要将一份代码切割成若干份代码,总得有个切割点的标志吧,从哪儿开始切呢?

答案:require.ensure

webpack 使用 require.ensure 作为切割点。

分割点表示代码在此处被分割成两个独立的文件。

具体的方式有两种:

  1. require.ensure
  2. amd的动态require

1. require.ensure

代码语言:javascript复制
require.ensure(["module-a", "module-b"], function(require) {
    var a = require("module-a");
    // ...
});

2. amd的动态require

代码语言:javascript复制
require(["module-a", "module-b"], function(a, b) {
    // ...
});

上面的例子中,module-amodule-b 就会被分割到独立的文件中去,而不会和入口文件打包在同一个文件中。

然而, 我用 nodeJS 也挺长时间了,怎么不知道还有require.ensure这种用法?

事实上 nodeJS 也是不支持的,这个问题我在 CommonJS 标准中找到了答案:

虽然 CommonJS 通俗地讲是一个同步模块加载规范,但是其中是包含异步加载相关内容的。

只不过这条内容只停留在 PROPOSAL(建议)阶段,并未最终进入标准。

所以 nodeJS 没有实现它也就不奇怪了。

只不过 webpack 恰好利用了这个作为代码的切割点。

草案链接:http://wiki.commonjs.org/wiki/Modules/Async/A

ok,现在我们已经明白了为什么要选择require.ensure作为切割点了。

接下来的问题是:

如何根据切割点对代码进行切割?

下面举个例子。

代码分割实例

代码语言:javascript复制
// 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

关键代码如下:

代码语言:javascript复制
// 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 模块最终解析出来的数据结构如下图所示:

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

下面我们要做的就是:如何从8个 module 中构建出3个 chunk 出来。

这里的代码较长,我就不贴出来了,想看的到这里的 buildDep.js, 地址:https://github.com/youngwind/fake-webpack/commit/d6589263f90752ef8222749208694df654b631e3#diff-92c5d90abece48343aa1cdb71978f37b

其中要重点注意是:

前文说到,为了避免代码的冗余,需要将模块 b 从 chunk1 中移除,具体发挥作用的就是函数removeParentsModules,本质上无非就是改变一下标志位。

最终生成的chunks的结构如下:

拼接 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个,并未就其加载时机进行控制。

webapck 打包之后生成了很多代码,中间也有不少细节, 有很多相关的分析文章写的不错, 在此推荐两篇:

  1. https://blog.csdn.net/haodawang/article/details/77126686
  2. https://segmentfault.com/a/1190000006814420

篇幅有限,就不过多展开,感兴趣的朋友可以看看。

下篇文章,就是轻松愉快的项目打包优化实践环节, 敬请期待~

以上就是本文全部内容, 希望对大家有所启发。

资料以及参考链接:

  • https://www.webpackjs.com/guides/code-splitting/
  • https://github.com/jin5354/404forest/issues/66
  • https://github.com/youngwind/blog/issues/100

0 人点赞