一、前言
最近在学习许多框架的底层插件化实现,如ice.js、umi.js,在这些框架中,我们都可以在介绍中看到支持可插拔灵活的插件系统,那插件化的本质是怎么实现的?
此时不如回归本质,在我们学习Webpack的时候,都遇到过面试题------Webpack插件是怎么实现的?当我们思考插件化的实现,不如回到Webpack,从源码看看在前端工程化领域看看插件化是怎么设计的。
二、插件化
插件化底层涉及两个核心概念。
- 钩子(生命周期),在每个生命周期阶段都可以触发很多插件,比如
webpack开始构建、生成html文件、完成构建等等。 - 插件(每个钩子阶段做的事情),比如在生成
html文件后再二次修改处理一些文件,这是插件做的事情。
那钩子和插件如何结合起来呢?
使用发布订阅模式。
这可以让钩子和插件充分的解耦。
一个钩子可以触发多个插件(运行时代码);
一个插件可以在多个钩子阶段处理(日志,在每个钩子打日志);
而webpack底层依赖tapable库,这个库就是提供增强版本的发布订阅模式。
webpack在初始化开始构建之前,会发布所有的生命周期钩子、初始化所有的插件。
并且将钩子和插件关联起来。之后,webpack在主流程中定期的触发钩子就行。
就像这样:
ts
const Compiler = require("./Compiler");
const WebpackOptionsApply = require("./WebpackOptionsApply");
const webpack = (options, callback) => {
// 1. 创建 Compiler 实例
const compiler = new Compiler(options.context, options);
// 2. 把所有用户配置的 plugins 应用到 compiler 上
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
plugin.apply(compiler); // ← 注意这里!逐个执行插件的 apply
}
}
// 3. 执行 Webpack 自带的一些内部插件
new WebpackOptionsApply().process(options, compiler);
if (callback) {
compiler.run(callback); // 执行构建流程(会触发 hooks)
}
return compiler;
};
这就是插件化在webpack中的原理。
下面我们从webpack源码来看看它是如何实现的。
三、源码入手
我们从webpack源码开始,看下插件化是如何实现的。
webpack主流程代码在webpack/lib/webpack.js中。
在这里会做初始化的事情,比如Compiler初始化、所有插件初始化。

- 这里首先初始化了
Compiler实例,后续的所有生命周期钩子都会在里面初始化。 - 然后读取了所有的插件,执行了插件函数,并将
Compiler实例作为参数传递,这一步成功将Compiler的this指向到了所有插件,让插件的函数绑定在了钩子上。
而这些Webpack插件我们都知道它的写法类型是固定的。
- 是个
class。 - 必须有
apply函数。
看到这里是不是秒懂了?apply函数就是让我们写的plugin和webpack建立关系。绑定在生命周期钩子上。
这里给一个Plugin代码示例:
ts
class MyPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
// 订阅 Compiler 阶段
compiler.hooks.run.tap('MyPlugin', (compilerInstance) => {
console.log('[MyPlugin] 开始运行,options:', this.options);
});
// 订阅 Compilation 阶段
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS
},
(assets) => {
console.log('[MyPlugin] 当前构建的资源:', Object.keys(assets));
assets['extra.txt'] = {
source: () => '这是 MyPlugin 生成的文件',
size: () => Buffer.byteLength('这是 MyPlugin 生成的文件')
};
}
);
});
// 订阅 done 阶段
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('[MyPlugin] 构建完成,耗时:', stats.endTime - stats.startTime, 'ms');
});
}
}
使用这个插件也很简单。
在webpack.config.js中这样配置下就行:
ts
// webpack.config.js
const path = require('path');
const MyPlugin = require('./plugins/MyPlugin');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true
},
plugins: [
new MyPlugin({ customOption: 'abc' })
]
};
回到webpack源码。
Compiler 本身并不关心插件注册,它只是提供 lifecycle hooks(事件源)。
然后走到webpack/lib/compiler.js中,在类初始化中基于tapable创建了webpack的所有生命周期。

OK,到这里插件化的两大要点已经结束。插件、生命周期Hook都已经读完了,接下来Webpack会做啥?
执行构建 -> 在构建中不断地触发各种Hook进而触发来自不同插件的逻辑。

这不就是插件化的原理么?
在Webpack中体现。
一句话概括。
在Webpack从源码到构建出产物这件事情注入很多的阶段,并在每个阶段触发指定的插件。
那Tapable是怎么实现的?我们同样去看下源码。
找到tapable/lib/Hook.js,这是所有Hook的子类,我们看下子类怎么监听的就行。

这是个抽象类,本质也是基于发布订阅模式的实现。
通过tap来保存订阅列表。
通过call来触发订阅回调。
当在webpack中订阅的时候
ts
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, cb) => { ... });
实际上就是走到了这里:
ts
tapAsync() → _tap("async", options, fn)
然后保存到this.taps中。
而当webpack发布事件的时候:
ts
compiler.hooks.emit.callAsync(compilation, done);
tapable会遍历taps按注册顺序依次触发回调,最终实现webpack插件的代码逻辑。
这就是Webpack插件化的原理,也是市面上很多应用框架插件化的底层原理。
我们在umi、ice代码库中都可以搜到tapable,而使用姿势和Webpack是差不多的。
四、结尾
插件化是前端应用框架的基石,也是设计模式精妙的应用体验。
看到这里,相信你对Webpack原理更深了一步,同时也更加熟悉许多插件的设计理念了。
如果文章对你有帮助,欢迎在评论区探讨。