背景
接上一篇文章:「 面试三板斧 」之 代码分割(上)
中, 我们分析了配置 splitChunks
不同值后的行为。
本篇我们将深入分析代码分割背后的运行机制。
今天的主要内容包括:
代码分割的思路及方法
切割点的选择
代码分割实例分析
识别与处理切割点
构建 chunks
拼接 output.js
相关资料推荐
文中部分内容参考了网上一些优秀实践以及相关的分析。引用部分将会在文中标注
, 文末也将给出具体链接。
正文
代码分割是
webpack 中最引人注目的特性之一。
此特性能够把代码分离到不同的 bundle 中,然后可以按需加载
或并行加载
这些文件。
代码分割的思路及方法
代码分割
可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。
且异步 chunk 为懒加载的——执行到 require.ensure 时才拉取并执行。
大体思路如下:
- 通过
require.ensure
标识新 chunk 依赖收集
时,单独标识异步依赖- 执行 require.ensure 时,拉取新 chunk
- 新 chunk 设计为一个
jsonp
函数,由webpackJsonp
函数包裹 - 实现 webpackJsonp 函数,其会将新拉下来的 chunk 中的模块添加到主 modules 上,随后
执行 require.ensure 的回调
。
通常,有三种常用的代码分离方法:
- 入口起点:使用
entry
配置手动地分离代码。 - 防止重复:使用
CommonsChunkPlugin
去重和分离 chunk。 - 动态导入:通过模块的
内联函数调用
来分离代码。
前2种是通过配置实现,第3种是通过代码动态分析实现。
动态导入也有2种写法:
- 一种是基于ESMA提案的
import()
方法 - 另一种是webpack提供的
require.ensure
方法。
「1」 一般说来,code-splitting 有两种含义
:
- 将第三方类库单独打包成 vendor.js ,以提高缓存命中率。
- 将项目本身的代码分成多个 js 文件,分别进行加载。
换句话说,我们的目标是:
将原先集中到一个 output.js 中的代码,切割成若干个 js 文件,然后分别进行加载。
也就是说:原先只加载 output.js。
现在把代码分割到 3 个文件中,先加载 output.js ,然后 output.js 又会自动加载 1.output.js 和 2.output.js 。
现在问题来了:
如何确定分割点
呢?
切割点的选择
既然要将一份代码切割成若干份代码,总得有个切割点的标志吧,从哪儿开始切呢?
答案:require.ensure
。
webpack 使用 require.ensure 作为切割点。
分割点表示代码在此处被分割成两个独立的文件。
具体的方式有两种:
require.ensure
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-a
和 module-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
。
观察上面的例子,得出以下结论:
- chunk0(也就是主 chunk,也就是 output.js)应该包含 example 本身和 a、b 三个模块。
- chunk1(1.output.js)是从 chunk0 中切割出来的,所以 chunk0 是 chunk1 的 parent。
- 本来 chunk1 应该是包含模块 c、b 和 d 的,但是由于 b 已经被其 parent-chunk(也就是 chunk1)包含,所以,必须将 b 从 chunk1 中移除,这样方能避免代码的冗余。
- chunk2(2.output.js)是从 chunk0 中切割出来的,所以 chunk0 也是 chunk2 的 parent。
- 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个:
- 模板的不同。原先是一个 output.js 的时候,用的模板是 templateSingle 。现在是多个 chunks 了,所以要使用模板 templateAsync。其中不同点主要是 templateAsync 会发起 jsonp 的请求,以加载后续的 x.output.js,此处就不加多阐述了。仔细 debug 生成的 output.js 应该就能看懂这一点。
- 模块名字替换为模块 id 的算法有所改进。原先我直接使用正则进行匹配替换,但是如果存在重复的模块名的话,比如此例子中 example.js 出现了2次模块 b,那么简单的匹配就会出现错乱。因为 repalces 是从后往前匹配,而正则本身是从前往后匹配的。webpack 原作者提供了一种非常巧妙的方式,具体的代码可以参考这里。
其实关于 webpack 的代码切割还有很多值得研究的地方。
本文我们实现的例子仅仅是将1个文件切割成3个,并未就其加载时机进行控制。
webapck 打包之后生成了很多代码,中间也有不少细节, 有很多相关的分析文章写的不错, 在此推荐两篇:
- https://blog.csdn.net/haodawang/article/details/77126686
- 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