webpack系列文章
写在前面
在日常开发中,我们使用webpack已经非常频繁了,但是对于webpack的内部实现,整个工作流程可能还是非常模糊。在之前的文章中,我们实现了一个实现简易的模块打包器,它只是具备简单的打包功能。但是对于webpack
,我们都知道它是非常复杂的,内部存在着各个阶段,每个阶段又存在着各种钩子,但是到底有哪些钩子,这些钩子在哪个阶段执行,我们可能都比较困惑。当我们去看源码时经常会非常头疼,因为源码本来就非常多,再加上webpack使用了tapable类似与发布订阅机制,也就是说它的代码不是线性的,可以一路读下来,而是跳来跳去,因此一直难以坚持读下来。经常看到这个钩子,就需要去找它定义的地方,跳来跳去,读起来很困难。这篇文章,我们抛弃细节,也就是我们不去看每个钩子的内部实现,只关注于钩子的触发。从而避免陷入细节中。最终,我们会将一些最常见的一些钩子以及它所处的流程分门别类的列举出来,这样方便大家记忆和理解(注意有些描述可能并不准确,纯粹是个人为了方便理解和记忆进行的定义,如果有不正确的地方,欢迎指出)。
webpack-cli和webpack
我们在构建项目时,使用webpack3以上版本时都知道既需要webpack又需要安装webpack-cli。我们都知道webpack
的功能时模块打包器,没有人说webpack-cli
是模块打包吧。那么webpack-cli
到底做了什么,我们也需要先了解一下。这里我们先下载webpack-cli和webpack的源码,其中webpack的版本是5.10.1,webpack-cli的版本是4.2.0。然后我们查看webpack-cli
的源码。
if (packageExists('webpack')) {
runCLI(rawArgs);
} else {
...
}
我们可以看到webpack-cli
的入口文件核心就是判断webpack
是否存在,如果存在就执行runCLI
函数。因此我们直接去看runCLI
函数的内部实现。
// runCLI函数的实现
const runCLI = async(cliArgs) => {
...
try {
// Create a new instance of the CLI object
const cli = new WebpackCLI();
...
await cli.run(parsedArgsOpts, core);
} catch (error) {
...
}
};
我们可以看到runCLI
函数的核心代码就是,通过WebpackCLI
类创建一个cli
实例,然后调用run
方法,因此我们再去看下run
方法的实现。
async run(args) {
await this.runOptionGroups(args);
...
let compiler;
// 看这里,看这里
compiler = this.createCompiler(options, callback)...
}
// createCompiler函数。
createCompiler(options, callback) {
let compiler;
try {
// 看这里,看这里
compiler = webpack(options, callback);
} catch (error) {
this.handleError(error);
process.exit(2);
}
return compiler;
}
我们可以看到run
方法就是通过调用webpack函数来创建一个compiler编译器。这就是webpack-cli
这个包的所有核心功能了。它的整个核心功能执行webpack函数返回一个编译器。其他的所有编译打包的工作都是通过webpack实现的。因此,我们只需要关注webpack的实现即可。好了接下来我们进入webpack的源码中。
webpack的各个阶段以及重要的钩子
阶段 | 关键钩子 | 钩子类型 | 钩子参数 | 说明 |
---|---|---|---|---|
创建编译器:createCompiler() | environment | SyncHook | - | 读取环境 |
创建编译器:createCompiler() | afterEnvironment | SyncHook | - | 读取环境后触发 |
创建编译器:createCompiler() | initialize | SyncHook | - | 初始化compiler |
编译器运行:compiler.run() | beforeRun | AsyncSeriesHook | Compiler | 运行前的准备活动,主要启动了文件读取功能 |
编译器运行:compiler.run() | run | AsyncSeriesHook | Compiler | “机器”已经跑起来了,在编译之前有缓存,则启用缓存,这样可以提高效率。 |
编译器编译:compiler.compile(onCompiled) | beforeCompile | AsyncSeriesHook | params | 开始编译前的准备,创建的ModuleFactory,创建Compilation,并绑定ModuleFactory到Compilation上。同时处理一些不需要编译的模块,比如ExternalModule(远程模块)和DllModule(第三方模块) |
编译器编译:compiler.compile(onCompiled) | compile | SyncHook | params | 进行编译 |
编译器编译:compiler.compile(onCompiled) | make | AsyncParallelHook | compilation | 编译的核心流程 |
编译器编译:compiler.compile(onCompiled) | afterCompile | AsyncSeriesHook | compilation | 编译结束了 |
编译结束后进行输出(onCompiled()) | shouldEmit | SyncBailHook | compilation | 获取compilation发来的电报,确定编译时候成功,是否可以开始输出了。 |
编译结束后进行输出(onCompiled()) | emit | AsyncSeriesHook | compilation | 输出文件了 |
编译结束后进行输出(onCompiled()) | afterEmit | AsyncSeriesHook | compilation | 输出完毕 |
编译结束后进行输出(onCompiled()) | done | AsyncSeriesHook | Stats | 所有流程结束 |
首先,我将流程大致分为四个阶段:创建编译器的阶段、编译器运行阶段、编译器编译阶段以及编译器输出阶段。每一个阶段都有对应的钩子。这些钩子实现着不同的功能。
一、创建编译器阶段
我们都知道,webpack的核心功能就是编译打包,因此它肯定是有一个打包器compiler,compiler是通过Compiler
类创建,这个Compiler
类是webpack的核心。创建的代码如下:
compiler = createCompiler(options); // 创建compiler
const createCompiler = rawOptions => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
const compiler = new Compiler(options.context);
compiler.options = options;
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
// 看这里看这里各种钩子
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
return compiler;
};
我们可以看到,在这个阶段调用了三个钩子:compiler.hooks.environment.call();
,compiler.hooks.afterEnvironment.call();
和compiler.hooks.initialize.call();
。通过这三个钩子的名称我们可以知道,是在创建编译器之前进行读取环境信息和初始化,这里仅仅根据钩子名称来判断它所作的事情,不要陷入某个阶段的某个钩子中。因为webpack采用了tapable的类似于发布订阅机制,我们可能需要花费很多时间去找它的定义和触发,得不偿失。因此,这篇文章主要是介绍存在哪些流程和哪些钩子,让大家对webpack有个大致印象。
二、编译器运行阶段
我们可以看到创建编译器之后,就到了编译器开始运行阶段。
const { compiler, watch, watchOptions } = create(); // 创建编译器
if (watch) {
compiler.watch(watchOptions, callback);
} else {
compiler.run((err, stats) => { // 编译器运行
compiler.close(err2 => {
callback(err || err2, stats);
});
});
}
我们可以看下这个compiler.run
中主要做了什么,或者说触发了哪些钩子。
const run = () => {
// 看这里,看这里beforeRun钩子
this.hooks.beforeRun.callAsync(this, err => {
if (err) return finalCallback(err);
// 看这里,看这里run钩子
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
this.readRecords(err => {
if (err) return finalCallback(err);
// 看这里看这里,这是又一个核心方法。
this.compile(onCompiled);
});
});
});
};
compiler.run
主要是运行run
方法,run方法,首先会触发beforeRun这个saync钩子,在这个钩子中绑定了读取文件的对象。接着是run
这个async钩子,在这个钩子中主要是处理缓存的模块,减少编译的模块,加速编译速度。run执行完毕的回调函数中,执行this.compile(compiled)
,也就是说在run之后,才会执行compile
方法进行编译,编译完成之后执行回调函数onCompiled
。因此,接下来进入编译器编译阶段。
三、编译器编译阶段
我们通过上面的代码知道,compile方法就是用于编译,因此,我们看下compile函数的实现。
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
...
this.hooks.make.callAsync(compilation, err => {
...
this.hooks.finishMake.callAsync(compilation, err => {
...
process.nextTick(() => {
...
compilation.finish(err => {
...
compilation.seal(err => {
...
this.hooks.afterCompile.callAsync(compilation, err => {
logger.timeEnd("afterCompile hook");
if (err) return callback(err);
return callback(null, compilation);
});
});
});
});
});
});
});
}
我们可以看到,编译时首先会触发beforeCompile
的async
钩子。然后在回调函数中触发make这个钩子,事实上make这个钩子就是正式开始编译,这里才是真正地进入编译流程。在make的回调函数中触发了finishMake这个钩子,然后在process.nextTick
这个hi掉函数中,执行compilation.finish
方法,在compilation.finish的回调函数中执行compilation.seal
方法,在这个方法中触发afterCompile
的钩子,到此编译阶段正式完成。我们可以整个编译阶段触发了:beforeCompile
,compile
,make
,finishMake
以及afterCompile
等五个钩子。这其中make
这个钩子是真正地编译,是非常重要的钩子。这里我们还是不去详细了解每个钩子的所有实现,先不关注细节。
四、编译输出阶段
我们在之前的run说到过this.compile(onCompiled);
,意思是编译完成之后,执行onCompiled这个回调函数,也就是编译完成后所做的事情,因此这里我们去看下omCompiled
函数的实现。
const onCompiled = (err, compilation) => {
...
if (this.hooks.shouldEmit.call(compilation) === false) { // 看这里,看这里,shouldEmit是否可以输出
...
}
process.nextTick(() => {
...
this.emitAssets(compilation, err => { // emitAssets进行输出
...
});
});
};
onCompiled
函数会首先触发一个shouldEmit
的钩子,这个钩子用于询问是否可以进行输出。如果不可以则触发done
这个钩子;如果可以,那么就会执行process.nextTick
的回调函数中的emitAssets
函数,这个函数是非常重要的函数,就是用来输出编译后的文件。这个函数的内容非常多,这里就不作展示了,这里我们知道它会触发afterEmit
和done
两个钩子,afterEmit
表示输出完成,done
表示所有流程结束。
总结
好了,到目前为止,我们已经介绍了webpack从创建编译器到编译结束输出的各个阶段以及每个阶段触发的重要的钩子。通过了解webpack的大致流程以及每个流程做的事情即触发的钩子,我们可以对整个webpack的内部运行有一个大致的印象。具体的每个钩子做什么事情,或者你想要知道webpack的某些事情在哪个阶段,比如webpack是如何查找入口进行打包的。我们都可以粗略地知道它可能在哪个阶段,可能在哪个钩子中,然后直接找到相对应的代码查看即可,而不再去查看整个源码,这也是查看源码的一个技巧。
其他——关于阅读源码的技巧
这篇文章中,我们涉及到了源码的阅读,很多人可能对源码阅读比较恐惧,看到源码几千行,跳来跳去不知道如何下手,这里说一下个人的阅读源码的技巧。
- 折叠代码。进入文件后第一件事就是折叠代码,不要陷入几千行的代码中,很多代码都是无关的,不需要关注的细节,这个非常重要。
- 不看变量定义,函数定义。变量定义,函数定义函数定义这些都是跟主流程无关的,我们不需要知道为什么要去定义这样一个变量,定义这样一个函数,很多时候喜欢拆分的作者,喜欢将一个一行代码都拆分成一个函数,导致整个源码定义了很多行数,我们最后读下去发现可能几十个函数才实现了一个重要的功能。因此我们先不要去关注所有的函数细节(重要函数除外),只有调用了的重点函数才去关注。
- 不去看if中的语句。因为if中的语句通常是分支,是在一定条件下才会执行的,所以它肯定不是主逻辑,不会影响主逻辑的执行,因此可以先不看。
- 需要看if.. else和try...catch。if...else是肯定会执行的,try...catch中的try也是一定会执行的,也就是说他们会影响主逻辑,因此需要读。
- 尽可能地少关注细节,先读个大概,了解主要流程,然后再去查找细节。否则容易劝退。
- 需要使用编辑器的前进,返回按钮。我们在读源码时经常会跳来跳去,这时候如果我们想要回到之前的地方,如果再通过手动一个一个去找就很麻烦了,这时候就需要借助编辑器的回退功能,比如VSCODE的回退快捷键就是
Alt+<
,通过这些编辑期能够快速地帮助我们回到我们想要的地方。