webpack-插件机制杂记

前言

webpack本身并不难,他所完成的各种复杂炫酷的功能都依赖于他的插件机制。或许我们在日常的开发需求中并不需要自己动手写一个插件,然而,了解其中的机制也是一种学习的方向,当插件出现问题时,我们也能够自己来定位。

Tapable

Webpack的插件机制依赖于一个核心的库, Tapable
在深入webpack的插件机制之前,需要对该核心库有一定的了解。

Tapable是什么

tapable 是一个类似于nodejs 的EventEmitter 的库, 主要是控制钩子函数的发布与订阅。当然,tapable提供的hook机制比较全面,分为同步和异步两个大类(异步中又区分异步并行和异步串行),而根据事件执行的终止条件的不同,由衍生出 Bail/Waterfall/Loop 类型。

Tapable的使用 (该小段内容引用文章

基本使用

  1. const {
  2. SyncHook
  3. } = require('tapable')
  4. // 创建一个同步 Hook,指定参数
  5. const hook = new SyncHook(['arg1', 'arg2'])
  6. // 注册
  7. hook.tap('a', function (arg1, arg2) {
  8. console.log('a')
  9. })
  10. hook.tap('b', function (arg1, arg2) {
  11. console.log('b')
  12. })
  13. hook.call(1, 2)
  14. 复制代码

钩子类型

webpack-插件机制 - 图1

webpack-插件机制 - 图2

BasicHook:执行每一个,不关心函数的返回值,有SyncHook、AsyncParallelHook、AsyncSeriesHook。

BailHook:顺序执行 Hook,遇到第一个结果result!==undefined则返回,不再继续执行。有:SyncBailHook、AsyncSeriseBailHook, AsyncParallelBailHook。

什么样的场景下会使用到 BailHook 呢?设想如下一个例子:假设我们有一个模块 M,如果它满足 A 或者 B 或者 C 三者任何一个条件,就将其打包为一个单独的。这里的 A、B、C 不存在先后顺序,那么就可以使用 AsyncParallelBailHook 来解决:

  1. x.hooks.拆分模块的Hook.tap('A', () => {
  2. if (A 判断条件满足) {
  3. return true
  4. }
  5. })
  6. x.hooks.拆分模块的Hook.tap('B', () => {
  7. if (B 判断条件满足) {
  8. return true
  9. }
  10. })
  11. x.hooks.拆分模块的Hook.tap('C', () => {
  12. if (C 判断条件满足) {
  13. return true
  14. }
  15. })
  16. 复制代码

如果 A 中返回为 true,那么就无须再去判断 B 和 C。 但是当 A、B、C 的校验,需要严格遵循先后顺序时,就需要使用有顺序的 SyncBailHook(A、B、C 是同步函数时使用) 或者 AsyncSeriseBailHook(A、B、C 是异步函数时使用)。

WaterfallHook:类似于 reduce,如果前一个 Hook 函数的结果 result !== undefined,则 result 会作为后一个 Hook 函数的第一个参数。既然是顺序执行,那么就只有 Sync 和 AsyncSeries 类中提供这个Hook:SyncWaterfallHook,AsyncSeriesWaterfallHook 当一个数据,需要经过 A,B,C 三个阶段的处理得到最终结果,并且 A 中如果满足条件 a 就处理,否则不处理,B 和 C 同样,那么可以使用如下

  1. x.hooks.tap('A', (data) => {
  2. if (满足 A 需要处理的条件) {
  3. // 处理数据 data
  4. return data
  5. } else {
  6. return
  7. }
  8. })
  9. x.hooks.tap('B', (data) => {
  10. if (满足B需要处理的条件) {
  11. // 处理数据 data
  12. return data
  13. } else {
  14. return
  15. }
  16. })
  17. x.hooks.tap('C', (data) => {
  18. if (满足 C 需要处理的条件) {
  19. // 处理数据 data
  20. return data
  21. } else {
  22. return
  23. }
  24. })
  25. 复制代码

LoopHook:不停的循环执行 Hook,直到所有函数结果 result === undefined。同样的,由于对串行性有依赖,所以只有 SyncLoopHook 和 AsyncSeriseLoopHook (PS:暂时没看到具体使用 Case)

Tapable的源码分析

Tapable 基本逻辑是,先通过类实例的 tap 方法注册对应 Hook 的处理函数, 这里直接分析sync同步钩子的主要流程,其他的异步钩子和拦截器等就不赘述了。

  1. const hook = new SyncHook(['arg1', 'arg2'])
  2. 复制代码

从该句代码, 作为源码分析的入口,

  1. class SyncHook extends Hook {
  2. // 错误处理,防止调用者调用异步钩子
  3. tapAsync() {
  4. throw new Error("tapAsync is not supported on a SyncHook");
  5. }
  6. // 错误处理,防止调用者调用promise钩子
  7. tapPromise() {
  8. throw new Error("tapPromise is not supported on a SyncHook");
  9. }
  10. // 核心实现
  11. compile(options) {
  12. factory.setup(this, options);
  13. return factory.create(options);
  14. }
  15. }
  16. 复制代码

从类SyncHook看到, 他是继承于一个基类Hook, 他的核心实现compile等会再讲, 我们先看看基类Hook

  1. // 变量的初始化
  2. constructor(args) {
  3. if (!Array.isArray(args)) args = [];
  4. this._args = args;
  5. this.taps = [];
  6. this.interceptors = [];
  7. this.call = this._call;
  8. this.promise = this._promise;
  9. this.callAsync = this._callAsync;
  10. this._x = undefined;
  11. }
  12. 复制代码

初始化完成后, 通常会注册一个事件, 如:

  1. // 注册
  2. hook.tap('a', function (arg1, arg2) {
  3. console.log('a')
  4. })
  5. hook.tap('b', function (arg1, arg2) {
  6. console.log('b')
  7. })
  8. 复制代码

很明显, 这两个语句都会调用基类中的tap方法:

  1. tap(options, fn) {
  2. // 参数处理
  3. if (typeof options === "string") options = { name: options };
  4. if (typeof options !== "object" || options === null)
  5. throw new Error(
  6. "Invalid arguments to tap(options: Object, fn: function)"
  7. );
  8. options = Object.assign({ type: "sync", fn: fn }, options);
  9. if (typeof options.name !== "string" || options.name === "")
  10. throw new Error("Missing name for tap");
  11. // 执行拦截器的register函数, 比较简单不分析
  12. options = this._runRegisterInterceptors(options);
  13. // 处理注册事件
  14. this._insert(options);
  15. }
  16. 复制代码

从上面的源码分析, 可以看到_insert方法是注册阶段的关键函数, 直接进入该方法内部

  1. _insert(item) {
  2. // 重置所有的 调用 方法
  3. this._resetCompilation();
  4. // 将注册事件排序后放进taps数组
  5. let before;
  6. if (typeof item.before === "string") before = new Set([item.before]);
  7. else if (Array.isArray(item.before)) {
  8. before = new Set(item.before);
  9. }
  10. let stage = 0;
  11. if (typeof item.stage === "number") stage = item.stage;
  12. let i = this.taps.length;
  13. while (i > 0) {
  14. i--;
  15. const x = this.taps[i];
  16. this.taps[i + 1] = x;
  17. const xStage = x.stage || 0;
  18. if (before) {
  19. if (before.has(x.name)) {
  20. before.delete(x.name);
  21. continue;
  22. }
  23. if (before.size > 0) {
  24. continue;
  25. }
  26. }
  27. if (xStage > stage) {
  28. continue;
  29. }
  30. i++;
  31. break;
  32. }
  33. this.taps[i] = item;
  34. }
  35. }
  36. 复制代码

_insert主要是排序tap并放入到taps数组里面, 排序的算法并不是特别复杂,这里就不赘述了, 到了这里, 注册阶段就已经结束了, 继续看触发阶段。

  1. hook.call(1, 2) // 触发函数
  2. 复制代码

在基类hook中, 有一个初始化过程,

  1. this.call = this._call;
  2. Object.defineProperties(Hook.prototype, {
  3. _call: {
  4. value: createCompileDelegate("call", "sync"),
  5. configurable: true,
  6. writable: true
  7. },
  8. _promise: {
  9. value: createCompileDelegate("promise", "promise"),
  10. configurable: true,
  11. writable: true
  12. },
  13. _callAsync: {
  14. value: createCompileDelegate("callAsync", "async"),
  15. configurable: true,
  16. writable: true
  17. }
  18. });
  19. 复制代码

我们可以看出_call是由createCompileDelegate生成的, 往下看

  1. function createCompileDelegate(name, type) {
  2. return function lazyCompileHook(...args) {
  3. this[name] = this._createCall(type);
  4. return this[name](...args);
  5. };
  6. }
  7. 复制代码

createCompileDelegate返回一个名为lazyCompileHook的函数,顾名思义,即懒编译, 直到调用call的时候, 才会编译出正在的call函数。

createCompileDelegate也是调用的_createCall, 而_createCall调用了Compier函数

  1. _createCall(type) {
  2. return this.compile({
  3. taps: this.taps,
  4. interceptors: this.interceptors,
  5. args: this._args,
  6. type: type
  7. });
  8. }
  9. compile(options) {
  10. throw new Error("Abstract: should be overriden");
  11. }
  12. 复制代码

可以看到compiler必须由子类重写, 返回到syncHook的compile函数, 即我们一开始说的核心方法

  1. class SyncHookCodeFactory extends HookCodeFactory {
  2. content({ onError, onResult, onDone, rethrowIfPossible }) {
  3. return this.callTapsSeries({
  4. onError: (i, err) => onError(err),
  5. onDone,
  6. rethrowIfPossible
  7. });
  8. }
  9. }
  10. const factory = new SyncHookCodeFactory();
  11. class SyncHook extends Hook {
  12. ...
  13. compile(options) {
  14. factory.setup(this, options);
  15. return factory.create(options);
  16. }
  17. }
  18. 复制代码

关键就在于SyncHookCodeFactory和工厂类HookCodeFactory, 先看setup函数,

  1. setup(instance, options) {
  2. // 这里的instance 是syncHook 实例, 其实就是把tap进来的钩子数组给到钩子的_x属性里.
  3. instance._x = options.taps.map(t => t.fn);
  4. }
  5. 复制代码

然后是最关键的create函数, 可以看到最后返回的fn,其实是一个new Function动态生成的函数

  1. create(options) {
  2. // 初始化参数,保存options到本对象this.options,保存new Hook(["options"]) 传入的参数到 this._args
  3. this.init(options);
  4. let fn;
  5. // 动态构建钩子,这里是抽象层,分同步, 异步, promise
  6. switch (this.options.type) {
  7. // 先看同步
  8. case "sync":
  9. // 动态返回一个钩子函数
  10. fn = new Function(
  11. // 生成函数的参数,no before no after 返回参数字符串 xxx,xxx 在
  12. // 注意这里this.args返回的是一个字符串,
  13. // 在这个例子中是options
  14. this.args(),
  15. '"use strict";\n' +
  16. this.header() +
  17. this.content({
  18. onError: err => `throw ${err};\n`,
  19. onResult: result => `return ${result};\n`,
  20. onDone: () => "",
  21. rethrowIfPossible: true
  22. })
  23. );
  24. break;
  25. case "async":
  26. fn = new Function(
  27. this.args({
  28. after: "_callback"
  29. }),
  30. '"use strict";\n' +
  31. this.header() +
  32. // 这个 content 调用的是子类类的 content 函数,
  33. // 参数由子类传,实际返回的是 this.callTapsSeries() 返回的类容
  34. this.content({
  35. onError: err => `_callback(${err});\n`,
  36. onResult: result => `_callback(null, ${result});\n`,
  37. onDone: () => "_callback();\n"
  38. })
  39. );
  40. break;
  41. case "promise":
  42. let code = "";
  43. code += '"use strict";\n';
  44. code += "return new Promise((_resolve, _reject) => {\n";
  45. code += "var _sync = true;\n";
  46. code += this.header();
  47. code += this.content({
  48. onError: err => {
  49. let code = "";
  50. code += "if(_sync)\n";
  51. code += `_resolve(Promise.resolve().then(() => { throw ${err}; }));\n`;
  52. code += "else\n";
  53. code += `_reject(${err});\n`;
  54. return code;
  55. },
  56. onResult: result => `_resolve(${result});\n`,
  57. onDone: () => "_resolve();\n"
  58. });
  59. code += "_sync = false;\n";
  60. code += "});\n";
  61. fn = new Function(this.args(), code);
  62. break;
  63. }
  64. // 把刚才init赋的值初始化为undefined
  65. // this.options = undefined;
  66. // this._args = undefined;
  67. this.deinit();
  68. return fn;
  69. }
  70. 复制代码

最后生成的代码大致如下, 参考文章

  1. "use strict";
  2. function (options) {
  3. var _context;
  4. var _x = this._x;
  5. var _taps = this.taps;
  6. var _interterceptors = this.interceptors;
  7. // 我们只有一个拦截器所以下面的只会生成一个
  8. _interceptors[0].call(options);
  9. var _tap0 = _taps[0];
  10. _interceptors[0].tap(_tap0);
  11. var _fn0 = _x[0];
  12. _fn0(options);
  13. var _tap1 = _taps[1];
  14. _interceptors[1].tap(_tap1);
  15. var _fn1 = _x[1];
  16. _fn1(options);
  17. var _tap2 = _taps[2];
  18. _interceptors[2].tap(_tap2);
  19. var _fn2 = _x[2];
  20. _fn2(options);
  21. var _tap3 = _taps[3];
  22. _interceptors[3].tap(_tap3);
  23. var _fn3 = _x[3];
  24. _fn3(options);
  25. }
  26. 复制代码

ok, 以上就是Tapabled的机制, 然而本篇的主要对象其实是基于tapable实现的compile和compilation对象。不过由于他们都是基于tapable,所以介绍的篇幅相对短一点。

compile

compile是什么

compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用 compiler 来访问 webpack 的主环境。

也就是说, compile是webpack的整体环境。

compile的内部实现

  1. class Compiler extends Tapable {
  2. constructor(context) {
  3. super();
  4. this.hooks = {
  5. /** @type {SyncBailHook<Compilation>} */
  6. shouldEmit: new SyncBailHook(["compilation"]),
  7. /** @type {AsyncSeriesHook<Stats>} */
  8. done: new AsyncSeriesHook(["stats"]),
  9. /** @type {AsyncSeriesHook<>} */
  10. additionalPass: new AsyncSeriesHook([]),
  11. /** @type {AsyncSeriesHook<Compiler>} */
  12. ......
  13. ......
  14. some code
  15. };
  16. ......
  17. ......
  18. some code
  19. }
  20. 复制代码

可以看到, Compier继承了Tapable, 并且在实例上绑定了一个hook对象, 使得Compier的实例compier可以像这样使用

  1. compiler.hooks.compile.tapAsync(
  2. 'afterCompile',
  3. (compilation, callback) => {
  4. console.log('This is an example plugin!');
  5. console.log('Here’s the `compilation` object which represents a single build of assets:', compilation);
  6. // 使用 webpack 提供的 plugin API 操作构建结果
  7. compilation.addModule(/* ... */);
  8. callback();
  9. }
  10. );
  11. 复制代码

compilation

什么是compilation

compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

compilation的实现

  1. class Compilation extends Tapable {
  2. /**
  3. * Creates an instance of Compilation.
  4. * @param {Compiler} compiler the compiler which created the compilation
  5. */
  6. constructor(compiler) {
  7. super();
  8. this.hooks = {
  9. /** @type {SyncHook<Module>} */
  10. buildModule: new SyncHook(["module"]),
  11. /** @type {SyncHook<Module>} */
  12. rebuildModule: new SyncHook(["module"]),
  13. /** @type {SyncHook<Module, Error>} */
  14. failedModule: new SyncHook(["module", "error"]),
  15. /** @type {SyncHook<Module>} */
  16. succeedModule: new SyncHook(["module"]),
  17. /** @type {SyncHook<Dependency, string>} */
  18. addEntry: new SyncHook(["entry", "name"]),
  19. /** @type {SyncHook<Dependency, string, Error>} */
  20. }
  21. }
  22. }
  23. 复制代码

具体参考上面提到的compiler实现。

编写一个插件

了解到tapable\compiler\compilation之后, 再来看插件的实现就不再一头雾水了
以下代码源自官方文档

  1. class MyExampleWebpackPlugin {
  2. // 定义 `apply` 方法
  3. apply(compiler) {
  4. // 指定要追加的事件钩子函数
  5. compiler.hooks.compile.tapAsync(
  6. 'afterCompile',
  7. (compilation, callback) => {
  8. console.log('This is an example plugin!');
  9. console.log('Here’s the `compilation` object which represents a single build of assets:', compilation);
  10. // 使用 webpack 提供的 plugin API 操作构建结果
  11. compilation.addModule(/* ... */);
  12. callback();
  13. }
  14. );
  15. }
  16. }
  17. 复制代码

可以看到其实就是在apply中传入一个Compiler实例, 然后基于该实例注册事件, compilation同理, 最后webpack会在各流程执行call方法。

compiler和compilation一些比较重要的事件钩子

compier

事件钩子 触发时机 参数 类型
entry-option 初始化 option - SyncBailHook
run 开始编译 compiler AsyncSeriesHook
compile 真正开始的编译,在创建 compilation 对象之前 compilation SyncHook
compilation 生成好了 compilation 对象,可以操作这个对象啦 compilation SyncHook
make 从 entry 开始递归分析依赖,准备对每个模块进行 build compilation AsyncParallelHook
after-compile 编译 build 过程结束 compilation AsyncSeriesHook
emit 在将内存中 assets 内容写到磁盘文件夹之前 compilation AsyncSeriesHook
after-emit 在将内存中 assets 内容写到磁盘文件夹之后 compilation AsyncSeriesHook
done 完成所有的编译过程 stats AsyncSeriesHook
failed 编译失败的时候 error SyncHook

compilation

事件钩子 触发时机 参数 类型
normal-module-loader 普通模块 loader,真正(一个接一个地)加载模块图(graph)中所有模块的函数。 loaderContext module SyncHook
seal 编译(compilation)停止接收新模块时触发。 - SyncHook
optimize 优化阶段开始时触发。 - SyncHook
optimize-modules 模块的优化 modules SyncBailHook
optimize-chunks 优化 chunk chunks SyncBailHook
additional-assets 为编译(compilation)创建附加资源(asset)。 - AsyncSeriesHook
optimize-chunk-assets 优化所有 chunk 资源(asset)。 chunks AsyncSeriesHook
optimize-assets 优化存储在 compilation.assets 中的所有资源(asset) assets AsyncSeriesHook

总结

插件机制并不复杂,webpack也不复杂,复杂的是插件本身..
另外, 本应该先写流程的, 流程只能后面补上了。

引用

不满足于只会使用系列: tapable
webpack系列之二Tapable
编写一个插件
Compiler
Compilation
compiler和comnpilation钩子
看清楚真正的 Webpack 插件