本文示例源代码请戳 github 博客,建议大家动手敲敲代码。
webpack 本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是 Tapable,webpack 中最核心的负责编译的 Compiler 和负责创建 bundles 的 Compilation 都是 Tapable 的实例。Tapable 暴露出挂载 plugin 的方法,使我们能 将 plugin 控制在 webapack 事件流上运行(如下图)。
Tabable 是什么?
tapable 库暴露了很多 Hook(钩子)类,为插件提供挂载的钩子。
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
Tabable 用法
1.new Hook 新建钩子
- tapable 暴露出来的都是类方法,new 一个类方法获得我们需要的钩子。
- class 接受数组参数 options,非必传。类方法会根据传参,接受同样数量的参数。
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);
2. 使用 tap/tapAsync/tapPromise
绑定钩子
tapable 提供了同步 & 异步绑定钩子的方法,并且他们都有绑定事件和执行事件对应的方法。
– | Async* | Sync* |
---|---|---|
绑定 | tapAsync/tapPromise/tap |
tap |
执行 | callAsync/promise |
call |
3.call/callAsync
执行绑定事件
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);
// 绑定事件到 webapck 事件流
hook1.tap('hook1', (arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) //1,2,3
// 执行绑定的事件
hook1.call(1,2,3)
举个例子
- 定义一个
Car
方法,在内部hooks
上新建钩子。分别是同步钩子accelerate
(accelerate
接受一个参数)、break
、异步钩子calculateRoutes
- 使用钩子对应的绑定和执行方法
-
calculateRoutes
使用tapPromise
可以返回一个promise
对象。
// 引入 tapable
const {SyncHook, AsyncParallelHook} = require('tapable');
// 创建类
class Car {constructor() {
this.hooks = {accelerate: new SyncHook(["newSpeed"]),
break: new SyncHook(),
calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
};
}
}
const myCar = new Car();
// 绑定同步钩子
myCar.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin'));
// 绑定同步钩子 并传参
myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));
// 绑定一个异步 Promise 钩子
myCar.hooks.calculateRoutes.tapPromise("calculateRoutes tapPromise", (source, target, routesList, callback) => {
// return a promise
return new Promise((resolve,reject)=>{setTimeout(()=>{console.log(`tapPromise to ${source} ${target} ${routesList}`)
resolve();},1000)
})
});
// 执行同步钩子
myCar.hooks.break.call();
myCar.hooks.accelerate.call('hello');
console.time('cost');
// 执行异步钩子
myCar.hooks.calculateRoutes.promise('i', 'love', 'tapable').then(() => {console.timeEnd('cost');
}, err => {console.error(err);
console.timeEnd('cost');
})
运行结果
WarningLampPlugin
Accelerating to hello
tapPromise to i love tapable
cost: 1008.725ms
calculateRoutes
也可以使用 tapAsync
绑定钩子,注意:此时用 callback
结束异步回调。
myCar.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => {
// return a promise
setTimeout(() => {console.log(`tapAsync to ${source} ${target} ${routesList}`)
callback();}, 2000)
});
myCar.hooks.calculateRoutes.callAsync('i', 'like', 'tapable', err => {console.timeEnd('cost');
if(err) console.log(err)
})
运行结果
WarningLampPlugin
Accelerating to hello
tapAsync to i like tapable
cost: 2007.045ms
进阶一下~
到这里可能已经学会使用 tapable 了,但是它如何与 webapck/webpack
插件关联呢?
我们将刚才的代码稍作改动,拆成两个文件:Compiler.js
、Myplugin.js
Compiler.js
- 把
Class Car
类名改成webpack
的核心Compiler
- 接受
options
里传入的plugins
- 将
Compiler
作为参数传给plugin
- 执行
run
函数,在编译的每个阶段,都触发执行相对应的钩子函数。
const {
SyncHook,
AsyncParallelHook
} = require('tapable');
class Compiler {constructor(options) {
this.hooks = {accelerate: new SyncHook(["newSpeed"]),
break: new SyncHook(),
calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
};
let plugins = options.plugins;
if (plugins && plugins.length > 0) {plugins.forEach(plugin => plugin.apply(this));
}
}
run(){console.time('cost');
this.accelerate('hello')
this.break()
this.calculateRoutes('i', 'like', 'tapable')
}
accelerate(param){this.hooks.accelerate.call(param);
}
break(){this.hooks.break.call();
}
calculateRoutes(){const args = Array.from(arguments)
this.hooks.calculateRoutes.callAsync(...args, err => {console.timeEnd('cost');
if (err) console.log(err)
});
}
}
module.exports = Compiler
MyPlugin.js
- 引入
Compiler
- 定义一个自己的插件。
-
apply
方法接受compiler
参数。 - 给
compiler
上的钩子绑定方法。 - 仿照
webpack
规则,向plugins
属性传入new
实例。
webpack
插件是一个具有apply
方法的JavaScript
对象。apply
属性会被webpack compiler
调用,并且compiler
对象可在整个编译生命周期访问。
const Compiler = require('./Compiler')
class MyPlugin{constructor() { }
apply(conpiler){// 接受 compiler 参数
conpiler.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin'));
conpiler.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));
conpiler.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => {setTimeout(() => {console.log(`tapAsync to ${source}${target}${routesList}`)
callback();}, 2000)
});
}
}
// 这里类似于 webpack.config.js 的 plugins 配置
// 向 plugins 属性传入 new 实例
const myPlugin = new MyPlugin();
const options = {plugins: [myPlugin]
}
let compiler = new Compiler(options)
compiler.run()
运行结果
Accelerating to hello
WarningLampPlugin
tapAsync to iliketapable
cost: 2009.273ms
改造后运行正常,仿照 Compiler 和 webpack 插件的思路慢慢得理顺插件的逻辑成功。
更多其他 Tabable 方法
Plugin 基础
Webpack
通过 Plugin
机制让其更加灵活,以适应各种应用场景。在 Webpack
运行的生命周期中会广播出许多事件,Plugin
可以监听这些事件,在合适的时机通过 Webpack
提供的 API
改变输出结果。
一个最基础的 Plugin 的代码是这样的:
class BasicPlugin{
// 在构造函数中获取用户给该插件传入的配置
constructor(options){ }
// Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
apply(compiler){compiler.hooks.compilation.tap('BasicPlugin', compilation => {});
}
}
// 导出 Plugin
module.exports = BasicPlugin;
在使用这个 Plugin 时,相关配置代码如下:
const BasicPlugin = require('./BasicPlugin.js');
module.export = {
plugins:[new BasicPlugin(options),
]
}
Compiler 和 Compilation
在开发 Plugin
时最常用的两个对象就是 Compiler
和 Compilation
,它们是 Plugin
和 Webpack
之间的桥梁。 Compiler
和 Compilation
的含义如下:
- Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
- Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
Compiler 和 Compilation 的 区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。
常用 API
插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。
1、读取输出资源、代码块、模块及其依赖
有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。
在 emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。插件代码如下:
class MyPlugin {apply(compiler) {compiler.hooks.emit.tabAsync('MyPlugin', (compilation, callback) => {
// compilation.chunks 存放所有代码块,是一个数组
compilation.chunks.forEach(function (chunk) {
// chunk 代表一个代码块
// 代码块由多个模块组成,通过 chunk.forEachModule 能读取组成代码块的每个模块
chunk.forEachModule(function (module) {
// module 代表一个模块
// module.fileDependencies 存放当前模块的所有依赖的文件路径,是一个数组
module.fileDependencies.forEach(function (filepath) {});
});
// Webpack 会根据 Chunk 去生成输出的文件资源,每个 Chunk 都对应一个及其以上的输出文件
// 例如在 Chunk 中包含了 CSS 模块并且使用了 ExtractTextPlugin 时,// 该 Chunk 就会生成 .js 和 .css 两个文件
chunk.files.forEach(function (filename) {
// compilation.assets 存放当前所有即将输出的资源
// 调用一个输出资源的 source() 方法能获取到输出资源的内容
let source = compilation.assets[filename].source();});
});
// 这是一个异步事件,要记得调用 callback 通知 Webpack 本次事件监听处理结束。// 如果忘记了调用 callback,Webpack 将一直卡在这里而不会往后执行。callback();})
}
}
2、监听文件变化
Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时,就会触发一次新的 Compilation。
在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:
// 当依赖的文件发生变化时会触发 watch-run 事件
compiler.hooks.watchRun.tap('MyPlugin', (watching, callback) => {
// 获取发生变化的文件列表
const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
// changedFiles 格式为键值对,键为发生变化的文件路径。if (changedFiles[filePath] !== undefined) {// filePath 对应的文件发生了变化}
callback();});
默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:
compiler.hooks.afterCompile.tap('MyPlugin', (compilation, callback) => {
// 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
compilation.fileDependencies.push(filePath);
callback();});
3、修改输出资源
有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit
事件,因为发生 emit
事件时所有模块的转换和代码块对应的文件已经生成好,需要输出的资源即将输出,因此 emit
事件是修改 Webpack
输出资源的最后时机。
所有需要输出的资源会存放在 compilation.assets
中,compilation.assets
是一个键值对,键为需要输出的文件名称,值为文件对应的内容。
设置 compilation.assets
的代码如下:
// 设置名称为 fileName 的输出资源
compilation.assets[fileName] = {
// 返回文件内容
source: () => {
// fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
return fileContent;
},
// 返回文件大小
size: () => {return Buffer.byteLength(fileContent, 'utf8');
}
};
callback();
读取 compilation.assets
的代码如下:
// 读取名称为 fileName 的输出资源
const asset = compilation.assets[fileName];
// 获取输出资源的内容
asset.source();
// 获取输出资源的文件大小
asset.size();
callback();
实战!写一个插件
怎么写一个插件?参照 webpack 官方教程 Writing a Plugin。一个 webpack plugin 由一下几个步骤组成:
- 一个 JavaScript 类函数。
- 在函数原型 (prototype)中定义一个注入 compiler 对象的 apply 方法。
- apply 函数中通过 compiler 插入指定的事件钩子, 在钩子回调中拿到 compilation 对象
- 使用 compilation 操纵修改 webapack 内部实例数据。
- 异步插件,数据处理完后使用 callback 回调
下面我们举一个实际的例子,带你一步步去实现一个插件。
该插件的名称取名叫 EndWebpackPlugin
,作用是在 Webpack
即将退出时再附加一些额外的操作,例如在 Webpack
成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。同时该插件还能区分 Webpack
构建是否执行成功。使用该插件时方法如下:
module.exports = {
plugins:[
// 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数;new EndWebpackPlugin(() => {// Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作}, (err) => {
// Webpack 构建失败,err 是导致错误的原因
console.error(err);
})
]
}
要实现该插件,需要借助两个事件:
-
done
:在成功构建并且输出了文件后,Webpack 即将退出时发生; -
failed
:在构建出现异常导致构建失败,Webpack 即将退出时发生;
实现该插件非常简单,完整代码如下:
class EndWebpackPlugin {constructor(doneCallback, failCallback) {
// 存下在构造函数中传入的回调函数
this.doneCallback = doneCallback;
this.failCallback = failCallback;
}
apply(compiler) {compiler.hooks.done.tab('EndWebpackPlugin', (stats) => {
// 在 done 事件中回调 doneCallback
this.doneCallback(stats);
});
compiler.hooks.failed.tab('EndWebpackPlugin', (err) => {
// 在 failed 事件中回调 failCallback
this.failCallback(err);
});
}
}
// 导出插件
module.exports = EndWebpackPlugin;
从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。在 工作原理概括 中详细介绍过 Webpack 在运行过程中广播出常用事件,你可以从中找到你需要的事件。
参考
tapable
compiler-hooks
Compilation Hooks
writing-a-plugin
深入浅出 Webpack
干货!撸一个 webpack 插件