解密 Tapable:Webpack插件机制

851

本文正在参加「金石计划 . 瓜分6万现金大奖」

webpack 的生态中围绕着 loaderplugin 两种机制展开。如果你想学习webpack plugins那么 tapable 是你必须掌握的知识。

本文内容主要来源于风佬的Tapable,看这一篇就够了,这里这是为了加强自己的记忆和理解。

tapable 基本使用

tapable 提供了一系列事件的发布订阅 API ,通过 tapable 可以注册事件,从而在不同时机去触发注册的事件进行执行。

webpack 中的 plugin 机制正是基于这种机制实现了在不同编译阶段调用不同的插件,从而影响编译结果。

tapable 中所有注册的事件可以分为同步、异步两种执行方式:

image.png

  • 针对同步钩子来 tap 方法是唯一的注册事件的方法,通过 call 方法触发同步钩子的执行。

  • 异步钩子可以通过 taptapAsynctapPromise三种方式来注册,同时可以通过对应的 callcallAsyncpromise 三种方式来触发注册的函数。

  • 异步串行钩子( AsyncSeries ):可以被串联(连续按照顺序调用)执行的异步钩子函数。

  • 异步并行钩子( AsyncParallel ):可以被并联(并发调用)执行的异步钩子函数。

按照执行机制分类

tapable 也可以按照执行机制进行分类,比如:

Basic Hook

基本类型的钩子,它仅仅执行钩子注册的事件,并不关心每个被调用的事件函数返回值如何。

const {SyncHook} = require('tapable');
// 所有的构造函数都接收一个可选的参数,这个参数是一个参数名的字符串数组
// 1. 这里array的字符串随便填写,但是array的长度必须与实际要接受参数个数保持一致;
// 2. 如果回调不接受参数,可以传入空数组。
// 后面类型都是这个规则,不再做过多说明
const hook = new SyncHook(['name']);

// 添加监听
hook.tap('1', (arg0, arg1) => {
    // tap 的第一个参数是用来标识`call`传入的参数
    // 因为new的时候值的array长度为1
    // 所以这里只得到了`call`传入的第一个参数,即Webpack
    // arg1 为 undefined
    console.log(arg0, arg1);
    return '1';
});
hook.tap('2', arg0 => {
    console.log(arg0);
});
hook.tap('3', arg0 => {
    console.log(arg0);
});

// 传入参数,触发监听的函数回调
// 这里传入两个参数,但是实际回调函数只得到一个
hook.call('Webpack', 'Tapable');

// 执行结果:
Webpack undefined // 传入的参数需要和new实例的时候保持一致,否则获取不到多传的参数
Webpack
Webpack

通过上面的代码可以得出结论:

  1. 在实例化SyncHook传入的数组参数实际是只用了长度,跟实际内容没有关系
  2. 执行call时,入参个数跟实例化时数组长度相关;
  3. 回调栈是按照「先入先出」顺序执行的(这里叫回调队列更合适,队列是先入先出);
  4. 功能跟 EventEmitter 类似。

image.png

Bail Hook(保险类型钩子)

Bail类型的 Hook 也是按回调栈顺序依次执行回调,但是如果其中一个回调函数返回结果result !== undefined 则退出回调栈调。

const { SyncBailHook } = require('tapable')
const hook = new SyncBailHook(['name'])
hook.tap('1', name => {
  console.log(name, 1)
})
hook.tap('2', name => {
  console.log(name, 2)
  return 'stop'
})
hook.tap('3', name => {
  console.log(name, 3)
})
hook.call('hello')

// 打印
hello 1
hello 2

通过上面的代码可以得出结论:

  1. BailHook 中的回调是顺序执行的;
  2. 调用call传入的参数会被每个回调函数都获取;
  3. 当回调函数返回undefined 才会停止回调栈的调用。

image.png

SyncBailHook类似Array.find找到(或者发生)一件事情就停止执行AsyncParallelBailHook类似Promise.race这里竞速场景,只要有一个回调解决了一个问题,全部都解决了。

Waterfall Hook

类似Array.reduce效果,如果上一个回调函数的结果 result !== undefined,则会被作为下一个回调函数的第一个参数。代码示例如下:

const { SyncWaterfallHook } = require('tapable')
const hook = new SyncWaterfallHook(['arg0', 'arg1'])
hook.tap('1', (arg0, arg1) => {
  console.log(arg0, arg1, 1)
  return 1
})
hook.tap('2', (arg0, arg1) => {
  // 这里 arg0 = 1
  console.log(arg0, arg1, 2)
  return 2
})
hook.tap('3', (arg0, arg1) => {
  // 这里 arg0 = 2
  console.log(arg0, arg1, 3)
  // 等同于 return undefined
})
hook.tap('4', (arg0, arg1) => {
  // 这里 arg0 = 2 还是2
  console.log(arg0, arg1, 4)
})
hook.call('Webpack', 'Tapable')

// 打印结果
Webpack Tapable 1
1 'Tapable' 2
2 'Tapable' 3
2 'Tapable' 4

通过上面的代码可以得出结论:

  1. WaterfallHook 的回调函数接受的参数来自于上一个函数结果;
  2. 调用call传入的第一个参数会被上一个函数的undefined结果给替换;
  3. 当回调函数返回undefined 不会停止回调栈的调用。

image.png

Loop Hook

LoopHook 执行特点是不停地循环执行回调函数,直到所有函数结果 result === undefined。为了更加直观地展现 LoopHook 的执行过程,我对示例代码做了一下丰富:

const { SyncLoopHook } = require('tapable')
const hook = new SyncLoopHook(['name'])
let callbackCalledCount1 = 0
let callbackCalledCount2 = 0
let callbackCalledCount3 = 0
let intent = 0
hook.tap('callback 1', arg => {
  callbackCalledCount1++
  if (callbackCalledCount1 === 2) {
    callbackCalledCount1 = 0
    intent -= 4
    intentLog('</callback-1>')
    return
  } else {
    intentLog('<callback-1>')
    intent += 4
    return 'callback-1'
  }
})

hook.tap('callback 2', arg => {
  callbackCalledCount2++
  if (callbackCalledCount2 === 2) {
    callbackCalledCount2 = 0
    intent -= 4
    intentLog('</callback-2>')
    return
  } else {
    intentLog('<callback-2>')
    intent += 4
    return 'callback-2'
  }
})

hook.tap('callback 3', arg => {
  callbackCalledCount3++
  if (callbackCalledCount3 === 2) {
    callbackCalledCount3 = 0
    intent -= 4
    intentLog('</callback-3>')
    return
  } else {
    intentLog('<callback-3>')
    intent += 4
    return 'callback-3'
  }
})

hook.call('args')

function intentLog(...text) {
  console.log(new Array(intent).join(' '), ...text)
}

打印结果如下:

<callback-1>
 </callback-1>
 <callback-2>
    <callback-1>
    </callback-1>
 </callback-2>
 <callback-3>
    <callback-1>
    </callback-1>
    <callback-2>
        <callback-1>
        </callback-1>
    </callback-2>
 </callback-3>

image.png

AsyncSeriesHook

表示异步串联执行:

const { AsyncSeriesHook } = require('tapable');

// 初始化同步钩子
const hook = new AsyncSeriesHook(['arg1', 'arg2', 'arg3']);

console.time('timer');

// 注册事件
hook.tapAsync('flag1', (arg1, arg2, arg3, callback) => {
  console.log('flag1:', arg1, arg2, arg3);
  setTimeout(() => {
    // 1s后调用callback,表示flag1执行完成
    callback();
  }, 1000);
});

hook.tapPromise('flag2', (arg1, arg2, arg3) => {
  console.log('flag2:', arg1, arg2, arg3);
  // tapPromise返回Promise
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 1000);
  });
});

// 调用事件并传递执行参数
hook.callAsync('1', 'wang', 'haoyu', () => {
  console.log('全部执行完毕 done');
  console.timeEnd('timer');
});
// 打印结果
flag1: 1 wang haoyu
flag2: 1 wang haoyu
全部执行完毕 done
timer: 2.012s
  • 首先打印flag1: 1 wang haoyu,等待1s后,打印flag2: 1 wang haoyu,所以是一个串行的过程。所以总的用时是2s多一点。

  • tapAsync 注册时实参结尾额外接受一个 callback ,调用 callback 表示本次事件执行完毕。

AsyncParallelHook

表示异步并行钩子:

const { AsyncParallelHook } = require('tapable')

// 初始化同步钩子
const hook = new AsyncParallelHook(['arg1', 'arg2', 'arg3'])

console.time('timer')

// 注册事件
hook.tapAsync('flag1', (arg1, arg2, arg3, callback) => {
  console.log('flag1:', arg1, arg2, arg3)
  setTimeout(() => {
    // 1s后调用callback表示 flag1执行完成
    console.log('flag1----')
    callback()
  }, 1000)
})

hook.tapPromise('flag2', (arg1, arg2, arg3) => {
  console.log('flag2:', arg1, arg2, arg3)
  // tapPromise返回Promise
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('flag2----')
      resolve()
    }, 1000)
  })
})

// 调用事件并传递执行参数
hook.callAsync('19Qingfeng', 'wang', 'haoyu', () => {
  console.log('全部执行完毕 done')
  console.timeEnd('timer')
})

首先打印flag1: 1 wang haoyu flag2: 1 wang haoyu,然后等待1s后打印flag1---- flag2----,总用时是1s多,可以看出这两个函数是并行执行的。

Tapable 的原理解析

首先看下这段简单的代码:

code.png

看起来很简单对吧,这段代码通过 SyncHook 创建了一个同步 Hook 的实例之后,然后通过 tap 方法注册了两个事件,最后通过 call 方法来调用。

实质上这段代码在调用 hook.call('arg1','agr2') 时, Tapable 会动态编译出来这样一个函数:

function fn(arg1, arg2) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(arg1, arg2);
    var _fn1 = _x[1];
    _fn1(arg1, arg2);
}

通过 this._x 获取调用者的 _x 属性,之后从 _x 属性中获取到对应下标元素。

这里的_x[0] 正是我们监听的第一个 flag1 对应的事件函数体。

同理 _x[1] 正是通过 tap 方法监听的 flag2 函数体内容。

同时会生成一个 Hook 对象,它具有如下属性:

const hook = {
  _args: [ 'arg1', 'arg2' ],
  name: undefined,
  taps: [
    { type: 'sync', fn: [Function (anonymous)], name: 'flag1' },
    { type: 'sync', fn: [Function (anonymous)], name: 'flag2' }
  ],
  interceptors: [],
  _call: [Function: CALL_DELEGATE],
  call: [Function: anonymous],
  _callAsync: [Function: CALL_ASYNC_DELEGATE],
  callAsync: [Function: CALL_ASYNC_DELEGATE],
  _promise: [Function: PROMISE_DELEGATE],
  promise: [Function: PROMISE_DELEGATE],
  _x: [ [Function (anonymous)], [Function (anonymous)] ],
  compile: [Function: COMPILE],
  tap: [Function: tap],
  tapAsync: [Function: TAP_ASYNC],
  tapPromise: [Function: TAP_PROMISE],
  constructor: [Function: SyncHook]
} 

tapable 所做的事件就是根据 Hook 中对应的内容动态编译上述的函数体以及创建 Hook 实例对象。

最终在我们通过 Call 调用时,相当于执行这段代码:

// fn 为我们上述动态生成最终需要执行的fn函数
// hook 为我们上边 tapable 内部创建的hook实例对象
hook.call = fn
hook.call(arg1, arg2)

Tapable 源码中的核心正是围绕生成这两部分内容: 一个是动态生成的 fn,二是生成调用fn的 hook 实例对象。

源码中分别存在两个 class 去管理这两块的内容:

  • Hook 类,负责创建管理上边的 hook 实例对象。下文简称这个对象为核心hook实例对象
  • HookCodeFactory 类,负责根据内容编译最终通过 hook 调用的函数 fn 。下文简称这个函数为最终生成的执行函数

实现一个简易版的 Tapable

我们先从最基础的 SyncHook 出发来一步一步尝试实现 Tapable

首先创建基本目录结构:

image.png

  • 创建了一个 index.js 作为项目入口文件
exports.SyncHook = require('./SyncHook')
  • 创建了一个 SyncHook.js 保存同步基本钩子相关逻辑。
function SyncHook () {

}
module.exports = SyncHook
  • 创建 Hook.js ,该文件是所有类型 Hook 的父类,所有 Hook 都是基于该类派生而来的。

  • 创建一个 HookCodeFactory.js 作为生成最终需要执行的函数的文件。

实现 SyncHook.js

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

function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);
    hook.constructor = SyncHook;
    // COMPILE 方法你可以暂时忽略它
    hook.compile = COMPILE;
    return hook;
}

SyncHook.prototype = null;

module.exports = SyncHook;

当我们进行 new SyncHook

  • 首先通过 new SyncHook(args, name) 创建了基础的 hook 实例对象。

  • 所有类型的 Hook 都是基于这个 Hook 类去继承而来的,同时这个基础的 Hook 类的实例也就是所谓的核心hook实例对象

  • 返回 hook 实例对象,并且将 SyncHook 的原型设置为 null。

实现 Hook.js

class Hook {
  constructor(args = [], name = undefined) {
    // 保存初始化Hook时传递的参数
    this._args = args;
    // name参数没什么用可以忽略掉
    this.name = name;
    // 保存通过tap注册的内容
    this.taps = [];
    // hook.call 调用方法
    this._call = CALL_DELEGATE;
    this.call = CALL_DELEGATE;
    // _x存放hook中所有通过tap注册的函数
    this._x = undefined;
    // 动态编译方法
    this.compile = this.compile;
    // 相关注册方法
    this.tap = this.tap;
  }

  compile(options) {
    throw new Error('Abstract: should be overridden');
  }
}

module.exports = Hook;

所谓 compile 方法正是编译我们最终生成的执行函数的入口方法,同时我们可以看到在 Hook 类中并没有实现 compile 方法,这是因为不同类型的 Hook 最终编译出的执行函数是不同的形式,所以这里以一种抽象方法的方式将 compile 方法交给了子类进行实现。

实现 tap 注册方法

class Hook {
  ...
  tap(options, fn) {
    // 这里额外多做了一层封装 是因为this._tap是一个通用方法
    // 这里我们使用的是同步 所以第一参数表示类型传入 sync
    this._tap('sync', options, fn);
  }

  _tap(type, options, fn) {
    if (typeof options === 'string') {
      options = {
        name: options.trim(),
      };
    } else if (typeof options !== 'object' || options === null) {
      // 如果非对象或者传入null
      throw new Error('Invalid tap options');
    }
    // 那么此时剩下的options类型仅仅就只有object类型了
    if (typeof options.name !== 'string' || options.name === '') {
      // 如果传入的options.name 不是字符串 或者是 空串
      throw new Error('Missing name for tap');
    }
    // 合并参数 { type, fn,  name:'xxx'  }
    options = Object.assign({ type, fn }, options);
    // 将合并后的参数插入
    this._insert(options)
  }
  
  _insert(item) {
    this.taps.push(item)
  }
}

当我们调用 hook.tap 方法注册事件时,最终会在 this.taps 中插入一个 { type:'sync',name:string, fn: Function} 的对象。

实现 call 调用方法

真实的 call 方法的内部核心就是通过调用 hook.call 时动态生成最终生成的执行函数,从而通过 hook 实例对象调用这个最终生成的执行函数

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

class Hook {
    constructor(args = [], name = undefined) {
        // ...
        this._call = CALL_DELEGATE;
        this.call = CALL_DELEGATE;
        // ...
    }
        
    ...
        
    // 编译最终生成的执行函数的方法
    // compile是一个抽象方法 需要在继承Hook类的子类方法中进行实现
    _createCall(type) {
      return this.compile({
        taps: this.taps,
        args: this._args
        type: type,
        });
    }
}

这里的 CALL_DELEGATE 只有在 this.call 被调用的时才会执行,换句话说每次调用 hook.call 方法时才会进行一次编译,根据 hook 内部注册的事件函数编译称为最终生成的执行函数从而调用它。

你可以将它理解成为一种懒(动态)编译的方式

实现 HookCodeFactory.js

接下来走进 HookCodeFactory.js 开始探索 Tapable 是如何编译生成最终生成的执行函数

Hook.js Compile 方法

在 Hook.js 的父类中,我们并没有实现 compile 方法,我们说过每个 compile 方法不同类型的 Hook 编译的结果函数都是不尽相同的。

// SyncHook.js
const Hook = require('./Hook');
const HookCodeFactory = require('./HookCodeFactory');

class SyncHookCodeFactory extends HookCodeFactory {
  // 关于 content 方法 你可以先忽略它
  content({ onError, onDone, rethrowIfPossible }) {
    return this.callTapsSeries({
      onError: (i, err) => onError(err),
      onDone,
      rethrowIfPossible,
    });
  }
}

const factory = new SyncHookCodeFactory();

/**
 * 调用栈 this.call() -> CALL_DELEGATE() -> this._createCall() -> this.compile() -> COMPILE()
 * @param {*} options
 * @returns
 */
function COMPILE(options) {
  factory.setup(this, options);
  return factory.create(options);
}

function SyncHook(args = [], name = undefined) {
  const hook = new Hook(args);
  hook.constructor = SyncHook;
  hook.tapAsync = TAP_ASYNC;
  hook.tapPromise = TAP_PROMISE;
  hook.compile = COMPILE;
  return hook;
}

SyncHook.prototype = null;
module.exports = SyncHook;
  • hook.compile 方法在 hook.call 调用时会被调用,接受的 options 类型的参数如下:
{
  taps: this.taps,
  args: this._args,
  type: type,
}
  • HookCodeFactory 这个类即是编译生成最终生成的执行函数的方法类,这是一个基础类。Tapable 将不同种类 Hook 编译生成最终方法相同逻辑抽离到了这个类上。

  • SyncHookCodeFactory 它是 HookCodeFactory 的子类,它用来存放不同类型的 Hook 中差异化的 content 方法实现。

  • COMPILE 方法内部 SyncHookCodeFactory 的实例对象 factory 调用了初始化 factory.setup(this, options) 以及通过 factory.create(options) 创建最终生成的执行函数并且返回这个函数。

其实 Tapable 中的代码思路还是非常清晰的,不同的类负责不同的逻辑处理。

抽离公用的逻辑在基类中进行实现,同时对于差异化的逻辑基于抽象类的方式在不同的子类中进行实现。

HookCodeFactory.js 基础骨架

class HookCodeFactory {
  constructor(config) {
    this.config = config;
    this.options = undefined;
    this._args = undefined;
  }

  // 初始化参数
  setup(instance, options) {}

  // 编译最终需要生成的函数
  create(options) {}
}
 
module.exports = HookCodeFactory;

setup 方法

setup 方法的实现非常简单,它的作用是用来初始化当前事件组成的集合

class HookCodeFactory {
    ...
      // 初始化参数
      setup(instance, options) {
        instance._x = options.taps.map(i => i.fn)
      }
    ...
}
  • 第一个参数是 COMPILE 方法中的 this 对象,也就是我们通过 new Hook 生成的 hook 实例对象。
  • 第二个参数是调用 COMPILE 方法时 Hook 类上 _createCall 传递的 options 对象,它的内容是:
{
  taps: this.taps,
  args: this._args,
  type: type,
}

每次调用 hook.call 时会首先通过 setup 方法为 hook 实例对象上的 _x 赋值为所有被 tap 注册的事件函数 [fn1,fn2 ...]

create 方法

Tapable 中正是通过 HookCodeFactory 类上的 create 方法正是实现了编译出最终需要执行函数的核心逻辑。正是通过 HookCodeFactory 类上的 create 方法编译出的这段函数:

function fn(arg1, arg2) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(arg1, arg2);
    var _fn1 = _x[1];
    _fn1(arg1, arg2);
}

让我们一步一步先来实现 create 方法:

class HookCodeFactory {
  constructor(config) {
    this.config = config
    this.options = undefined
    this._args = undefined
  }

  // 参数初始化
  setup(instance, options) {
    instance._x = options.taps.map(i => i.fn)
  }

  // 编译最终需要生成的函数
  create(options) {
    this.init(options)
    // 生成最终的编译方法fn
    let fn
    switch (this.options.type) {
      case 'sync':
        fn = new Function(
          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
      default:
        break
    }
    this.deinit()
    return fn
  }

  callTapsSeries({ onDone }) {
    let code = ''
    let current = onDone
    // 没有注册的事件则直接返回
    if (this.options.taps.length === 0) {
      return onDone()
    }
    // 遍历taps注册的函数 编译生成需要执行的函数
    for (let i = this.options.taps.length - 1; i >= 0; i--) {
      const done = current
      // 一个一个创建对应的函数调用
      const content = this.callTap(i, {
        onDone: done
      })
      current = () => content
    }
    code += current()
    return code
  }

  // 编译生成单个的事件函数并且调用 比如 fn1 = this._x[0]; fn1(...args)
  callTap(tapIndex, { onDone }) {
    let code = ''
    // 无论什么类型的都要通过下标先获得内容
    // 比如这一步生成 var _fn[1] = this._x[1]
    code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`
    // 不同类型的调用方式不同
    // 生成调用代码 fn1(arg1,arg2,...)
    const tap = this.options.taps[tapIndex]
    switch (tap.type) {
      case 'sync':
        code += `_fn${tapIndex}(${this.args()});\n`
        break
      default:
        break
    }
    if (onDone) {
      code += onDone()
    }
    return code
  }

  // 从this._x中获取函数内容 this._x[index]
  getTapFn(idx) {
    return `_x[${idx}]`
  }

  contentWithInterceptors(options) {
    return this.content(options)
  }

  header() {
    let code = ''
    code += 'var _context;\n'
    code += 'var _x = this._x;\n'
    return code
  }

  args({ before, after } = {}) {
    let allArgs = this._args
    if (before) {
      allArgs = [before].concat(allArgs)
    }
    if (after) {
      allArgs = [after].concat(allArgs)
    }
    if (allArgs.length === 0) {
      return ''
    } else {
      return allArgs.join(',')
    }
  }

  init(options) {
    this.options = options
    // 保存初始化Hook时的参数,即占位符参数
    this._args = options.args.slice()
  }

  deinit() {
    this.options = undefined
    this._args = undefined
  }
}

module.exports = HookCodeFactory

动态创建函数使用了new Function进行创建,它的用法如下:

new Function ([arg1[, arg2[, ...argN]],] functionBody)
const adder = new Function("a, b", "return a + b");
// 调用函数
adder(2, 6);//8

通过一个 SyncHook 应该已经说明了 Tapable 中基础的工作流,如下图所示:

image.png

本质上 Tapable 就是通过 Hook 这个类来保存相应的监听属性和方法,同时在调用 call 方法触发事件时通过 HookCodeFactory 动态编译生成的一个 Function ,从而执行达到相应的效果。

Tapable 与 Webpack

纵观 Webapck 编译阶段存在两个核心对象 CompilerCompilationWebpack在初始化 CompilerCompilation 对象时会创建一系列相应的 Hook 作为属性保存各自实例对象中。

image.png

在进行 Webapck Plugin 开发时,正是基于这一系列 Hook 在不同时机发布对应事件。执行相应的事件从而影响最终的编译结果。