浅析 Webpack 背后的 Tapable

821 阅读5分钟

主要内容

  1. tapable 使用流程

    • 实例化 Hook
    • 通过 tap/tapAsync/tapPromise 设置 hookInstance.taps
    • 调用 hookInstance.call(callAsync, callPromise)触发事件
  2. 各模块职责

    • Hook -> 维护 taps
    • HookCodeFactory -> 生成可执行代码 new Function
    • SpecHookCodeFactory(在) -> 重写 HookCodeFactory.content 实现不同的 Hook 逻辑(执行代码)
  3. 模块关系 image.png

基本使用

import { SyncHook } from 'tapable';
const hook = new SyncHook(['arg1', 'arg2']);

hook.tap('process1', (...args) => {
    console.log('process 1', args)
})
hook.tap({
    name: 'finalProcess',
    stage: 4,
}, (...args) => {
    console.log('process 4', args)
})
hook.tap({
    name: 'process3',
    stage: 3,
}, (...args) => {
    console.log('process 3', args)
})
hook.tap({
    name: 'process2',
    before: ['process3', 'finalProcess']
}, (...args) => {
    console.log('process 2', args)
})

hook.call('1111', '2222')

// process 1 [ '1111', '2222' ]
// process 2 [ '1111', '2222' ]
// process 3 [ '1111', '2222' ]
// process 4 [ '1111', '2222' ]

实际执行代码: image.png

源码分析

从前面的结果我们可以看出,尽管 tap 触发的顺序不同,但是在最终 call 方法执行的时候按照我们的预期顺序正确的执行了添加的 tap 处理函数。

接下来就深入源码看下是如何实现的。

核心模块

├── AsyncParallelBailHook.js
├── AsyncParallelHook.js
├── AsyncSeriesBailHook.js
├── AsyncSeriesHook.js
├── AsyncSeriesLoopHook.js
├── AsyncSeriesWaterfallHook.js
├── Hook.js
├── HookCodeFactory.js
├── HookMap.js
├── MultiHook.js
├── SyncBailHook.js
├── SyncHook.js
├── SyncLoopHook.js
├── SyncWaterfallHook.js
├── index.js

以上为 tapable 仓库目录结构。 基于我们的例子从 SyncHook 为入口进行源码分析:

SyncHook

// SyncHook.js

/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Tobias Koppers @sokra
*/
"use strict";

const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");

class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}

const factory = new SyncHookCodeFactory();

const TAP_ASYNC = () => {
    throw new Error("tapAsync is not supported on a SyncHook");
};

const TAP_PROMISE = () => {
    throw new Error("tapPromise is not supported on a SyncHook");
};

const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};

// 1. SyncHook 类定义
function SyncHook(args = [], name = undefined) {
    // 生成 Hook 实例
    const hook = new Hook(args, name);
    hook.constructor = SyncHook;
	
    // 重写async, promise 方法
    hook.tapAsync = TAP_ASYNC;
    hook.tapPromise = TAP_PROMISE;

    // 重写 compile 方法
    hook.compile = COMPILE;
    // 返回 hook
    return hook;
}

SyncHook.prototype = null;

module.exports = SyncHook;

整个 SyncHook.js 代码量不大,主要依赖 Hook 类和 HookCodeFactory 类。

Hook 类

我们接着分析 Hook 类:

// Hook.js

const CALL_DELEGATE = function(...args) {
    this.call = this._createCall("sync");
    return this.call(...args);
};

class Hook {
    constructor(args = [], name = undefined) {
        this._args = args; // 构造函数接收的参数(实际也是 处理函数的形参,后续会解释)
        this.name = name; // hook 名称
        this.taps = []; // 维护所有的 taps 队列

        this._call = CALL_DELEGATE;
        this.call = CALL_DELEGATE;

        this._x = undefined;

        this.compile = this.compile;
        this.tap = this.tap;
    }
	

    // abstract 方法,需要每个 Hook 自行实现
    compile(options) {
        throw new Error("Abstract: should be overridden");
    }

    _createCall(type) {
        return this.compile({
            taps: this.taps,
            interceptors: this.interceptors,
            args: this._args,
            type: type
        });
    }

    _tap(type, options, fn) {
        if (typeof options === "string") {
            options = {
                name: options.trim()
            };
        } else if (typeof options !== "object" || options === null) {
            throw new Error("Invalid tap options");
        }
        if (typeof options.name !== "string" || options.name === "") {
            throw new Error("Missing name for tap");
        }
        if (typeof options.context !== "undefined") {
            deprecateContext();
        }
        options = Object.assign({ type, fn }, options);
        this._insert(options);
    }

    tap(options, fn) {
        this._tap("sync", options, fn);
    }

    _resetCompilation() {
        this.call = this._call;
        this.callAsync = this._callAsync;
        this.promise = this._promise;
    }

    _insert(item) {
        this._resetCompilation();
        let before;
        if (typeof item.before === "string") {
            before = new Set([item.before]);
        } else if (Array.isArray(item.before)) {
            before = new Set(item.before);
        }
        let stage = 0;
        if (typeof item.stage === "number") {
            stage = item.stage;
        }
        let i = this.taps.length;
        while (i > 0) {
            i--;
            const x = this.taps[i];
            // 从有到左处理
            // 算法比较巧妙,从最后开始,直接将前一个元素设置为后一个元素,如果不满足条件则直接 i++,设置 i++ 位置为目标值,如果满足则继续前进 
            this.taps[i + 1] = x;
            const xStage = x.stage || 0;
            if (before) {
                if (before.has(x.name)) {
                    before.delete(x.name);
                    continue;
                }
                if (before.size > 0) {
                    continue;
                }
            }
            if (xStage > stage) {
                continue;
            }
            i++;
            break;
        }
        this.taps[i] = item;
    }
}

Object.setPrototypeOf(Hook.prototype, null);

module.exports = Hook;

为了简单已删除 async 和 promise 以及 interceptor 相关逻辑,读者感兴趣可自行查看 Hook.js 源码。

通过 Hook.js 源码可以得出以下结论:

  1. Hook.js 实际为一个抽象类(compile)方法需要每个子类自行实现 compile 方法 也是每个 Hooks 表现不一样的具体原因
  2. Hook 核心数据为 taps (_insert、tap 均为其服务)
  3. Hook 执行 call 的时候会先生成 call 函数(_createCall)再调用
    1. 初次调用 call 方法会生成实际的 call 方法,并替换
    2. 每当有新的 tap 发生时,call 会被重置(下次再触发时又重写生成)
  4. tap(options, fn) 可通过 stagebefore 两种方式设置 taps 队列顺序

compile & factory

基于上文第一条结论:compile 方法也是每个 Hooks 表现不一样的具体原因 我们以 SyncHook 为例看下其具体实现:

const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};
  1. factory.setup(this, options)
    此时 this 指向为 hook 实例

    setup(instance, options) {
        instance._x = options.taps.map(t => t.fn);
    }
    

    只做一件事情:设置 instance._x 值为 taps 队列的具体处理函数。

  2. return factory.create(options)

    create(options) {
      this.init(options);
      let fn;
      switch (this.options.type) {
        case "sync":
          fn = new Function(
            // 设置 before _args, after 参数 args 就是 new SyncHook 时传递的参数
            this.args(), 
            '"use strict";\n' +
              this.header() +
              this.contentWithInterceptors({
                onError: err => `throw ${err};\n`,
                onResult: result => `return ${result};\n`,
                resultReturns: true,
                onDone: () => "",
                rethrowIfPossible: true
              })
          );
          break;
      // ...
      }
      return fn;
    

主要做两件事情:

  1. 处理 args(不同的 hook 在实际处理函数中除了 new Hooks() 传递的参数还会有其他前后参数)
  2. 通过 new Function 生成实际调用函数
    factory.contentWithInterceptors 会调用 factory.content 方法

    实际执行的函数由 factory.content 生成

总体流程

  • 实例化 Hook

  • 通过 tap/tapAsync/tapPromise 设置 hookInstance.taps

  • 调用 hookInstance.call(callAsync, callPromise)触发事件

    call
        -> CALL_DELEGATE
            -> _createCall
                -> compile
                    -> factory.create
                    -> factory.content
    

各模块职责

  • Hook -> 维护 taps
  • HookCodeFactory -> 生成可执行代码
  • SpecHookCodeFactory -> 重写 HookCodeFactory.content 实现不同的 Hook 逻辑

一些实现细节

  1. tap(options, fn)

    • options 类型

      // 非官方,个人总结
      type TapOptions = 
        | string
        | { name: string; before?: string | string[], stage?: number }
      

      通过 beforestage 实现顺序的调整

    • options name 是必须的(顺序调整),但是 name 也是可以重复的,同名 tap 彼此不影响

      最终调用时 taps 为数组

  2. 每次 tap 后,下次调用 call 都会通过 _resetCompilation 重置 call/callAsync/promsie 方法

    保证 taps 调用顺序正确,注意重置和 _createCall 时对 call 方法的处理

  3. new Hook 时第一个参数为 string[] 类型表示 tap/tapAsync/tapPromsie 处理方法的形参

    • 具体值无所谓,只要正确记录数量即可
    • 处理方法基于 Hook 不同,可能在 new Hook 的基础上前后添加参数。

在 webpack 中的使用

  • 插件 每个插件都有一个 apply(compiler) 方法,在方法内通过 compiler.hooks.xxx.tap('', (compilation) => { //... }) 实现在特定流程上的插件处理逻辑

  • compiler 和 compilation 中都有大量的 tapable 的使用

个人业务中如果有需要动态绑定生成函数执行关系的场景,或者特定场景添加 Hook 解耦逻辑的场景均可使用。