1. 写作初衷
目前各大论坛上存有非常多的针对 webpack 5 全生命周期流程总结的资料,但相当一部分内容都只是片段性的概述,并未贯通整体 webpack 核心流程,因此对绝大多数读者来说并不能形成一套完整、闭环的知识体系。还有些资料并未能由浅入深,而是开篇就直接逐行解析源码,导致很难理解透彻,比如有些内容拉开阵仗讨论了半天插件,可很多读者连什么是钩子(Hook)都还不清楚,最后只好一知半解不了了之。有的则是只停留在 webpack 的相关配置上,并未能以 webpack 的全生命周期为视角逐步由浅入深讨论,导致很多人的水平始终停留在 webpack "调配员师的阶段,始终无法突破自己。因此,本文将以 webpack 的初始化阶段、编译构建阶段、生成阶段为整体流程,最终以插件的实现机理为落地,由浅入深地摸清楚 webpack 核心运作机制,并在此过程中逐步剖析一些核心概念。还会列举一些直观、风趣的示例供大家理解,同时也欢迎多批评指正。
2. webpack 基本阶段划分
首先直接看官网对 webpack 的整体介绍:webpack 是一套静态的资源模块打包器。逐字解读一下为:
- 静态:即针对的是静态资源,且是在一次完整的生命周期内打包完成;
- 资源模块:webpack 将一切内容资源都视为模块(Module),这也是其模块化的具体表现;
- 打包器:即以所配置的入口为起点,将资源统一转为 AST 语法树,再分析各模块的依赖关系,最终将经过编译后得到的产物(assets)输出到指定目录中。 所以,核心内容在于打包器的实现过程上(即 webpack 核心过程),总体可划分为三个阶段:「初始化阶段」、「编译构建阶段」、「生成阶段」。
3. 初始化阶段
webpack 的起点为项目中的配置文件webpack.config.js
,最常用的配置内容包括entry
(入口配置)、output
(输出配置)、mode
(模式配置)、loader
(加载器配置)、plugin
(插件配置)等。当我们执行 webpack 的打包命令时,则会先进入初始化阶段,其中核心工作内容梳理为:
- 将具体的执行命令参数(
process.args
)与上述配置文件进行初始配置项整合,生成一个只针对当前这条执行命令的具体配置项; - 遍历插件集,依次执行插件类的
apply
方法,具体执行机制主要是通过将下文即将介绍的Compiler
编译器实例对象作为各个插件类apply
方法的入参,并在其内部逻辑中通过监听不同钩子函数的触发时机来执行相应的功能; - 通过钩子机制监控整个执行编译构建生成前的流程(触发
make
钩子前)。
具体看一下整个初始化过程的源码,这里主要为createCompiler
函数的具体实现:
// /lib/webpack.js
const createCompiler = (rawOptions) => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
const compiler = new Compiler(
/** @type {string} */ (options.context),
options
);
if (Array.isArray(options.plugins)) {
plugin.apply(compiler);
}
applyWebpackOptionsDefaults(options);
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
if (callback) { // 如果传递了回调
compiler.run((err, stats) => {
compiler.close(err2 => {
callback(err || err2, stats);
});
});
}
return compiler;
}
module.exports = webpack;
相关解析:
- 整合配置项
options
对象和默认选项,生成最终的配置项结果。options
即为在webpack.config.js
中的内容; - 通过
new Compiler()
创建实例对象 compiler。可以看出下面的核心代码基本都与 compiler 相关,也印证了 webpack 官网针对 Compiler 的一句概述:Compiler 对象是整个 webpack 的引擎; - 遍历当前配置的插件集合(
plugins
),并执行各插件的apply
方法,将 compiler 以参数形式传入; - 触发 compiler 的 两个钩子:
environment
、afterEnvironment
,即宣告当前 webpack 的环境准备完成; - 环境准备结束后,注册初始化一些 webpack 自带的内部默认插件
compiler.hooks.initialize.call()
; - 执行
compiler.run
函数,真正运行起构建流程。
所有compiler.run()
之前的处理过程都是初始化阶段,在此之后就是执行具体的模块化编译构建和生成阶段了。
现在我们再回顾一下上述初始化过程中出现的核心内容:Compiler
、Hook
、Plugin
。下文我们分别对其展开介绍和讨论。
4 钩子及 Tapable 概述
4.1 钩子有多重要
首先需要明白一点:webpack 之所以强大,很重要的原因在于使用者可以充分利用其丰富的插件体系(Plugins,下文有述)在各个阶段改变构建或生成策略,使最终的产物结果符合既定要求。我们通常会使用到各种各样的插件,每一种插件的作用时机都不尽相同,就好比你做木须肉一样,油温几成热的时候下葱姜蒜,炒多长时间后下肉,肉再炒制几成熟的时候下鸡蛋木耳,再什么时候下酱油、盐、白糖和胡椒粉,最后什么阶段再淋点儿锅边醋装盘上桌等。这每一步的具体操作都可以理解为是一个插件,而每一步的触发时机都能理解为是一个钩子(Hook)。整个过程是将食材炒制成菜肴的过程,而不同阶段会触发不同 Hook 进而执行不同的插件以完成相应的工作内容。 。下表列举一些常用的核心钩子:
阶段 | Hook 名称 | 触发时机 |
---|---|---|
初始化阶段 | environment、afterEnvironment | 创建完compiler 实例且执行了配置内定义的插件的apply 方法后触发(上述代码示例有述) |
初始化阶段 | entryOption、afterPlugins、afterResolvers | 执行EntryOptions 插件和其他 Webpack 内置插件,以及解析了resolver 配置后触发 |
构建阶段 | normalModuleFactory、contextModuleFactory | 在两类模块工程创建后触发 |
构建阶段 | beforeRun、run、watchRun、beforeCompiler、compiler、thisCompilation、compilation、make、afterCompile | 在运行构建过程中触发 |
生成阶段 | shouldEmit、emit、assetEmitted、afterEmit | 在两类模块工程创建后触发 |
生成阶段 | failed、done | 生成产物在达到最终结果状态时触发 |
现在再看一下上段初始化代码中调用的两处钩子函数代码:compiler.hooks.environment.call(); compiler.hooks.afterEnvironment.call();
。可以看到它们都是挂载到compiler.hooks
下的。而 compiler 之所以具备这样的能力,要归功于它扩展(extend)自Tapable
类,以便注册和调用插件。所以也可以说 webpack 的 Hooks 底层来源于 Tapple 框架。
4.2 Tapable,钩子之父
Tapple 通俗来说是一个专用于管理过钩子事件监听与触发的小型库,并且对外暴露出了许多 Hook 类,可以用来为插件创建相应 hooks。当 webpack 构建过程中到指定钩子上时,就会被依赖当前钩子的插件所捕获,并注入该插件的相应构建逻辑。包括我们上文初始化阶段创建的 Compiler 以及下文即将介绍的 Compilation 都是得益于继承自 Tapable 才有了强大的作用。因此,这种模式可以说类似于发布-订阅模式,但也不完全是,姑且可以先这样理解。下例所示为插件系统中用到的 Tapple 的一些钩子函数:
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
下面看一下各钩子的划分及使用要点:
钩子名 | 执行方式 | 使用要点 |
---|---|---|
SyncHook | 同步串行 | 不关心监听函数的返回值 |
SyncBailHook | 同步串行 | 只要监听函数中有一个函数的返回值不为null ,则跳过剩下所有的逻辑 |
SyncWaterfallHook | 同步串行 | 上一个监听函数的返回值可以传给下一个监听函数 |
SyncLoopHook | 同步循环 | 当监听函数被触发的时候,如果该监听函数返回true 时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环 |
AsyncParallelHook | 异步并发 | 不关心监听函数的返回值 |
AsyncParallelBailHook | 异步并发 | 只要监听函数的返回值不为null ,就会忽略后面的监听函数执行,直接跳跃到callAsync等触发函数绑定的回调函数,然后执行这个被绑定的回调函数 |
AsyncSeriesHook | 异步串行 | 不关系callback() 的参数 |
AsyncSeriesBailHook | 异步串行 | callback()的参数不为null,就会直接执行callAsync等触发函数绑定的回调函数 |
AsyncSeriesWaterfallHook | 异步串行 | 上一个监听函数的中的callback(err, data)的第二个参数,可以作为下一个监听函数的参数 |
除此之外,Tapable 还对外暴露了tap
、tapAsync
、tapPromise
三个最常用的绑定 hook 的方法,用来向 webpack 注入自定义构建的步骤。后面的插件章节中会有相关示例。
5. Compiler,合格的项目经理
现在我们把目光再投回 compiler 实例对象身上,上文我们多次强调了 Compiler 的重要性,说它是 webpack 的引擎,管控着整个构建生成阶段的核心流程。那它又是如何去实施的呢? 从上面的示例代码createCompiler
的内部实现逻辑上我们已领教了它的出彩,主要戏份为:
- 遍历并执行各插件的
apply
方法,将 compiler 以参数形式给到各插件; - 执行
compiler.run
。 姑且把第一点留到下章 plugin 中介绍,现在我们深入一下compiler.run
方法,看看它主要做了什么。 分阶段查看 Compiler 类内部的相关源码,先看其contructor
部分:
// /lib/Compiler.js
const {
SyncHook,
SyncBailHook,
AsyncParallelHook,
AsyncSeriesHook
} = require("tapable");
class Compiler {
constructor(context)
this.hooks = Object.freeze({
initialize: new SyncHook([]),
...
done: new AsyncSeriesHook(["stats"]),
...
run: new AsyncSeriesHook(["compiler"]),
emit: new AsyncSeriesHook(["compilation"]),
...
thisCompilation: new SyncHook(["compilation", "params"]),
compilation: new SyncHook(["compilation", "params"]),
...
compile: new SyncHook(["params"]),
make: new AsyncParallelHook(["compilation"]),
...
})
...
}
...
由上述代码知,在 Compiler 初始化实例时会定义一堆 Hook(this.hooks
),用于管控全构建流程,这里上例代码只将一些核心的列举了出来,具体所有的钩子定义可以查看官网事例 Compiler.js 及相关文档compiler 钩子,绝大多数我们日常都用不到,故本文不再深入展开。
接着再看下面的Compiler.run
:
// /lib/Compiler.js
class Compiler {
...
run(callback) {
...
const onCompiled = (err, compilation) => {})
const run = () => {
this.hooks.beforeRun.callAsync(this, err => {
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
this.readRecords(err => { // 读取之前的 records
if (err) return finalCallback(err);
this.compile(onCompiled);
});
});
});
};
}
从run
方法的内部实现可以看到,其最终的本质是调用了compile
方法,那在其调用前,会依次触发beforeRun
和run
钩子。同时执行compile
方法是在读取之前的构建记录(records)的回调函数中,并将onCompiled
函数函数作为参数传入(在 compile 过程后调用 onCompiled,主要用于输出构建资源,具体该函数的内部实现,大家可以自行查看源码学习)。我们这里主要看它最后一层洋葱:Compile.compile
:
// /lib/Compiler.js
class Compiler {
...
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
...
const compilation = this.newCompilation(params);
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 => {
if (err) return callback(err);
return callback(null, compilation);
});
});
});
});
});
});
});
}
}
}
由上述compile
方法的内部核心源码上面可以看到,compile
控制了相当重要的构建生成内容,主要包括:
- 通过
newCompilation
方法创建了compilation
实例,这个可以算是丰功伟绩,这里留些悬念,下文章节细述; - 触发了
make
钩子,并将compilation
以参数形式传入到回调函数中,至此主流程才真正进入到了构建阶段; - 之后依次执行了
finish
、seal
函数以及触发了afterCompile
钩子,至此编译过程结束,整体进入构建收尾阶段。
综上,我想可以把 compiler 比做一个项目经理(简称项管),他们深知一个项目的各个节点内容,并做好一定把控,比如及时知会开发人员提测、测试人员跟测、产品经理验收、预发布&正式发布跟进等等。虽然可能对每个节点的工作内容不完全了解,但他们关注的是进度,是风险,更是收益。所以,大家后续一定要和项目经理们搞好关系。
6. Compilation,深入一线的操盘者
上章中有述 compilation 实例对象的创建时机是在 Compiler.compile
方法中,本章我们重点了解一下它。我们目前还未深入到 webpack 的第二大核心环节「构建阶段」阶段,但需要清楚一点:webpack 的构建过程是将一切内容资源都视为模块(Module),这也是其模块化的具体表现。 而 Compiler 的设计使它不会深入下放到一线去,这个时候就需要 Compilation 去完成了。就好比将军不会去检查今天某连队中午吃啥,而要交给炊事班负责,因为将军的主要职责不包括这么具体,最多是视察一下。
看一段来自官网的介绍:
compilation
实例能够访问所有的模块和它们的依赖(大部分是循环依赖)。 它会对应用程序的依赖图中所有模块, 进行字面上的编译(literal compilation)。 在编译阶段,模块会被加载(load)、封存(seal)、优化(optimize)、 分块(chunk)、哈希(hash)和重新创建(restore)。
Compilation 模块也继承自 Tapable 类,主要工作包括模块依赖分析、优化、连接,打包等。一个 compilation 对象代表了一次单一的版本构建和生成资源,它储存了当前的模块资源、编译生成的资源、变化的文件、以及被跟踪依赖的状态信息。简单来说,Compilation 的职责就是对所有 require 图(graph)中对象的字面上的编译,构建module
和chunk
,并利用插件优化构建过程,同时把本次打包编译的内容全存到内存里。compilation 实例对象只代表了一次资源版本构建,即可以理解为每当执行一次构建编译过程,就会重新实例化一次 compilation 去做事,这一点是与 Compiler 实例最大的区别,Compiler 自始至终只会创建一次。其对象实例 compiler 全局唯一。 当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。
既然要深入模块构建阶段,那 Compilation 必然也有它自己的钩子体系,详情可查看官网compilation 钩子。
同样挑选几个常用的钩子:
阶段 | Hook 名称 | 触发时机 |
---|---|---|
构建阶段 | buildModule、rebuildModule、finishRebuildingModule、failedModule、succeedModule | 在构建单个模块时触发 |
构建阶段 | finishModules | 在所有模块构建完成后触发 |
优化阶段 | optimize、optimizeModules、afterOptimizeModules、optimizeChunks、afterOptimizeChunks | 优化阶段开始时、结束后等节点触发 |
Compilation 的钩子调用也很简单:
compilation.hooks.someHook.tap(/* ... */);
比如在某个模块构建开始之前触发的buildModule
钩子,用它来强制模块开启souceMap
:
compilation.hooks.buildModule.tap(
'SourceMapDevToolModuleOptionsPlugin',
(module) => {
module.useSourceMap = true;
}
);
7. 插件,你终于来了
对,没错,此时我最想说的一句话就是“插件,你终于来了”。为了引出插件,前面真的是花费了太多内容,感谢你能坚持看到现在,那就让我们一起把这最后一程走完。现在我们的知识储备也基本可以去理解插件(plugin)了。首先我们都知道 webpack 对外提供了 Loader 和 Plugin 两种扩展方式,其中 Plugin 功能更强大,是因为在 webpack 的设计体系中,在整个构建阶段都对外提供了极其强大的钩子功能供开发者几乎改写任何构建策略,并最终影响生成产物结果。因此可以说 webpack 的插件机制是一种非常弹性的引擎机制。也可以说 webpack 引擎是基于插件系统搭建而成的。这点足以说明其重要性。
但在开发插件时,我们必须要提早知道所要编写的插件需要在哪个阶段去执行,是同步还是异步执行。我们上文中有说到,插件类的apply
方法的入参是 compiler ,而 compiler 的特点之一就是钩子触发,因此,在插件内部,我们基本都会看到各种各样的基于compiler的钩子调用,再由 compilation 这个实干家去以回调函数入参的形式被调用。直接看两个例子直观感受一下:
设计一个插件,要求在构建完成后,输出一个记录所有构建文件名的filelist.md
文件(包括资源和大小):
class GenerateFileList {
constructor(option) {
this.option = option
}
apply(compiler) {
compiler.hooks.emit.tap('GenerateFileList', compilation => {
let filelist = '构建后的文件: \n'
for (var filename in compilation.assets) {
filelist += '- ' + filename + '\n';
}
compilation.assets['filelist.md'] = {
source: function() {
return filelist
},
size: function() {
return filelist.length
}
}
})
}
}
由上例可见,当在webpack 运行周期中一旦emit
钩子事件被触发后,setFileList
插件就会开启工作,通过compilation.assets
获取当前所有的构建产物资源文件集,并通过for...in
遍历获取filename值并拼接。最后在compilation.assets
中再加入一个filelist.md
文件,内容为当前生成的产物文件名集字符串及大小。
现在我们稍微上点儿难度,在构建即将完成时把所有.js
、.css
文件再进行一遍基于gzip的压缩并返回,同时在配置插件时可以传入压缩等级进行控制:
const zlib = require('zlib')
class GzipPlugin {
constructor(option) {
this.option = option
}
apply(compiler) {
compiler.hooks.emit.tap('GzipPlugin', compilation => {
for (var filename in compilation.assets) {
if (/(.js|.css)/.test(filename)) {
const gzipFile = zlib.gzipSync(compilation.assets[filename]._value, {
//压缩等级
level: this.option.level || 7
})
compilation.assets[filename + '.gz'] = {
source: function () {
return gzipFile
},
size: function () {
return gzipFile.length
}
}
}
}
})
}
}
同样,我们也可以当触发 compilation 上的钩子函数时,能够为我们执行某些任务。同时也尝试使用一下tapAsync
异步事件钩子(或使用tapPromise
,只需在回调方法中返回一个 Promise 即可):
// 一个插件类基本结构示例:
class TestCompilationHookPlugin {
constructor(options = {}) {
...
}
apply(compiler) {
compiler.hooks.compilation.tap('TestCompilationHookPlugin', (compilation) => {
compilation.hooks.optimize.tap('HelloCompilationPlugin', () => {
console.log('资源正在优化');
});
});
compiler.hooks.emit.tapAsync(
'TestCompilationHookPlugin',
(compilation, callback) => {
}
);
}
}
module.exports = HelloCompilationPlugin;
8. 总结
本文以 webpack 插件体系的实现机理为视角,完整地将其所涉及的整个初始化阶段、构建阶段以及生成阶段做了较为详细地梳理概述,包括 Hooks、Tapable、Compiler、Compilation 等核心要素。最后通过列举部分插件的内部实现逻辑将所有涉及的知识点进行了串联,最终形成了完整的知识链路。希望通过这样的方式能够帮助大家更好的理解 webpack 整体流程。但本篇文章并未着重介绍 webpack 的核心构建逻辑,包括模块整合以及监听机制,期待后续能有时间多做一些分享。创作不易,欢迎点赞和关注。