🎨 Webpack 插件机制之 Tapable

🍻 前言

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

Webpack 的插件机制本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是 TapableWebpack 中最核心的负责编译的 Compiler 和负责创建bundles的 Compilation 都是 Tapable 的实例。

🍻 所谓 Tapable?

Tapable 是一个类似于 Node.js 中的 EventEmitter 的库,但它更专注于自定义事件的触发和处理。通过 Tapable 我们可以注册自定义事件,然后在适当的时机去执行自定义事件。类比到 VueReact 框架中的生命周期函数,它们就是到了固定的时间节点就执行对应的生命周期。

以syncHook 为例实操一下

初始化项目,安装依赖:

js 复制代码
npm init //初始化项目
yarn add tapable -D //安装依赖

根据以下目录来添加对于对应的目录和文件

js 复制代码
├── node_modules
├── package-lock.json
├── package.json
└── src # 源码目录
     └── syncHookDemo.js

使用 tapable 步骤:

🔸 实例化钩子函数(tapable 会暴露出各种各样的 Hook,这里先以同步钩子 Synchook 为例)

🔸 注册事件

🔸 触发事件

src/syncHookDemo.js

js 复制代码
const SyncHook = require("../my/SyncHook");//同步钩子

// 第一步:实例化钩子函数,可以在这里定义形参
const syncHook = new SyncHook(['author', 'age']);

// 第二步:注册事件1
syncHook.tap("监听器1", (name, age) => {
    console.log('监听器1:', name, age);
})

// 第二步:注册事件2
syncHook.tap("监听器2", (name) => {
    console.log('监听器2:', name);
})

// 第三步:注册事件3
syncHook.tap("监听器3", (name) => {
    console.log('监听器3:', name);
})

// 第三步:触发事件,这里传的是实参,会被每一个注册函数接收到
syncHook.call('嘎嘎嘎', '9');

运行 node ./src/syncHookDemo.js,拿到执行结果:

js 复制代码
监听器1 嘎嘎嘎 99
监听器2 嘎嘎嘎
监听器3 嘎嘎嘎

可见,tapable 采用的是发布订阅方式,通过 tap 函数注册监听函数,然后通过 call 函数按顺序执行之前注册的函数。

大致的原理:

js 复制代码
class SyncHook {
    contrustor() {
        this.taps = [];
    }
    
    // 注册监听函数,这里的name其实没啥用
    tap(name, fn) {
        this.taps.push({name, fn})
    }
    
    // 执行函数
    call(...args) {
        this.taps.foEach((tap) => tap.fn(...args));
    }
}

🍻 Tapable 钩子分类

🚖 按照同步/异步分类

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

对于异步钩子来说,可以通过 taptapAsynctapPromise 三种方式来注册,通过对应的 callAsyncpromise 这两种方式来触发注册的函数。

🚖 按照执行机制分类

🔸 Basic Hook

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

🔸 Waterfall

瀑布类型的钩子,如果前一个事件函数的结果 result !== undefined,则 result 会作为后一个事件函数的第一个参数

上一个函数的执行结果会成为下一个函数的参数

🔸 Bail

保险类钩子,执行每一个事件函数,遇到第一个结果 result !== undefined 则返回,不再继续执行

其中有一个有结果后面的就不执行

🔸 Loop

循环类钩子,不停地循环执行事件函数,直到所有函数结果 result === undefined

🍻 基本使用

🚖 SyncHook

上面的案例就是基于 SyncHook,就不再赘述。

🚖 SyncBailHook

SyncBailHook 是一个同步的、保险类型的 Hook,意思是只要其中一个有返回了,后面的就不执行了。

js 复制代码
const { SyncBailHook } = require("tapable");

const hook = new SyncBailHook(["author", "age"]); //先实例化,并定义回调函数的形参

//通过tap函数注册事件
hook.tap("测试1", (param1, param2) => {
  console.log("测试1接收的参数:", param1, param2);
});

//该监听函数有返回值
hook.tap("测试2", (param1, param2) => {
  console.log("测试2接收的参数:", param1, param2);
  return "123";
});

hook.tap("测试3", (param1, param2) => {
  console.log("测试3接收的参数:", param1, param2);
});

//通过call方法触发事件
hook.call("嘎嘎嘎", "99");

//结果:
测试1接收的参数: 嘎嘎嘎 99
测试2接收的参数: 嘎嘎嘎 99

🚖 SyncWaterfallHook

SyncWaterfallHook 是一个同步的、瀑布式类型的 Hook。瀑布类型的钩子就是如果前一个事件函数的结果result !== undefined,则 result 会作为后一个事件函数的第一个参数(也就是上一个函数的执行结果会成为下一个函数的参数)

js 复制代码
const { SyncWaterfallHook } = require("tapable");

const hook = new SyncWaterfallHook(["author", "age"]); //先实例化,并定义回调函数的形参

//通过tap函数注册事件
hook.tap("测试1", (param1, param2) => {
  console.log("测试1接收的参数:", param1, param2);
});

hook.tap("测试2", (param1, param2) => {
  console.log("测试2接收的参数:", param1, param2);
  return "123";
});

hook.tap("测试3", (param1, param2) => {
  console.log("测试3接收的参数:", param1, param2);
});

//通过call方法触发事件
hook.call("嘎嘎嘎", "99");


//结果
测试1接收的参数: 嘎嘎嘎 99
测试2接收的参数: 嘎嘎嘎 99
测试3接收的参数: 123 99

🚖 SyncLoopHook

SyncLoopHook 是一个同步、循环类型的 Hook。循环类型的含义是不停的循环执行事件函数,直到所有函数结果result === undefined,不符合条件就调头重新开始执行。

js 复制代码
const { SyncLoopHook } = require("tapable");

const hook = new SyncLoopHook([]); //先实例化,并定义回调函数的形参

let count = 5;

//通过tap函数注册事件
hook.tap("测试1", () => {
  console.log("测试1里面的count:", count);
  if ([1, 2, 3].includes(count)) {
    return undefined;
  } else {
    count--;
    return "123";
  }
});

hook.tap("测试2", () => {
  console.log("测试2里面的count:", count);
  if ([1, 2].includes(count)) {
    return undefined;
  } else {
    count--;
    return "123";
  }
});

hook.tap("测试3", () => {
  console.log("测试3里面的count:", count);
  if ([1].includes(count)) {
    return undefined;
  } else {
    count--;
    return "123";
  }
});

//通过call方法触发事件
hook.call();

//结果
测试1里面的count: 5
测试1里面的count: 4
测试1里面的count: 3
测试2里面的count: 3
测试1里面的count: 2
测试2里面的count: 2
测试3里面的count: 2
测试1里面的count: 1
测试2里面的count: 1
测试3里面的count: 1

🚖 AsyncParallelHook

AsyncParallelHook是一个异步并行、基本类型的 Hook,它与同步 Hook 不同的地方在于:

  • 它会同时开启多个异步任务,而且需要通过 tapAsync 方法来注册事件(同步 Hook 是通过 tap 方法)
  • 在执行注册事件时需要使用 callAsync 方法来触发(同步 Hook 使用的是 call 方法)

同时,在每个注册函数的回调中,会多一个 callback 参数,它是一个函数。执行 callback 函数相当于告诉 Hook 它这一个异步任务执行完成了。

js 复制代码
const { AsyncParallelHook } = require("tapable");

const hook = new AsyncParallelHook(["author", "age"]); //先实例化,并定义回调函数的形参
console.time("time");
//异步钩子需要通过tapAsync函数注册事件,同时也会多一个callback参数,执行callback告诉hook该注册事件已经执行完成
hook.tapAsync("测试1", (param1, param2, callback) => {
  setTimeout(() => {
    console.log("测试1接收的参数:", param1, param2);
    callback();
  }, 2000);
});

hook.tapAsync("测试2", (param1, param2, callback) => {
  console.log("测试2接收的参数:", param1, param2);
  callback();
});

hook.tapAsync("测试3", (param1, param2, callback) => {
  console.log("测试3接收的参数:", param1, param2);
  callback();
});

//call方法只有同步钩子才有,异步钩子得使用callAsync
hook.callAsync("嘎嘎嘎", "99", (err, result) => {
  //等全部都完成了才会走到这里来
  console.log("这是成功后的回调", err, result);
  console.timeEnd("time");
});


// 结果
测试2接收的参数: 嘎嘎嘎 99
测试3接收的参数: 嘎嘎嘎 99
测试1接收的参数: 嘎嘎嘎 99
这是成功后的回调 undefined undefined
time: 2.008s

🚖 AsyncParallelBailHook

AsyncParallelBailHook 是一个异步并行、保险类型的 Hook,只要其中一个有返回值,就会执行 callAsync 中的回调函数

js 复制代码
const { AsyncParallelBailHook } = require("tapable");

const hook = new AsyncParallelBailHook(["author", "age"]); //先实例化,并定义回调函数的形参
console.time("time");
//异步钩子需要通过tapAsync函数注册事件,同时也会多一个callback参数,执行callback告诉hook该注册事件已经执行完成
hook.tapAsync("测试1", (param1, param2, callback) => {
  console.log("测试1接收的参数:", param1, param2);
  setTimeout(() => {
    callback();
  }, 1000);
});

hook.tapAsync("测试2", (param1, param2, callback) => {
  console.log("测试2接收的参数:", param1, param2);
  setTimeout(() => {
    callback(null, "测试2有返回值啦");
  }, 2000);
});

hook.tapAsync("测试3", (param1, param2, callback) => {
  console.log("测试3接收的参数:", param1, param2);
  setTimeout(() => {
    callback(null, "测试3有返回值啦");
  }, 3000);
});

hook.callAsync("嘎嘎嘎", "99", (err, result) => {
  //等全部都完成了才会走到这里来
  console.log("这是成功后的回调", result);
  console.timeEnd("time");
});

// 结果
测试1接收的参数: 嘎嘎嘎 99
测试2接收的参数: 嘎嘎嘎 99
测试3接收的参数: 嘎嘎嘎 99
这是成功后的回调 测试2有返回值啦
time: 2.007s

🚖 AsyncSeriesHook

AsyncSeriesHook 是一个异步、串行类型的 Hook,只有前面的执行完成了,后面的才会一个接一个的执行。

js 复制代码
const { AsyncSeriesHook } = require("tapable");

const hook = new AsyncSeriesHook(["author", "age"]); //先实例化,并定义回调函数的形参
console.time("time");
//异步钩子需要通过tapAsync函数注册事件,同时也会多一个callback参数,执行callback告诉hook该注册事件已经执行完成
hook.tapAsync("测试1", (param1, param2, callback) => {
  console.log("测试1接收的参数:", param1, param2);
  setTimeout(() => {
    callback();
  }, 1000);
});

hook.tapAsync("测试2", (param1, param2, callback) => {
  console.log("测试2接收的参数:", param1, param2);
  setTimeout(() => {
    callback();
  }, 2000);
});

hook.tapAsync("测试3", (param1, param2, callback) => {
  console.log("测试3接收的参数:", param1, param2);
  setTimeout(() => {
    callback();
  }, 3000);
});

hook.callAsync("嘎嘎嘎", "99", (err, result) => {
  //等全部都完成了才会走到这里来
  console.log("这是成功后的回调", err, result);
  console.timeEnd("time");
});

// 结果
测试1接收的参数: 嘎嘎嘎 99
测试2接收的参数: 嘎嘎嘎 99
测试3接收的参数: 嘎嘎嘎 99
这是成功后的回调 undefined undefined
time: 6.017s

🚖 AsyncSeriesBailHook

AsyncSeriesBailHook 是一个异步串行、保险类型的 Hook。在串行的执行过程中,只要其中一个有返回值,后面的就不会执行了。

js 复制代码
const { AsyncSeriesBailHook } = require("tapable");

const hook = new AsyncSeriesBailHook(["author", "age"]); //先实例化,并定义回调函数的形参
console.time("time");
//异步钩子需要通过tapAsync函数注册事件,同时也会多一个callback参数,执行callback告诉hook该注册事件已经执行完成
hook.tapAsync("测试1", (param1, param2, callback) => {
  console.log("测试1接收的参数:", param1, param2);
  setTimeout(() => {
    callback();
  }, 1000);
});

hook.tapAsync("测试2", (param1, param2, callback) => {
  console.log("测试2接收的参数:", param1, param2);
  setTimeout(() => {
    callback(null, "123");
  }, 2000);
});

hook.tapAsync("测试3", (param1, param2, callback) => {
  console.log("测试3接收的参数:", param1, param2);
  setTimeout(() => {
    callback();
  }, 3000);
});

hook.callAsync("嘎嘎嘎", "99", (err, result) => {
  //等全部都完成了才会走到这里来
  console.log("这是成功后的回调", result);
  console.timeEnd("time");
});

// 结果
测试1接收的参数: 嘎嘎嘎 99
测试2接收的参数: 嘎嘎嘎 99
这是成功后的回调 123
time: 3.010s

🚖 AsyncSeriesWaterfallHook

AsyncSeriesWaterfallHook 是一个异步串行、瀑布类型的 Hook。如果前一个事件函数的结果 result !== undefined,则 result 会作为后一个事件函数的第一个参数(也就是上一个函数的执行结果会成为下一个函数的参数)。

js 复制代码
const { AsyncSeriesWaterfallHook } = require("tapable");

const hook = new AsyncSeriesWaterfallHook(["author", "age"]); //先实例化,并定义回调函数的形参
console.time("time");
//异步钩子需要通过tapAsync函数注册事件,同时也会多一个callback参数,执行callback告诉hook该注册事件已经执行完成
hook.tapAsync("测试1", (param1, param2, callback) => {
  console.log("测试1接收的参数:", param1, param2);
  setTimeout(() => {
    callback(null, "2");
  }, 1000);
});

hook.tapAsync("测试2", (param1, param2, callback) => {
  console.log("测试2接收的参数:", param1, param2);
  setTimeout(() => {
    callback(null, "3");
  }, 2000);
});

hook.tapAsync("测试3", (param1, param2, callback) => {
  console.log("测试3接收的参数:", param1, param2);
  setTimeout(() => {
    callback(null, "4");
  }, 3000);
});

hook.callAsync("嘎嘎嘎", "99", (err, result) => {
  //等全部都完成了才会走到这里来
  console.log("这是成功后的回调", err, result);
  console.timeEnd("time");
});

// 结果
测试1接收的参数: 嘎嘎嘎 99
测试2接收的参数: 2 99
测试3接收的参数: 3 99
这是成功后的回调 null 4
time: 6.012s

🍻 Tapable 拦截器

Tapable 提供的所有 Hook 都支持注入 Interception ,它和 Axios 中的拦截器的效果非常类似。

我们可以通过拦截器对整个 Tapable 发布/订阅流程进行监听,从而触发对应的逻辑。

js 复制代码
const hook = new SyncHook(['arg1', 'arg2', 'arg3']);

hook.intercept({
  // 每次调用 hook 实例的 tap() 方法注册回调函数时, 都会调用该方法,
  // 并且接受 tap 作为参数, 还可以对 tap 进行修改;
  register: (tapInfo) => {
    console.log(`${tapInfo.name} is doing its job`);
    return tapInfo; // may return a new tapInfo object
  },
  // 通过hook实例对象上的call方法时候触发拦截器
  call: (arg1, arg2, arg3) => {
    console.log('Starting to calculate routes');
  },
  // 在调用被注册的每一个事件函数之前执行
  tap: (tap) => {
    console.log(tap, 'tap');
  },
  // loop类型钩子中 每个事件函数被调用前触发该拦截器方法
  loop: (...args) => {
    console.log(args, 'loop');
  },
});

🔸 register

每次通过 taptapAsynctapPromise 方法注册事件函数时,会触发 register 拦截器。这个拦截器中接受注册的 Tap 作为参数,同时可以对于注册的事件进行修改。

🔸 call

通过调用 hook 实例对象的 call 方法时执行。(包括 callAsync, promise)接受的参数为调用 Hook 时传入的参数。

🔸 tap

在每一个被注册的事件函数调用之前执行,接受参数为对应的 Tap 对象。

🔸 loop

loop 类型钩子中 每次重新开始 loop 之前会执行该拦截器,拦截器函数接受的参数为调用时传入的参数。

🍻 其他 API

关于 Tapable 其实还有相关的模块 API 分别是B efore && stageHookMapContext

🚖 Before & stage

Tapable 在注册事件函数时,第一个参数同时支持传入一个对象。

我们可以通过这个对象上的 stage 和 before 属性来控制本次注册的事件函数执行时机。

🔸 Before 属性

before 属性的值可以传入一个数组或者字符串,值为注册事件对象时的名称,它可以修改当前事件函数在传入的事件名称对应的函数之前进行执行。

比如:

js 复制代码
const { SyncHook } = require('tapable');

const hooks = new SyncHook();

hooks.tap(
  {
    name: 'flag1',
  },
  () => {
    console.log('This is flag1 function.');
  }
);

hooks.tap(
  {
    name: 'flag2',
    // flag2 事件函数会在flag1之前进行执行
    before: 'flag1',
  },
  () => {
    console.log('This is flag2 function.');
  }
);

hooks.call();

// result
This is flag2 function.
This is flag1 function.

🔸 stage 属性

stage 这个属性的类型是数字,数字越大事件回调执行的越晚,支持传入负数,不传时默认为0.

js 复制代码
const { SyncHook } = require('tapable');

const hooks = new SyncHook();

hooks.tap(
  {
    name: 'flag1',
    stage: 1,
  },
  () => {
    console.log('This is flag1 function.');
  }
);

hooks.tap(
  {
    name: 'flag2',
    // 默认为stage: 0,
  },
  () => {
    console.log('This is flag2 function.');
  }
);

hooks.call();

// result
This is flag2 function.
This is flag1 function.

注意:

如果同时使用 beforestage 时,优先会处理 before ,在满足 before 的条件之后才会进行 stage 的判断。

关于 beforestage 都可以修改事件回调函数的执行时间,但是不建议混用这两个属性。换句话说如果你选择在你的 hooks.tap 中使用 stage 的话就不要再出现 before ,反之亦然。

🚖 HookMap

HookMap 本质上就是一个辅助类,通过 HookMap 我们可以更好的管理 Hook

js 复制代码
const { HookMap, SyncHook } = require('tapable');

// 创建HookMap实例
const keyedHook = new HookMap((key) => new SyncHook(['arg']));

// 在keyedHook中创建一个name为key1的hook,同时为该hook通过tap注册事件 
keyedHook.for('key1').tap('Plugin 1', (arg) => {
  console.log('Plugin 1', arg);
});

// 在keyedHook中创建一个name为key2的hook,同时为该hook通过tap注册事件
keyedHook.for('key2').tap('Plugin 2', (arg) => {
  console.log('Plugin 2', arg);
});

// 在keyedHook中创建一个name为key1的hook,同时为该hook通过tap注册事件
keyedHook.for('key3').tap('Plugin 3', (arg) => {
  console.log('Plugin 3', arg);
});

// 从HookMap中拿到name为key1的hook
const hook = keyedHook.get('key1');

if (hook) {
  // 通过call方法触发Hook
  hook.call('hello');
}

🚖 MultiHook

MultiHook 在日常应用中并不是很常见,它的主要作用也就是通过 MultiHook 批量注册事件函数在多个钩子中。

🚖 Context

关于 Context 在源码中如果你传递了 Context 参数,那么会进入这段逻辑:

js 复制代码
const deprecateContext = util.deprecate(
    () => {},
    "Hook.context is deprecated and will be remove"
)

这个 API 将来会被废弃,一个即将废弃且使用场景不多的情况这里就不和大家展开讲解了。

🍻 Tapable 底层原理

Tapable 工程源码克隆到本地, 执行如下指令:

js 复制代码
$ git clone https://github.com/webpack/tapable.git

Tapable 源码的 lib 目录结构, 如下所示:

js 复制代码
lib
├─ 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
└─ util-browser.js

除了上面我们所提及的基本 hooks 函数、HookMap高级特性,还会有一些 HookCodeFactoryHook 这些文件。看完 hooks 函数内的内容, 会发现所有的 hooks 函数都会引用 HookCodeFactoryHook 这两个文件所导出的对象实例。

syncHook 钩子函数为例, 如下图所示:

总结:

一个 hooks 函数 会由一个 CodeFactory 代码工厂 以及 Hook 实例组成。 Hook 实例会针对不同场景的 hooks 函数, 更改其对应的 注册钩子( tapAsync , tap , tapPromise )事件触发钩子( call , callAsync ) , 编译函数(complier)Complier 函数会由我们 HookCodeFactory 实现。

接下来我们将通过分析 HookCodeFactoryHook 的内部实现来了解 Tapable 的内部实现机制。

🚖 Hook 实例

Hook 实例会生成我们 hooks 钩子函数通用的 事件注册事件触发。核心逻辑,我们大致可以分为:实例初始化构造函数、事件注册的实现、事件触发的实现这三个部分。

🔸(1)构造函数

构造函数会对实例属性初始化赋值。代码如下图所示:

🔸(2)注册事件

注册事件主要分为两块,一块是 适配器注册调用, 第二块是 触发事件注册。核心逻辑在 _tap 函数内部实现,代码如下图所示:

适配器调用

在这里会对携带 register 函数的适配器进行调用,更改 options 配置,返回新的 options 配置。代码如下图所示:

触发事件注册

Hook 实例的 taps 会存储我们的注册事件, 同时会根据,注册事件配置的执行顺序去存储对应的注册事件。

🔸(3)触发事件

触发事件会通过调用内部 的 _createCall 函数,函数内部会调用实例的 compile 函数。

Hook 实例内部不会去实现 complier的逻辑, 不同钩子的 complier 函数会通过通过对应的 继承 HookCodeFactory 的实例去实现。代码如下图所示:

🚖 HookCodeFactory 实例

Tapable 事件触发的逻辑。

HookCodeFactory 实例会根据我们传入的事件触发类型 (sync, async, promise)以及我们的触发机制类型 (常规 瀑布模式 保险模式 循环模式),生成事件触发函数的函数头(header)、函数体(content)。通过 new Function 构造出事件触发函数。

其中:

content 最终会由 callTapsSeriescallTapsLoopingcallTapsParallel 生成。每种生成方式都会包含 Done 处理、Error 处理以及 Result 处理。

Tapable 事件触发的执行,是动态生成执行代码, 包含我们的参数,函数头,函数体,然后通过 new Function 来执行。相较于我们通常的遍历/递归调用事件,这无疑让 webpack 的整个事件机制的执行有了一个更高的性能优势。

Hook 实例 的 complier 函数是 HookCodeFactory 实例 create 函数 的返回。

🔸 create

create 函数通过对应的 函数参数函数 header, 函数 content方法构造出我们事件触发的函数的内容, 通过 new Function 创建触发函数。

create 函数会根据事件的触发类型 ( syncasyncpromise),进行不同的逻辑处理。代码如下图所示:

每一种触发机制,都会由 this.args, this.header, this.contentWithInterceptors 三个函数去实现 动态函数的 code。代码如下图所示:

contentWithInterceptors

contentWithInterceptors 函数里包含两个模块, 一个是适配器 (interceptor), 一个 content 生成函数。

同时,HookCodeFactory 实例本身不会去实现 content 函数的逻辑,会由继承的实例去实现。整体结构代码如下图所示:

每个 hooks 钩子函数的 CodeFactory 实例会去实现 content 函数。 content 函数会调用 HookCodeFactory 实现的不同运行机制的方法( callTapcallTapsSeriescallTapsLoopingcallTapsParallel), 构造出最终的函数体。实现代码如下图所示:

接下来,就是不同运行机制,根据不同的调用方式 ( sync, async, promise ) 生成对应的执行代码。

🍻 mini 版的 SyncHook具体实现

🚖 核心思想

这里以 SyncHook 的实现原理为例,其他的 Hook 也会整理一下思路,大家举一反三,重点在于理解思想。

回过头来看看 SyncHook 的用法,也就是这三步:

(1)实例化

(2)通过 tap 函数注册事件

(3)通过 call 方法触发事件

js 复制代码
const SyncHook = require("../my/SyncHook");//同步钩子

// 第一步:实例化钩子函数,可以在这里定义形参
const syncHook = new SyncHook(['author', 'age']);

// 第二步:注册事件1
syncHook.tap("监听器1", (name, age) => {
    console.log('监听器1:', name, age);
})

// 第二步:注册事件2
syncHook.tap("监听器2", (name) => {
    console.log('监听器2:', name);
})

// 第三步:注册事件3
syncHook.tap("监听器3", (name) => {
    console.log('监听器3:', name);
})

// 第三步:触发事件,这里传的是实参,会被每一个注册函数接收到
syncHook.call('嘎嘎嘎', '9');

其实 tap 函数就是一个收集器,当调用 tap 函数时需要将传入的这些信息进行收集,并转换成一个数组,数组里面存放着注册函数的类型type回调函数(fn)等信息:

js 复制代码
this.taps = [
  {
    name: "监听器1",
    type: "sync",
    fn: (param1, param2) => {
      console.log("监听器1接收参数:", name, age);
    },
  },
  {
    name: "监听器2",
    type: "sync",
    fn: (param1, param2) => {
      console.log("监听器2接收参数:", name);
    },
  },
]; //用来存放我们的回调函数基本信息

调用 call 函数的本质就是 按指定的类型 去执行 this.taps中的注册函数 fn,比如这里的 type: sync,就是得按同步的方式执行,那我们只需将运行代码改造成这样:

js 复制代码
function anonymous(param1, param2) {
  const taps = this.taps;
  
  let fn0 = taps[0].fn;
  fn0(param1, param2);

  let fn1 = taps[1].fn;
  fn1(param1, param2);
}
anonymous("嘎嘎嘎", "99");

如果要按照SyncBailHook(同步、保险类型:只要其中一个有返回值,后面的就不执行了 )执行,那我们只需将运行代码改造成这样:

js 复制代码
function anonymous(param1, param2) {
  const taps = this.taps;
  
  let fn0 = taps[0].fn;
  let result0 = fn0(param1, param2);

  if (result0 !== undefined) {
    return result0;
  } else {
    let fn1 = taps[1].fn;
    let result1 = fn1(param1, param2);

    if (result1 !== undefined) {
      return result1;
    }
  }
}
anonymous("嘎嘎嘎", "99");

如果得按照 AsyncSeriesHook(异步、串行类型:只有前面的执行完成了,后面的才会一个接一个的执行 )执行,那我们需要将运行代码改造成这样:

js 复制代码
function anonymous(param1, param2, callback) {
  const taps = this.taps;
  
  let fn0 = taps[0].fn;
  fn0(param1, param2, function (err) {
    if (err) {
      //如果运行过程中报错,则直接结束
      callback(err);
    } else {
      next0();
    }
  });

  function next0() {
    let fn1 = taps[1].fn;
    fn1(param1, param2, function (err) {
      if (err) {
        callback(err);
      } else {
        callback(); //在末尾执行最终的回调函数
      }
    });
  }
}
anonymous("嘎嘎嘎", "99", (err,result)=>"最终的回调函数");

🚖 那如何生成运行函数呢?

官方的源码中是通过 new Function() 进行创建的,先了解一下 new Function 的语法:

js 复制代码
let func = new Function ([arg1, arg2, ...argN], functionBody);
  • arg1, arg2, ... argN(参数名称):是一个有效的 JavaScript 字符串(例如:"a , b"),或者是一个字符串列表(例如:["a","b"])。
  • functionBody(函数体):可执行的JavaScript字符串。

实例:

js 复制代码
const sum = new Function("a,b", "return a + b");
console.log(sum(2, 6));
//output: 8

所以,以上面SyncHook 所需要的函数体为例:

该函数体其实可以分为两部分:

🔸 第一部分(header):获取存放着注册函数信息的数组 taps

js 复制代码
 const taps = this.taps;

🔸 第二部分(content):可以通过对 taps 进行遍历生成

js 复制代码
let fn0 = taps[0].fn;
fn0(param1, param2);

let fn1 = taps[1].fn;
fn1(param1, param2);

现在通过new Function()生成我们想要的执行函数

🔸 第一步:生成形参字符串("param1 , param2"

🔸 第二步:生成函数体中 header 部分

🔸 第三步:遍历 taps,生成 content 部分

js 复制代码
new Function(
    this.args().join(","),
    this.header() + this.content()
);

核心思路就是这些,接下来实操!!

🚖 实操

首先需要通过 tap 函数进行收集工作,并将收集到的函数格式化

js 复制代码
class SyncHook {
    constructor(args) {
        this.args = Array.isArray(args) ? args : [];// 形参列表
        this.taps = [];// 用来存放注册函数的基本信息的数组
    }
}

🔸(1) taps 的收集工作

这里分成两个小步骤,先对传入参数进行格式化。

使用 tap 的时候

js 复制代码
hook.tap({name:"监听器1",后面还可以有其他参数}, callback);

因此要格式化处理

js 复制代码
class SyncHook {
     // ...
     
     tap(option, fn) {
         // 如果传入的是字符串,包装成对象
         if (typeof option === 'string') {
             option = {
                 name: option,
             };
         }
     }
}

接着定义 tap 函数,收集注册函数信息

js 复制代码
class SyncHook {
     // ...
     
     tap(option, fn) {
         // 如果传入的是字符串,包装成对象
         if (typeof option === 'string') {
             option = {
                 name: option,
             };
         }
         
         //type=sync fn是注册函数
         const tapInfo = { ...option, type: "sync", fn};
         this.taps.push(tapInfo);
     }
}

🔸 (2)动态生成执行代码

当调用 call 方法时,会走两个关键的步骤:先动态生成执行代码,再执行生成的代码

最终我们要通过 this.taps 生成如下格式的运行代码:

js 复制代码
new Function(
  "param1 , param2",
  `  
  const taps = this.taps;
  let fn0 = taps[0].fn;
  fn0(param1, param2);
  let fn1 = taps[1].fn;
  fn1(param1, param2);
 `
);

这一步需要遍历 this.taps 数组,然后生成对应的函数体字符串,这里封装成一个函数 compiler 来做:

js 复制代码
class SyncHook {
    // 省略其他
   
    compile({args, taps, type}) {
        const getHeader = () => {
            let code = '';
            code += `var taps = this.taps;\n`
        }
        
        const getContent = () => {
            let code = '';
            for (let i = 0; i < taps.length; i++) {
                code += `var fn${i} = taps[${i}].fn;\n`;
                code += `fn${i}(${args.join(',')});\n`;
            }
            return code;
        }
        
        return new Function(args.join(","), getHeader() + getContent());
    }
}

🔸(3)执行生成的代码

这里是最后一步,定义 call 方法,然后执行生成的函数体:

js 复制代码
class SyncHook {
    // ...
    
    call(...args) {
        this._call = this.compile({
            taps: this.taps,//tapInfo的数组 [{name,fn,type}]
            args: this.args, //形参数组
            type: "sync",
        });//动态创建一个call方法 这叫懒编译或者动态编译,最开始没有,用的时候才去创建执行
        return this._call(...args);
    }
}

🍻 Tapable 在 webpack 中的应用

Webpack 的流程可以分为以下三大阶段:

🔸 初始化

启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。这个 compiler 对象户会穿行在本次编译的整个周期

🔸 编译

从 Entry 发出,,针对每个 Modules 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理

🔸 输出文件

对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

执行webpack时,会生成一个compiler实例。

js 复制代码
// node_modules/webpack/lib/webpack.js
const Compiler = require("./Compiler");
const MultiCompiler = require("./MultiCompiler");

const webpack = (options, callback) => {
        // ...省略了多余代码...
    let compiler;
    if (typeof options === "object") {
            compiler = new Compiler(options.context);
    } else {
            throw new Error("Invalid argument: options");
    }
})

可见,Compiler是继承了Tapable的。同时发现webpack的生命周期hooks都是各种各样的钩子。

js 复制代码
// node_modules/webpack/lib/Compiler.js
class Compiler extends Tapable {
    constructor(context) {
    super();
        this.hooks = {
            /** @type {AsyncSeriesHook<Stats>} */
            done: new AsyncSeriesHook(["stats"]),
            /** @type {AsyncSeriesHook<>} */
            additionalPass: new AsyncSeriesHook([]),
            /** @type {AsyncSeriesHook<Compiler>} */
            beforeRun: new AsyncSeriesHook(["compiler"]),
            /** @type {AsyncSeriesHook<Compiler>} */
            run: new AsyncSeriesHook(["compiler"]),
            /** @type {AsyncSeriesHook<Compilation>} */
            emit: new AsyncSeriesHook(["compilation"]),
            /** @type {AsyncSeriesHook<string, Buffer>} */
            assetEmitted: new AsyncSeriesHook(["file", "content"]),
            /** @type {AsyncSeriesHook<Compilation>} */
            afterEmit: new AsyncSeriesHook(["compilation"]),
            // ....
        }
    }
}

然后在初始化webpack的配置过程中,会循环我们配置的以及webpack默认的所有插件也就是plugin

js 复制代码
// 订阅在options中的所有插件
if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
        if (typeof plugin === "function") {
            plugin.call(compiler, compiler);
        } else {
            plugin.apply(compiler);
        }
    }
}

这个过程,会把 plugin 中所有 tap 事件收集到每个生命周期的 hook 中。 最后根据每个 hook 执行 call 方法的顺序(也就是生命周期)。就可以把所有 plugin

举个例子,下面是我们经常使用的热更新插件代码,它订阅了 additionalPasshook

这也就是 webpack 它工作流程能将各个插件 plugin 串联起来的原因,而实现这一切的核心就是 Tapable

🍻 参考文章

🔸 宝啊~谈谈Tapable

🔸 一文了解Webpack中Tapable事件机制 - 掘金

相关推荐
_斯洛伐克1 小时前
下降npm版本
前端·vue.js
苏十八2 小时前
前端进阶:Vue.js
前端·javascript·vue.js·前端框架·npm·node.js·ecmascript
st紫月2 小时前
用MySQL+node+vue做一个学生信息管理系统(四):制作增加、删除、修改的组件和对应的路由
前端·vue.js·mysql
乐容3 小时前
vue3使用pinia中的actions,需要调用接口的话
前端·javascript·vue.js
似水明俊德3 小时前
ASP.NET Core Blazor 5:Blazor表单和数据
java·前端·javascript·html·asp.net
至天4 小时前
UniApp 中 Web/H5 正确使用反向代理解决跨域问题
前端·uni-app·vue3·vue2·vite·反向代理
与墨学长4 小时前
Rust破界:前端革新与Vite重构的深度透视(中)
开发语言·前端·rust·前端框架·wasm
H-J-L5 小时前
Web基础与HTTP协议
前端·http·php
Amore05255 小时前
React+TS前台项目实战(二十三)-- 基于属性自定义数值显示组件Decimal封装
前端·react.js·typescript·前端框架
friklogff6 小时前
【JavaScript脚本宇宙】美化网格布局:Isotope和Masonry让你的网页焕然一新
开发语言·前端·javascript