三、实战:从零实现一个简易 Tapable
为了彻底理解插件系统,我们不妨亲自动手实现一个迷你的 Tapable。在真实的 Webpack 源码中,Tapable 使用了复杂的代码生成技术(new Function)来动态创建执行函数,以追求极致的性能。为了易于理解,我们使用更直观的 JavaScript 方式来实现。
1. 实现 SyncHook
SyncHook 是最基础的同步钩子。
// Tapable/SyncHook.js
class SyncHook {
constructor(args = []) {
this._args = args; // 钩子接受的参数列表
this.taps = []; // 存储所有订阅的插件回调
}
// 插件订阅钩子
tap(name, fn) {
this.taps.push({ name, fn });
}
// 触发钩子执行
call(...args) {
// 依次同步执行所有插件逻辑
this.taps.forEach(tap => {
tap.fn(...args);
});
}
}
2. 实现 AsyncSeriesHook
AsyncSeriesHook 是最核心的异步串行钩子,Webpack 许多涉及文件 IO 或网络请求的钩子(如 emit)都使用这种类型。
// Tapable/AsyncSeriesHook.js
class AsyncSeriesHook {
constructor(args = []) {
this._args = args;
this.taps = [];
}
// 异步订阅
tapAsync(name, fn) {
this.taps.push({ name, fn });
}
// 触发异步串行执行
callAsync(...args) {
// 最后一个参数通常是 Webpack 完成当前阶段后的回调函数
const finalCallback = args.pop();
let index = 0;
const next = () => {
if (index === this.taps.length) {
return finalCallback(); // 所有插件执行完毕,通知 Webpack 继续
}
const tap = this.taps[index++];
// 每个插件执行完后,必须调用 next() 才能进入下一个插件
tap.fn(...args, next);
};
next();
}
}
通过这两种简单的实现,我们就能够模拟 Webpack 的绝大部分核心逻辑了。
四、核心改造:为 Compiler 注入钩子
现在,我们将这两个钩子引入到我们的 Compiler 类中,让它具备「生命周期」的概念。
1. 改造 Compiler 类
Compiler 是 Webpack 的指挥官。它在启动时会初始化所有插件,并暴露出核心的生命周期钩子。
// Compiler.js
const { SyncHook, AsyncSeriesHook } = require('./Tapable');
class Compiler {
constructor(options) {
this.options = options;
this.hooks = {
// 这里的参数定义了钩子触发时传递的数据
run: new AsyncSeriesHook(['compiler']),
compile: new SyncHook(['params']),
afterCompile: new SyncHook(['compilation']),
emit: new AsyncSeriesHook(['compilation']),
done: new SyncHook(['stats']),
};
// 关键步骤:遍历插件并调用 apply
if (Array.isArray(options.plugins)) {
options.plugins.forEach(plugin => {
// 插件必须实现 apply 方法
plugin.apply(this);
});
}
}
// 启动打包
run(callback) {
// 1. 触发 run 钩子(异步)
this.hooks.run.callAsync(this, () => {
console.log('Webpack: 准备开始编译...');
// 2. 触发 compile 钩子(同步)
this.hooks.compile.call();
// 3. 执行核心编译流程(之前章节实现的内容)
const compilation = this.newCompilation();
compilation.buildGraph(this.options.entry);
// 4. 触发 afterCompile 钩子
this.hooks.afterCompile.call(compilation);
// 5. 触发 emit 钩子(异步:通常用于生成文件)
this.hooks.emit.callAsync(compilation, () => {
// 将生成的 bundle 写入磁盘
this.emitAssets(compilation);
// 6. 触发 done 钩子
this.hooks.done.call();
callback && callback();
});
});
}
}
2. 引入 Compilation 类
在真实的 Webpack 中,Compiler 代表整个生命周期,而 Compilation 代表单次构建过程。每当文件发生变化重新打包时,Compiler 会创建一个新的 Compilation 实例。
// Compilation.js
class Compilation {
constructor(compiler) {
this.compiler = compiler;
this.options = compiler.options;
this.assets = {}; // 存储最终输出的所有文件内容
this.modules = []; // 存储所有模块
}
// 构建依赖图
buildGraph(entry) {
// ... 之前章节实现的模块解析与依赖图构建逻辑 ...
}
}
通过引入 Compilation,插件不仅可以监听全局的 Compiler 钩子,还可以通过 compiler.hooks.compilation 监听到单次构建的钩子,从而更精细地控制模块处理过程。
五、实战:实现两个经典 Webpack 插件
为了演示插件的强大能力,我们将亲手实现两个功能齐全的插件:一个用于自动生成 HTML 文件的 HtmlWebpackPlugin,以及一个用于清空目录的 CleanWebpackPlugin。
1. 实现 CleanWebpackPlugin
该插件的逻辑非常简单:在构建开始前,清空输出目录。
// plugins/CleanWebpackPlugin.js
const fs = require('fs');
const path = require('path');
class CleanWebpackPlugin {
apply(compiler) {
// 监听 emit 钩子(异步串行)
// 注意:在真实 Webpack 中通常监听 run 或 watchRun
compiler.hooks.emit.tapAsync('CleanWebpackPlugin', (compilation, callback) => {
const outputPath = compiler.options.output.path;
console.log(`CleanWebpackPlugin: 正在清空目录 ${outputPath}...`);
// 递归删除目录内容
if (fs.existsSync(outputPath)) {
this.deleteFolderRecursive(outputPath);
}
callback(); // 执行完毕,通知 Webpack 继续
});
}
deleteFolderRecursive(folderPath) {
if (fs.existsSync(folderPath)) {
fs.readdirSync(folderPath).forEach((file) => {
const curPath = path.join(folderPath, file);
if (fs.lstatSync(curPath).isDirectory()) {
this.deleteFolderRecursive(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(folderPath);
}
}
}
2. 实现简易版 HtmlWebpackPlugin
该插件在打包完成后,根据模板生成一个 index.html,并自动通过 <script> 标签引入打包好的资源。
// plugins/HtmlWebpackPlugin.js
class HtmlWebpackPlugin {
constructor(options = {}) {
this.template = options.template || '';
this.filename = options.filename || 'index.html';
}
apply(compiler) {
// 监听 emit 钩子
compiler.hooks.emit.tapAsync('HtmlWebpackPlugin', (compilation, callback) => {
const bundleName = compiler.options.output.filename || 'bundle.js';
// 构建 HTML 内容
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Webpack App</title>
</head>
<body>
<div id="app"></div>
<script src="${bundleName}"></script>
</body>
</html>`;
// 将生成的 HTML 加入到 assets 列表中
// Webpack 在 emit 阶段结束后会遍历 assets 并写入文件
compilation.assets[this.filename] = htmlContent;
console.log(`HtmlWebpackPlugin: 成功生成 ${this.filename}`);
callback();
});
}
}
通过这两个插件,我们已经能够自动化完成「清理 -> 构建 -> 生成 HTML」的全流程了。
3. 进阶实战:实现 DefinePlugin
DefinePlugin 是 Webpack 最常用的插件之一,它允许在编译时创建全局常量。这在根据开发/生产环境切换 API 地址时非常有用。
// plugins/DefinePlugin.js
class DefinePlugin {
constructor(definitions) {
this.definitions = definitions;
}
apply(compiler) {
// 监听 compilation 钩子,获取单次构建对象
compiler.hooks.compilation.tap('DefinePlugin', (compilation) => {
// 监听模块解析后的钩子
// 在我们的简易实现中,可以直接在编译代码阶段进行替换
compiler.hooks.afterCompile.tap('DefinePlugin', (compilation) => {
compilation.modules.forEach(module => {
Object.keys(this.definitions).forEach(key => {
const value = JSON.stringify(this.definitions[key]);
// 全局正则替换(实际 Webpack 使用 AST 替换,更安全)
const regex = new RegExp(`\\b${key}\\b`, 'g');
module._source = module._source.replace(regex, value);
});
});
});
});
}
}
用法示例:
// webpack.config.js
plugins: [
new DefinePlugin({
'process.env.NODE_ENV': 'production',
'VERSION': '1.0.0'
})
]
通过这个插件,你可以深刻理解:Plugin 并不只是操作文件,它们还可以深入到模块的源代码层面进行干预。
六、深度解析:Webpack 完整打包流程回顾
到目前为止,我们已经实现了一个简易 Webpack 的所有关键组件。现在,让我们站在全局的高度,梳理一遍完整的打包生命周期:
1. 初始化阶段 (Initialization)
- 合并配置:从配置文件(如
webpack.config.js)和 shell 参数中读取并合并参数,得出最终的配置对象。 - 加载插件:实例化配置中的所有插件,并调用它们的
apply方法。插件通过tap或tapAsync将自己的逻辑注册到Compiler的各种钩子上。 - 环境初始化:初始化
Compiler运行环境,准备好文件系统读写能力。
2. 编译阶段 (Compilation)
- 开始编译:调用
compiler.run方法,触发run钩子。随后创建Compilation实例,触发compile钩子。 - 确定入口:根据配置中的
entry找出所有的入口文件。 - 编译模块 (Make):从入口文件开始,调用所有配置的
Loader对模块进行转换。再利用Babel将转换后的代码解析为AST(抽象语法树),递归寻找模块依赖的模块。 - 完成模块编译:得到每个模块被翻译后的最终内容以及它们之间的依赖关系图(Dependency Graph)。
3. 输出阶段 (Output)
可以从入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk。再把每个 Chunk 转换成一个单独的文件加入到输出列表(compilation.assets)。
- 写入文件 (Emit):触发
emit钩子,这是修改输出内容的最后机会。随后 Webpack 会根据配置中的output路径和文件名,将文件内容写入到文件系统。
4. 结束阶段 (Done)
- 构建完成:触发
done钩子,输出打包摘要信息。
5. Webpack 内部的「插件化」真相
你可能不知道,当你运行一个最简单的 Webpack 配置时,Webpack 内部已经为你加载了数十个插件:
EntryPlugin: 负责处理entry配置,启动最初的编译。JsonModulesPlugin: 负责解析.json文件。JavascriptModulesPlugin: 负责处理.js文件。TemplatedPathPlugin: 负责处理output.filename中的[name]、[hash]等占位符。
甚至连 Webpack 的文件解析(Resolver)系统都是通过插件扩展的。这种「一切皆插件」的设计思想,使得 Webpack 具有了近乎无限的生命力。
七、常用钩子一览表 (Cheat Sheet)
为了方便大家查阅,我整理了 Webpack 开发中最常用的几个钩子及其用途:
| 钩子名称 | 类型 | 触发时机 | 典型用途 |
|---|---|---|---|
environment | Sync | 配置文件加载后 | 清理环境变量 |
afterPlugins | Sync | 所有插件初始化后 | 确保依赖的插件已安装 |
run | AsyncSeries | 编译开始前 | 读取远程配置、清理目录 |
compile | Sync | 编译器即将创建时 | 拦截编译参数 |
compilation | Sync | 编译对象创建后 | 修改模块构建逻辑 |
make | AsyncParallel | 编译进行中 | 动态添加入口模块 |
seal | Sync | 编译完成前 | 优化 Chunk 结构 |
emit | AsyncSeries | 资源输出到目录前 | 注入 HTML、生成资源清单 |
done | Sync | 编译完全结束 | 发送通知、统计性能 |
八、开发者体验:如何调试与优化你的自定义插件
编写插件时,我们经常会遇到「逻辑不生效」或「打包速度慢」的问题。这里分享几个实用的调试与优化技巧。
1. 利用调试器 (Debugger)
不要只依赖 console.log。你可以通过 Node.js 的调试模式启动 Webpack:
node --inspect-brk node_modules/webpack/bin/webpack.js
然后在 Chrome 开发者工具中进行断点调试,查看 compiler 和 compilation 对象中的实时状态。
2. 避免阻塞主线程
在异步钩子(如 emit)中,确保你总是调用了 callback()。如果你的逻辑非常耗时,考虑使用多进程或缓存机制。
3. 插件执行顺序
记住,插件的执行顺序通常与它们在 webpack.config.js 中的注册顺序一致。如果两个插件修改了同一个资源,后注册的插件会覆盖先注册的修改。
4. 性能监控
Speed-measure-webpack-plugin` 来分析每个插件的耗时情况,找出构建瓶颈。
5. 常见坑点 (Common Pitfalls)
- 忘记调用 callback:在
Async钩子中,如果不调用callback(),Webpack 进程会永远挂起。 - 直接操作文件系统:尽量通过
compilation.assets修改资源,而不是直接使用fs.writeFileSync。这样 Webpack 才能正确追踪资源变化并应用后续的优化插件。 - 误用钩子类型:比如在
compile钩子(同步)中尝试进行异步操作,会导致后续逻辑在异步完成前就已经执行。
十、常见问题解答 (FAQ)
Q1: 插件执行顺序重要吗?
非常重要。Webpack 按照你在 plugins 数组中定义的顺序依次调用它们的 apply 方法。虽然有些钩子是异步并行的,但初始化顺序决定了谁先订阅钩子。
Q2: 我可以在插件中动态添加 Loader 吗?
不建议这样做。Loader 通常在编译前的 resolve 阶段就已经确定了。如果你需要动态转换代码,建议在 compilation 的相关钩子中直接操作模块源码。
Q3: 为什么 Webpack 5 推荐使用 compiler.webpack 对象?
为了保证插件的兼容性。Webpack 5 将很多核心库(如 sources)直接挂载在了 compiler.webpack 上,这样插件就不需要自己去 require 这些依赖,避免了版本冲突。
Q4: 如何在插件中输出多个文件?
你只需要在 compilation.assets 对象上添加多个 key 即可。Webpack 在 emit 阶段会自动遍历这个对象并生成相应的文件。