再读 Webpack 源码,各个击破(一):从 Compiler 和 Compilation 说起

前言

前端的发展,离不开打包工具的助推。关于打包工具种类繁多,其中 Webpack 作为佼佼者被广泛使用。通过配置选项和 Loader、Plugin,我们可以实现各种功能。那么 Webpack 内部到底是怎么运行,通过本系列的源码学习,希望能给笔者有一个完整的认识。


1、准备工作

1.1、工具及版本说明

首先,在阅读本文之前。我们先约定下本系列文章约定的源码版本,如下:

1
webpack 4.46.0

对于 Node 版本,笔者使用的 14 的大版本,当然为了方便调试,建议使用不低于 10 的大版本。另外,我们使用 VSCode 进行调试和代码阅读。

1.2、约定及说明

阅读源码是一个学习的过程,当然也是一个比较痛苦的过程,尤其是 webpack 这种使用 hooks 来完成主流程的库。在主要步骤中会很跳跃,这一定程度上增加了源码阅读的难度,本文会集中在几个关键阶段或部分:compiler 、compilation 、Tapable 和 Hook、Module及其处理器 Loader、Plugin。并对行数过长的方法进行代码删减,保留一些关键代码的方法来让读者对整体执行流程有一个简单认识。然后,再根据自己的需要,按图索骥的对感兴趣的部分进行深入阅读。

2、源码分析

2.1、Webpack 执行入口

首先我们找到程序的入口位置,直接看 webpack 包(这个 node_modules\webpack 目录下)的 package.json 文件,并找到部分内容:

1
2
3
"main": "lib/webpack.js",
"web": "lib/webpack.web.js",
"bin": "./bin/webpack.js",

其他我们都不看,直接关注 main 这个属性,这就是我们在 Node 环境中执行 webpack 的入口。那么,我们得到完整的 webpack 代码路径:

node_modules\webpack\lib\webpack.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const webpack = (options, callback) => {
...
let compiler;
// 根据配置类型是数组还是对象,来创建 compiler 对象
if (Array.isArray(options)) {
...
} else if (typeof options === "object") {
// 一般情况下,module.export 出来就是单个配置。我们可以主要看这里的代码
// 首先,通过一个默认选项对象 WebpackOptionsDefaulter 对我们的配置项进行处理
options = new WebpackOptionsDefaulter().process(options);
// 创建 compiler 对象,该对象贯穿webpack的整个生命周期
compiler = new Compiler(options.context);
compiler.options = options;
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
// 重要!!!执行配置项里所有 plugin 对象的 apply 方法,如果是函数则执行该函数
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
// 执行完成环境配置步骤相关的 hooks
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
// 重要!!!process 方法将会添加更多的 plugin、初始化 resolver 以及执行 hooks
compiler.options = new WebpackOptionsApply().process(options, compiler);
}
if (callback) {
if (
options.watch === true ||
(Array.isArray(options) && options.some(o => o.watch))
) {
const watchOptions = Array.isArray(options)
? options.map(o => o.watchOptions || {})
: options.watchOptions || {};
// 运行 compiler,这里会开启监听模式,后面会针对这个进行分析,
return compiler.watch(watchOptions, callback);
}
...
// 运行 comiler,进行编译工作
compiler.run(callback);
}
return compiler;
};
exports = module.exports = webpack;

通过以上的代码,主要完成了以下几个工作:

  • 1、配置项处理
  • 2、创建 compiler 对象
  • 3、执行配置项里 plugins 的 apply 方法
  • 4、执行环境配置相关的 hooks
  • 5、执行 WebpackOptionsApply 的 process 方法,这个方法的代码我在下面讲解
  • 6、执行 compiler 的 run 或 watch 方法,真正的执行编译的任务

2.2、来自 WebpackOptionsApply.process 的加餐

上一节中提到 WebpackOptionsApply 的 process 方法,这个方法很长,大概4、500行(包括注释部分)。
我们会针对这个方法进行重点解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
process(options, compiler) {
let ExternalsPlugin;
// 上来是一大堆的属性配置,主要是一些输入、输出的路径
compiler.outputPath = options.output.path;
……
// 最终打包出的代码运行的目标环境,做一些插件创建和 apply 方法执行
if (typeof options.target === "string") {
let JsonpTemplatePlugin;
let FetchCompileWasmTemplatePlugin;
……
switch (options.target) {
case "web":
// 针对web浏览器目标环境的处理
break;
case "node":
...
default:
throw new Error("Unsupported target '" + options.target + "'.");
}
}
……
// 如果是打包成一个库的配置处理
if (options.output.library || options.output.libraryTarget !== "var") {
const LibraryTemplatePlugin = require("./LibraryTemplatePlugin");
new LibraryTemplatePlugin(
options.output.library,
options.output.libraryTarget,
……
).apply(compiler);
}
// 对于引入的依赖包在打包中进行去除,希望通过cdn方式或其他方式的处理
if (options.externals) {
ExternalsPlugin = require("./ExternalsPlugin");
new ExternalsPlugin(
options.output.libraryTarget,
options.externals
).apply(compiler);
}
……
// sourcemap 的处理
if (
options.devtool &&
(options.devtool.includes("sourcemap") ||
options.devtool.includes("source-map"))
) {
// sourcemap 参数处理
const hidden = options.devtool.includes("hidden");
const inline = options.devtool.includes("inline");
……
// 插件处理
const Plugin = evalWrapped
? EvalSourceMapDevToolPlugin
: SourceMapDevToolPlugin;
new Plugin({
filename: inline ? null : options.output.sourceMapFilename,
moduleFilenameTemplate: options.output.devtoolModuleFilenameTemplate,
……
}).apply(compiler);
} else if (options.devtool && options.devtool.includes("eval")) {
……
}
// compiler.hooks.entryOption 的执行
compiler.hooks.entryOption.call(options.context, options.entry);
// 严格模式的插件,这个我们在下面可以看一下它的源码部分
new UseStrictPlugin().apply(compiler);
……
// 在 optimization 中开启 sideEffects 的处理
if (options.optimization.sideEffects) {
const SideEffectsFlagPlugin = require("./optimize/SideEffectsFlagPlugin");
new SideEffectsFlagPlugin().apply(compiler);
}
……
// 代码做 split
if (options.optimization.splitChunks) {
const SplitChunksPlugin = require("./optimize/SplitChunksPlugin");
new SplitChunksPlugin(options.optimization.splitChunks).apply(compiler);
}
……// 省略的这部分都和 module 和 chunk 相关
// 压缩配置
if (options.optimization.minimize) {
for (const minimizer of options.optimization.minimizer) {
if (typeof minimizer === "function") {
minimizer.call(compiler, compiler);
} else {
minimizer.apply(compiler);
}
}
}
if (options.performance) {
const SizeLimitsPlugin = require("./performance/SizeLimitsPlugin");
new SizeLimitsPlugin(options.performance).apply(compiler);
}
……
// compiler.hooks.afterPlugins 调用
compiler.hooks.afterPlugins.call(compiler);
if (!compiler.inputFileSystem) {
throw new Error("No input filesystem provided");
}
// compiler.resolverFactory.hooks 上挂一些钩子
compiler.resolverFactory.hooks.resolveOptions
.for("normal")
.tap("WebpackOptionsApply", resolveOptions => {
return Object.assign(
{
fileSystem: compiler.inputFileSystem
},
cachedCleverMerge(options.resolve, resolveOptions)
);
});
……
// compiler.hooks.afterResolvers 调用
compiler.hooks.afterResolvers.call(compiler);
return options;
}

ok,可以看到在进入到 compiler.run 之前,在这里又做了大量的工作。这里主要的工作集中在根据我们对于 webpack 的配置项进行插件的添加和 apply 方法的执行。我们在上面的代码讲解里有一个严格模式的插件,我们简单看下它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apply(compiler) {
compiler.hooks.compilation.tap(
"UseStrictPlugin",
(compilation, { normalModuleFactory }) => {
const handler = parser => {
parser.hooks.program.tap("UseStrictPlugin", ast => {
const firstNode = ast.body[0];
……// 一些处理代码
});
};
normalModuleFactory.hooks.parser
.for("javascript/auto")
.tap("UseStrictPlugin", handler);
……
}
);
}

这里它主要通过钩子方法,可以在对代码生成一颗抽象语法树 ast 之后,拿到 ast,并做一些工作。这个地方其实给了我们一些启示,能够针对 ast 进行一些操作,当然还可以使用 Babel 作为可选方案。

2.3、贯穿始终的 Compiler

Compiler 和 Compilation 是两个在 webpack 的 plugin 中常用的两个对象。二者也包含了很多的 hooks,这些 hooks 代表了 webpack 在执行过程的不同阶段。其中,通过前面的代码我们看到了 Compiler 的创建过程,它包含了很多的配置项 options,里面又包含了 Loader 和 Plugin 信息。其生命周期一直到 webpack 结束,几乎等同于 webpack 实例本身。

而 Compilation 到此为止,我们还没有看到。它其实只是一次编程过程,包含了 Module、编译后的输出等,同时他也拥有 Compiler 对象实例。

在上面对于 webpack 执行入口的源码分析中,我们看到了 compiler.watch 或 compiler.run 才真正执行起来。我们一般在开发阶段中会使用到 webpack-dev-server ,它这里 Watch 模式默认开启。本文要讲以 compiler.watch 方法作为入口继续源码的分析。

源码位置 node_modules\webpack\lib\Compiler.js

1
2
3
4
5
6
7
watch(watchOptions, handler) {
if (this.running) return handler(new ConcurrentCompilationError());
this.running = true;
this.watchMode = true;
……
return new Watching(this, watchOptions, handler);
}

前面的一些设置代码,我们可以忽略,直接看最后创建并返回了一个 Watching 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Watching {
constructor(compiler, watchOptions, handler) {
……
this.compiler = compiler;
this.running = true;
// 这里调用了 compiler.readRecords 方法,该方法比较简单,最后会执行回调方法
this.compiler.readRecords(err => {
if (err) return this._done(err);
// 最后还是来到 _go 方法
this._go();
});
}
_go() {
……
// 执行 compiler.hooks.watchRun
this.compiler.hooks.watchRun.callAsync(this.compiler, err => {
const onCompiled = (err, compilation) => {
// 实际上最终执行的是 compiler.hooks.emit
this.compiler.emitAssets(compilation, err => {
//
this.compiler.emitRecords(err => {
……
// 执行
return this._done(null, compilation);
});
});
};
// 在执行 compiler.hooks.watchRun 后,真正进入编译阶段,并传入 onCompiled 这个回调函数
this.compiler.compile(onCompiled);
});
}
_done(err, compilation) {
this.running = false;
if (err) {
// 出错之后,执行 compiler.hooks.failed
this.compiler.hooks.failed.call(err);
return;
}
// 编译正常完成,执行 compiler.hooks.done
this.compiler.hooks.done.callAsync(stats, () => {
if (!this.closed) {
// 监听,暂时略过
this.watch(
……
);
}
……
});
}
}

ok,到这里可能有点混乱了,我们来理一理流程主线:

Compiler.watch() —->> Watching._go() —->> Compiler.hooks.watchRun —->> Compiler.compile(onCompiled) —->> onCompiled —->> 执行 Compiler.hooks.emit 和 Watching._done() —->> Compiler.hooks.done (通过Watching._done)

这个主线里,我们重点要记住 Compiler.hooks.emit 和 Compiler.hooks.done。前者是即将进行 output,这是我们最后能够修改模块内容的机会,后者则是完成编译文件输出。另外,在这个主线里,我们看到缺了 Compiler.compile(onCompiled) 这一部分的内容。接下来,我们继续 Compiler 的源码部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
node_modules\webpack\lib\Compiler.js
class Compiler extends Tapable {
createCompilation() {
return new Compilation(this);
}
newCompilation(params) {
const compilation = this.createCompilation();
……
// 执行两个 hooks,至此,compilation 才真正被创建
this.hooks.thisCompilation.call(compilation, params);
this.hooks.compilation.call(compilation, params);
return compilation;
}
createNormalModuleFactory() {
const normalModuleFactory = new NormalModuleFactory(……);
// 调用 hooks.normalModuleFactory
this.hooks.normalModuleFactory.call(normalModuleFactory);
return normalModuleFactory;
}
createContextModuleFactory() {
const contextModuleFactory = new ContextModuleFactory(this.resolverFactory);
// 调用 hooks.contextModuleFactory
this.hooks.contextModuleFactory.call(contextModuleFactory);
return contextModuleFactory;
}
newCompilationParams() {
// 主要是创建两个工厂实例
const params = {
normalModuleFactory: this.createNormalModuleFactory(),
contextModuleFactory: this.createContextModuleFactory(),
compilationDependencies: new Set()
};
return params;
}
compile(callback) {
// 拿到创建 Compilation 的参数
const params = this.newCompilationParams();
// 执行 hooks.beforeCompile
this.hooks.beforeCompile.callAsync(params, err => {
// 执行 hooks.compile,到这里为止,compilation 还未创建
this.hooks.compile.call(params);
// 创建 Compilation 对象
const compilation = this.newCompilation(params);
// hooks.make 进入编译阶段
this.hooks.make.callAsync(compilation, err => {
// compilation 部分
compilation.finish(err => {
compilation.seal(err => {
// 执行 hooks.afterCompile,这里已经完成编译的部分
this.hooks.afterCompile.callAsync(compilation, err => {
// 这里就是那个 onCompiled 回调最终被执行
return callback(null, compilation);
});
});
});
});
});
}
}

完成了上述代码的过程,我们对上面的 compiler.compile() 内部的流程主线可以归纳(侧重 hooks 部分):
compiler.compile() —->> compiler.hooks.normalModuleFactory 和 compiler.hooks.contextModuleFactory —->> compiler.hooks.beforeCompile —->> compiler.hooks.compile —->> compiler.hooks.thisCompilation —->> compiler.hooks.compilation —->> compiler.hooks.make —->> compilation.finish() —->> compilation.seal() —->> compiler.hooks.afterCompile —->> onCompiled()

hooks 本质上反映的是整个 webpack 运行的不同阶段。在上面这个主线中,我们其实重点关注两个点:

  • compiler.hooks.make,从这里开始正式进入编译
  • compilation.seal() 函数,它的执行标识着封闭编译,不再添加 module

然后我们看到从 compiler.hooks.make 直接到了 compilation.finish() 这一步。中间经历了什么,我们在下一节中进行源码分析。

另外,上述代码中生成了两个工厂对象:ContextModuleFactory 和 NormalModuleFactory 。其中,ContextModuleFactory 用来解析目录,为每个文件生成请求,并依据传递来的 regExp 进行过滤。最后匹配成功的依赖关系将被传入 NormalModuleFactory 。NormalModuleFactory 用来生成各类模块。从入口点开始,它会分解每个请求,解析文件内容以查找进一步的请求,然后通过分解所有请求以及解析新的文件来爬取全部文件。在最后阶段,每个依赖项都会成为一个模块实例。

2.4、大包干的 Compilation

在上一节的最后,我们缺了一大块内部,就是怎么就从 compiler.hooks.make 直接到了 compilation.finish() 这一步?开发过 plugin 的同学应该知道,通过 hooks 我们可以通过 tap 方法去钩住某个执行阶段,compiler.hooks.make 执行就会触发这些 hooks。我们通过调试或在 ./node_modules/webpack 下用关键词搜索 hooks.make.tap 可以发现很多插件。比如:DllEntryPlugin、MultiEntryPlugin、AutomaticPrefetchPlugin、PrefetchPlugin等,我们以 MultiEntryPlugin 为例来看一下它的源码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
node_modules\webpack\lib\MultiEntryPlugin.js
class MultiEntryPlugin {
apply(compiler) {
compiler.hooks.make.tapAsync(
"MultiEntryPlugin",
(compilation, callback) => {
const { context, entries, name } = this;
// 生成一个 MultiEntryDependency 依赖对象
const dep = MultiEntryPlugin.createDependency(entries, name);
// 调用了 compilation.addEntry 方法
compilation.addEntry(context, dep, name, callback);
}
);
}
static createDependency(entries, name) {
return new MultiEntryDependency(
entries.map((e, idx) => {
const dep = new SingleEntryDependency(e);
return dep;
}),
name
);
}
}

通过插件在 compiler.hooks.make 上挂的钩子,成功的从 compiler 转译到了 compilation 的执行阶段。 compilation.addEntry(context, dep, name, callback) 方法从名字上,我们就可以看出是为编译添加入口,其实这并不难理解。接下来,我们看看 addEntry 方法之后都干了些什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
node_modules\webpack\lib\Compilation.js
addEntry(context, entry, name, callback) {
this.hooks.addEntry.call(entry, name);
// 添加模块链接,其实就是从 entry 入口开始对每个模块 build,然后分析出依赖模型进行 build 的一个过程
this._addModuleChain(
context,
entry,
module => {
this.entries.push(module);
},
(err, module) => {
//
this.hooks.succeedEntry.call(entry, name, module);
return callback(null, module);
}
);
}
_addModuleChain(context, dependency, onModule, callback) {
const Dep = /** @type {DepConstructor} */ (dependency.constructor);
// dependencyFactories 里有很多类型的工厂对象,通过依赖的具体类型拿到工厂对象,暂时忽略下
const moduleFactory = this.dependencyFactories.get(Dep);
this.semaphore.acquire(() => {
// 通过模块工厂对象创建模块,工厂的 create 方法,我们先放一下,在后面说。
moduleFactory.create({},
(err, module) => {
// 添加模块
const addModuleResult = this.addModule(module);
const afterBuild = () => {
if (addModuleResult.dependencies) {
// 处理依赖模块
this.processModuleDependencies(module, err => {});
}
};
if (addModuleResult.build) {
// 开始构建模块
this.buildModule(module, false, null, null, err => {
// 完成构建之后,继续后面有可能的依赖模块处理
afterBuild();
});
}
}
);
});
}
addModule(module, cacheGroup) {
// 缓存,这里根据模块的标识符进行查找
const identifier = module.identifier();
// _modules 里存放的是已经添加的模块信息
const alreadyAddedModule = this._modules.get(identifier);
if (alreadyAddedModule) {
return {
module: alreadyAddedModule,
issuer: false,
build: false,
dependencies: false
};
}
const cacheName = (cacheGroup || "m") + identifier;
if (this.cache && this.cache[cacheName]) {
// 这里面主要是针对缓存部分有更新的处理,需要对模块进行 reBuild 处理
}
return {
module: module,
issuer: true,
build: true,
dependencies: true
};
}
buildModule(module, optional, origin, dependencies, thisCallback) {
// 在模块构建开始之前触发,可以用来修改模块。
this.hooks.buildModule.call(module);
// 使用模块对象进行构建
module.build(
this.options,
this,
this.resolverFactory.get("normal", module.resolveOptions),
this.inputFileSystem,
error => {
// 执行成功构建的钩子
this.hooks.succeedModule.call(module);
return callback();
}
);
}
processModuleDependencies(module, callback) {
this.addModuleDependencies(
module,
sortedDependencies,
this.bail,
null,
true,
callback
);
}
addModuleDependencies(
module,
dependencies,
bail,
cacheGroup,
recursive,
callback
) {
asyncLib.forEach(
dependencies,
(item, callback) => {
semaphore.acquire(() => {
const factory = item.factory;
factory.create({},
(err, dependentModule) => {
const addModuleResult = this.addModule();
const afterBuild = () => {
if (recursive && addModuleResult.dependencies) {
this.processModuleDependencies(dependentModule, callback);
}
};
if (addModuleResult.build) {
this.buildModule(
err => {
afterBuild();
}
);
}
}
);
});
},
err => {
}
);
}

这一块涉及到 compilation 中的几个方法,源码部分很多,笔者删除了大部分。主线捋一捋大致如下:通过 addEntry 找到入口模块然后把模块和依赖模块进行构建,构建过程是先构建当前模块,该过程伴随着缓存的处理和更新,真正的构建是通过具体模块对象的 module.build 方法进行(这个后面系列的文章中分析)。构建完当前模块后对分析出来的依赖模型就行构建,不断重复该过程直至所有的模块构建完毕。在这个过程中伴随着一些 hooks 的执行。

那么回到之前的那个问题,现在我们知道了如何从 compiler.hooks.make 顺滑的走到了 compilation.addEntry ,最终完成模块的构建工作。单个模块完成构建工作后执行了 compilation.hooks.succeedModule 钩子。那么,webpack 如何确定所有的模块以及构建完成最终执行了 compilation.finish() 呢?这个问题,我们放到下一篇文章中去介绍 Tapable 和 Hook。另外,我们在上面的源码分析中留下了一个问题就是模块如何构建,这个在系列三中去解答。