遇见Tapable-Webpack的插件机制

583 阅读4分钟

遇见Tapable

Webpack的成功之处不仅在于它强大的打包构建能力,也在于它灵活的插件机制。

大大的疑问?🤔️

  1. webpack插件配置是有序的吗?
  2. 写插件的时候,都要写成类的形式吗?
  3. 插件原型为什么一定要有apply方法?

Webpack内部拥有一套自己的事件流机制,它的工作流程是将各个插件串联起来,而实现这个串联的核心就是Tapable。

1. Webpack插件机制

1.1 Webpack插件被插入的时机

当定义了Webpack配置文件时,webpack开始工作Compiler对象会被实例话,而且是全局唯一的,Compiler包含了当前运行的webpack的配置,而插件就是在实例化Compiler对象时被添加到webpack的运行流程中的,看下源码(定位:lib/webpack.js:61):

const createCompiler = rawOptions => {
    const options = getNormalizedWebpackOptions(rawOptions);
    applyWebpackOptionsBaseDefaults(options);
    const compiler = new Compiler(options.context);
    compiler.options = options;
    new NodeEnvironmentPlugin({
        infrastructureLogging: options.infrastructureLogging
    }).apply(compiler);
    // 判断options.plugins插件配置是否存在
    if (Array.isArray(options.plugins)) {
        // 遍历插件
        for (const plugin of options.plugins) {
            // 这里有两种编写插件的方式,一种function,一种是对象实例
            if (typeof plugin === "function") {
                plugin.call(compiler, compiler);
            } else {
                // 如果是对象实例,则需要在构造函数中定义一个apply方法,作为webpack调用该插件的入口。然后将compiler对象作为参数传递。
                plugin.apply(compiler);
            }
        }
    }
    applyWebpackOptionsDefaults(options);
    compiler.hooks.environment.call();
    compiler.hooks.afterEnvironment.call();
    new WebpackOptionsApply().process(options, compiler);
    compiler.hooks.initialize.call();
    return compiler;
};

从遍历options.plugins这里开始看,不难看出调用插件通过两种方式:

  • 插件可以是一个函数,函数的作用域是当前的compiler,同时compiler也会作为参数传递。
  • 对象实例,并且原型链上有apply方法,调用apply,并将当前compiler传入。 所以,这也就解释了为什么插件需要使用new,并且插件中都要定义apply方法。

1.2 在Compiler中做了什么?

从上面可以看出,plugin在注入时传入了当前被实例化出来的compiler实例,所以看一下compiler中做了什么?(源码定位:lib/Compiler.js:117)

class Compiler {
  constructor(context) {
    // Object.freeze 冻结对象,不能被修改、删除、添加,包括原型也不可修改!
    this.hooks = Object.freeze({
      // ...
      
      /** @type {SyncBailHook<[Compilation], boolean>} */
      shouldEmit: new SyncBailHook(["compilation"]),
      /** @type {SyncHook<[CompilationParams]>} */
      compile: new SyncHook(["params"]),
      /** @type {AsyncParallelHook<[Compilation]>} */
      make: new AsyncParallelHook(["compilation"]),
      /** @type {AsyncSeriesHook<[Compilation]>} */
      emit: new AsyncSeriesHook(["compilation"])
      
      // ...
    })
  }
  emitAssets(compilation, callback) {
    let outputPath;
    // 如果有插件中监听了emit钩子,这里将会被触发。
    this.hooks.emit.callAsync(compilation, err => {
      if (err) return callback(err);
      outputPath = compilation.getPath(this.outputPath, {});
      mkdirp(this.outputFileSystem, outputPath, emitFiles);
    });
  }
}

通过上述源码可以看出,每一个compiler实例都会包含很多个钩子,webpack正式依赖这些hook完成了代码的构建,并且在编写plugin时,可以利用不同的钩子完成很多特殊的定制化的需求。

2. Tapable

Tapable的核心思路类似于node.js的EventEmitter,最基本的发布/订阅模式

const events = require('events');
const emitter = new events.EventEmitter();

// 注册事件监听和对应的回调函数
emitter.on('demo', params => {
  console.log('输入结果', params);
})
// 触发事件,传入参数
emitter.emit('demo', '遇见Tapable');

3. Tapable的hook介绍

tapable有如下10个Hook:

如何使用

先从最简单的开始,钩子都是大同小异,懂一个就其余的就很好懂了。

3.1 各个Hook的用法
  • Basic: 不关心回调函数返回值。SyncHook, AsyncParallelHook, AsyncSeriesHook
  • Bail: 只要其中一个监听函数的返回值不为undefined,则终止执行。SyncBailHook, AsyncParallelBailHook, AsyncSeriesBailHook
  • Waterfall: 前一个监听函数的返回值不为undefined,则作为下一个监听函数的第一个参数。SyncWaterfallHook, AsyncSeriesWaterfallHook
  • Loop: 如果有一个监听函数的返回值不为undefined,则终止向下执行,从头开始执行,直到所有监听函数的返回值均为undefined。SyncLoopHook, AsyncSeriesLoopHook
3.2 SyncHook
const { SyncHook } = require('tapable');

let hook = new SyncHook(['name']);
hook.tap('demo', function(params) {
  console.log('demo', params);
});
hook.tap('demo2', function(params) {
  console.log('demo2', params);
  return true;
});
hook.tap('demo3', function(params) {
  console.log('demo3', params);
});

hook.call('hello SyncHook');
输出/*
    * demo hello SyncHook
    * demo2 hello SyncHook
    * demo3 hello SyncHook
    */

根据上述使用实例,简单写下其实现原理:

  class SyncHook {
    constructor(args = []) {
      this._args = args;
      this.tasks = [];
    }
    tap(name, task) {
      this.tasks.push(task);
    }
    call(...args) {
      const params = args.slice(0, this._args.length);
      this.tasks.forEach(task => task(...params))
    }
  }
3.3 SyncBailHook
const { SyncBailHook } = require('tapable');
let hook = new SyncBailHook(['name']);

hook.tap('demo', function(params) {
  console.log('demo', params);
})
hook.tap('demo2', function(params) {
  console.log('demo2', params);
  return true;
})
hook.tap('demo3', function(params) {
  console.log('demo3', params);
})

hook.call('hello SyncBailHook');

输出/*
    * demo hello SyncBailHook
    * demo2 hello SyncBailHook
    */

根据上述输入结果显示,SyncBailHook钩子遇到回调函数中返回结果不为undefined时,则跳过执行下面所有逻辑。实现原理如下:

class SyncBailHook {
  constructor(args) {
    this._args = args;
    this.tasks = [];
  }
  tap(name, task) {
    this.tasks.push(task);
  }
  call() {
    const args = Array.from(arguments).slice(0, this._args.length);
    for (let i = 0; i < this.tasks.length; i++) {
      const result = this.tasks[i](...args);
      if (result !== undefined) break;
    }
  }
}

3.4 总结

通过上述两个钩子,可以发现tapable提供了各式各样的Hook来管理事件如何执行。tapable的核心功能就是控制一系列注册事件之间的执行流,比如注册了三个事件,不论是异步并发(串行)执行的,还是同步依次执行,还是通过回调函数返回值控制执行流,都可以通过tapable提供的Hook一一实现。

4.应用实例

4.1 plugin举例

举一个最简单的例子,我们经常用到的copyWebpackPlugin,看下源码中是如何写的(源码定位:src/index.js:607)

class CopyPlugin {
  // ...
  apply(compiler) {
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      // ...
    })
  }
  // ...
}

CopyPlugin中有一个apply方法,然后监听了compiler上thisCompilation这个钩子,从而当webpack中执行这个钩子的call方法时,就会触发此处的回调函数。

5. 强行吐槽

  • 监听器一旦添加无法移除,官方团队认为tapable是为静态插件服务的,移除监听器与设计理念不符,所以这点很大程度限制了tapable的拓展。
  • 同/异步钩子可以混用,着实有点混乱。

那么到这里,就分享结束了,你~学废了吗?点个赞再走呗~