背景
webpack 因其强大的打包能力以及丰富的插件生态,早已是现代前端工程化的基石了。虽然平时开发过程中基本很少接触到 webpack,但深入理解其原理不仅有助于理解前端的运行机制,也是进阶的必备能力之一。本文尝试通过阅读源码以及调试等手段,揭秘 webpack 内部的工作过程,以及其强大的插件生态的运行机制
注意:本文书写时 webpack 的最新版本为 5.73.0,就以此版本为基准进行后续的分析
基本理念
如图,webpack 是一个打包工具,它可以将不同的文件类型处理成最终的输出,其核心理念就以下4点:
- 入口:告诉 webpack 从那个文件开始解析
- 输出:配置输出的文件路径
- loader:webpack 只能识别 js 和 json 文件,对于其他类型的文件,webpack 通过 loader 将其转成可以识别的模块
- plugin:webpack 提供了插件供用户来对输出内容进行定制化
调试环境搭建
Step1:下载源码
从 github 上下载 webpack 源码
Step2:建立软连接
在 webpack 源码根目录下运行 yarn link,建立软连接:
Step3:创建调试项目
运行mkdir webpack-demo 命令创建 webpack-demo 文件夹
运行 cd webpack-demo 命令进入项目根目录,然后运行 yarn init 命令初始化我们的项目
初始化完成后,运行 yarn link webpack 命令将我们项目中的 webpack 引用路径修改到 Step2 中的 webpack 项目
Step4: 验证链接是否成功
我们先在 webpack 源码的入口文件中添加一行打印:
接着我们在调试项目中添加一个使用 webpack 打包的例子,然后运行 yarn build 命令进行打包,如下,控制台中出现了我们添加到源码中的打印,说明链接成功
在上述链接成功后,我们就可以手动修改 webpack 源码来进行一些调试了
Step5: 断点调试
我们还可以使用 vscode 来进行断点调试 webpack。首先在我们的 webpack-demo 项目中添加调试配置:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debugger",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/node_modules/webpack/bin/webpack.js"
}
]
}
然后我们可以在 webpack 源码项目中输入 debugger 来进行断点,如下,程序停在了 debugger 处,同时我们可以看到此时在 webpack-demo 项目下也打开了对应的 webpack 源码的文件(这个应该是 vscode 实现的一个功能,可以让我们不需要在两个工程中切换了),此时我们可以在这个打开的文件中点击左侧设置断点:
我们切回 webpack-demo 项目,直接按 F5 运行程序,我们可以看到程序成功停在了我们设置的断点处:
打包过程
当用户敲下 webpack 命令进行打包的时候,内部运行的过程如下:先进入 webpack-cli 模块并解析用户命令,然后调用 webpack 模块中的 webpack 函数。在 webpack 函数中会创建 Compiler 对象,并调用 run 函数启动打包。打包时会先创建 Compilation 对象来负责此次的打包,然后分别经历 make 阶段(根据入口文件来加载模块,并分析模块之间的依赖关系)、seal 阶段(根据模块之间的依赖关系,输出中间产物 chunks 和 assets)和 emit 阶段(根据上一阶段的 assets 来输出最终的产物),完成打包并输出最终的产物
插件机制
在更深入的介绍 webpack 打包过程之前,我们先来聊下 webpack 的插件机制。都说 webpack 的成功离不开其强大的插件生态,而 webpack 本身也是由无数个插件构成的。那么 webpack 的插件到底是什么?它又是如何保证插件之间能各司其职,稳定运行的呢?
是什么
先来回到第一个问题,即 webapck 的插件到底是什么。对此,官方的描述如下:
Plugins are the backbone of webpack. Webpack itself is built on the same plugin system that you use in your webpack configuration!
一个 webpack 的插件其实就是一个包含 apply 方法的 js 对象,其中的 apply 就是需要我们去实现的方法,一个插件例子如下:(看到这里你可能还是不知道插件为什么要这样写,先不用急,继续往下看)
// ConsoleLogOnBuildWebpackPlugin.js
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap('ConsoleLogOnBuildWebpackPlugin', (compilation) => {
console.log('The webpack build process is starting!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
在 webpack 的配置中如下使用插件:
const path = require('path');
const ConsoleLogOnBuildWebpackPlugin = require('./ConsoleLogOnBuildWebpackPlugin');
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new ConsoleLogOnBuildWebpackPlugin(),
]
};
至此,一个自定义的插件就开发完成,并成功的引入到实际的打包过程中。那么它又是如何运行的呢?
运行机制
现在来回答第二个问题,即 webpack 是如何保障插件运行的。在 webpack 中,任何插件都会经历如下三个生命周期阶段:
初始化
每个插件都是一个 js 对象,所以初始化就是实例化我们的插件对象,如上面的 ConsoleLogOnBuildWebpackPlugin 插件,我们在配置中通过 new ConsoleLogOnBuildWebpackPlugin() 来初始化插件对象
添加、调用
插件的添加、调用是插件最核心的部分:一个插件如何被方便的添加到运行流程中,又如何在合适的时机进行调用。针对上述的问题,webpack 专门开发了一个框架 tapable 来管理这些插件。tapable 框架中提供了各种类型的 Hooks 来管理这些插件
根据同步或者异步的方式,Hooks 可以分为如下 3 种:
- Sync:同步运行所有插件,只能使用 tap 来绑定插件
- AsyncSeries:异步串行运行插件,可以使用 tap、tapAsync 和 tapPromise 来绑定插件
- AsyncParallel:异步并行运行插件,可以使用 tap、tapAsync 和 tapPromise 来绑定插件
根据运行调用方式,Hooks 又可以分为如下 4 种:
- Basic hook:基本 hook,它会直接调用每个添加的插件
- Waterfall:以一种流水线的形式来运行插件,每个插件的输出都可以作为下一个插件的输入
- Bail:类似于 Promise.race,只要当其中一个插件返回任何值时,就停止运行剩下的插件
- Loop:循环运行各个插件,直到所有插件都返回 undefined
上述两种分类方式进行组合就形成 tapable 框架中提供的所有 Hooks,如下:
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesLoopHook,
AsyncSeriesWaterfallHook
} = require("tapable");
下面举个🌰来说明如何通过这些 Hook 来管理插件的添加和运行:
const { SyncHook } = require("tapable");
// step1. 初始化 hook,这里可以传一个参数列表来表示插件调用时的参数
const sHook = new SyncHook(["val"]);
// step2. 添加插件 LoggerPlugin、TimeCalcPlugin,这里通过 tap 方法来添加插件
// 这里接收两个参数,一个是字符串表示插件名,另一个则是插件运行的函数,参数 val 则是调用时传给插件的参数
sHook.tap('LoggerPlugin', val => console.log('### in logger plugin, val: ', val));
sHook.tap('TimeCalcPlugin', val => console.log('### in time calc plugin, val: ', val));
// step3. 调用插件,这里通过 call 方法来调用插件
sHook.call(123);
/* output
### in logger plugin, val: 123
### in time calc plugin, val: 123
*/
更多关于 Hooks 的使用例子可以参考如下文档:Tapable 框架介绍
插件的应用
我们现在来看下在 webpack 中是如何使用 tapable 提供的 Hooks 来管理这些插件的。我们知道 webpack 的打包主要由 Compiler 和 Compilation 对象实现,而这两个对象都通过 hooks 属性来管理插件。如下,Compiler 对象(Compilation 对象类似)的 hooks 属性中有 initialize、beforeRun、run、done 等 Hooks 实例(tapable 框架提供),从这些实例的命名我们知道它其实就代表着打包的不同阶段
注意:在下面的描述中,hooks 表示 Compiler 或者 Compilation 对象的属性,Hooks 则表示 tapable 框架提供的实例
class Compiler {
constructor(context, options = ({})) {
this.hooks = Object.freeze({
initialize: new SyncHook([]),
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
done: new AsyncSeriesHook(["stats"]),
...
})
}
}
class Compilation {
constructor(compiler, params) {
this.hooks = Object.freeze({
addEntry: new SyncHook(["entry", "options"]),
buildModule: new SyncHook(["module"]),
rebuildModule: new SyncHook(["module"]),
...
})
}
}
在初始化上述 Hooks 实例后,webpack 会在实例上添加对应的插件,其中即有 webpack 内部的插件,也有我们自定义的插件。而添加的时机主要在新建 Compiler 对象后。如下,在新建一个 Compiler 对象后,webpack 会先调用用户自定义的插件(配置中的 plugins 选项),并将当前 Compiler 对象作为参数传入 apply 方法中,然后调用 WebpackOptionsApply 对象的 process 方法添加 webpack 内部的插件(注意:webpack 打包过程中,插件的添加非常灵活,在任何阶段都是有可能添加的,甚至可以动态的添加,所以下面列出的只是一个比较集中的插件添加的地方)
我们以下面的自定义插件为例,来继续看下插件是如何被添加到打包的流程中并被调用的。上面我们说到 webpack 会调用插件的 apply 方法并将新建的 Compiler 对象作为参数传入,此时我们就可以通过 compiler.hooks 属性来将我们的插件添加到对应阶段的 Hooks 实例上。在下面的代码中我们将 ConsoleLogOnBuildWebpackPlugin 插件添加到了 run 这个 Hooks 实例上
// ConsoleLogOnBuildWebpackPlugin.js
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap('ConsoleLogOnBuildWebpackPlugin', (compilation) => {
console.log('The webpack build process is starting!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
然后 webpack 运行到某一个阶段时,会调用 run (Hooks 实例)上的所有插件,那么此时我们的插件内容就会被调用。如下:
以上即是我们自定义插件被添加和调用的全过程,webpack 在 Compiler 和 Compilation 对象的 hooks 属性中创建了代表各个阶段的 Hooks 实例,并在调用插件的 apply 方法时,将 compiler 对象作为参数传入,使用户可以通过 compiler.hooks 将插件添加到对应的 Hooks 实例上,然后由 webpack 在打包的对应阶段进行调用
webpack 就是通过这种方式来让用户方便的添加各种自定义插件到打包的任何阶段,当然 webpack 内部插件的添加和调用过程也是一样的,唯一的区别是他们被添加到了 Compiler 或者 Compilation 对象不同阶段的 Hooks 实例上,并在打包的不同阶段被调用
注意:在某些 Hooks 实例上,还会接收 Compilation 对象作为参数,此时我们可以通过 compilation.hooks 来将插件添加到 Compilation 对象的对应 Hooks 实例上,还有一些其他的添加方式等等,此处不再举例
总结
本文讲解了 webpack 的基本概念,源码调试方法,以及其打包的主要过程,包括模块加载、依赖分析、编译并输出最终产物的过程。然后重点讲述了插件机制,讲到了 webpack 是如何使用 tapable 框架来管理所有的插件的,包括插件是如何被添加到具体的打包过程中,又是如何被调用的
希望看完本文后能对理解 webpack 有所帮助。接下来我会对 webpack 的实际打包过程做一个更加详细的解析,具体请看:[webpack 原理解析(二)]