通关Tapable2.3

75 阅读6分钟

Tapable是什么

Tapable是Webpack的核心模块之一, 基于发布订阅模式, 提供了一套钩子(Hook)机制, 用于控制webpack的事件流和插件系统. 通过Tapable,插件可以在 webpack 构建过程中的特定时机注册并执行自定义逻辑,从而实现灵活的流程控制。

核心概念

  • 钩子 (Hook) :Tapable 提供了多种类型的钩子,如 SyncHook(同步钩子)、AsyncSeriesHook(异步串行钩子)等,每种钩子都有不同的事件执行机制。
  • 事件注册 (tap) :插件通过 tap 方法向钩子注册监听函数,这些函数将在钩子被触发时按顺序执行。
  • 事件触发 (call) :通过 call 方法触发钩子,从而按顺序执行所有已注册的监听函数。 

工作原理

  1. 实例化钩子:在 webpack 的某个对象上,会实例化一个 Tapable 的钩子,例如 new SyncHook(['name'])
  2. 事件注册:插件使用 tap 方法注册一个函数到这个钩子上,例如 hook.tap('myPlugin', (name) => console.log(name))
  3. 事件触发:在 webpack 的构建过程中,当该钩子的时机到达时,会调用 hook.call('value') 方法。
  4. 函数执行call 方法会按顺序执行所有已注册的函数,并将传入的参数传递给这些函数

按执行模式分类

  1. Basic Hook: 基本类型的钩子,它仅仅执行钩子注册的事件,并不关心每个被调用的事件函数返回值如何。
graph LR
Start --> 事件1 --> 事件2 --> 事件3 -->Stop
  1. Waterfall: 瀑布类型的钩子,瀑布类型的钩子和基本类型的钩子基本类似,如果前一个事件函数的结果 result !== undefined,则 result 会作为后一个事件函数的第一个参数
graph LR
Start --> 执行函数fn1返回result1 --传递参数result1--> 执行函数fn2参数为result1返回undefined --传递参数result1--> 执行函数fn3参数result1 -->Stop
  1. Bail:保险类型钩子,保险类型钩子在基础类型钩子上增加了一种保险机制,执行每一个事件函数,遇到第一个结果 result !== undefined 则返回,整个钩子执行过程会立即中断,之后注册事件函数就不会被调用了。
graph LR
Start --> 事件1 --> conditionA{result!==undefined} 
conditionA--Y-->Stop
conditionA--N--> 事件2 --> Stop
  1. Loop:循环类型钩子通过 call 调用时,如果任意一个注册的事件函数返回值非 undefeind ,那么会立即重头开始重新执行所有的注册事件函数,直到所有被注册的事件函数结果 resule === undefined。
graph LR

Start --> 事件1--> conditionA{result1!==undefined} 
conditionA--Y-->Start
conditionA--N--> 事件2
事件2 --> conditionB{result2!==undefined} 
conditionB--Y-->Start
conditionB--N--> 事件3
事件3 -->Stop
  1. Parallel并,有点类似于Promise.all,就是当一个hook注册了多个回调方法,这些回调同时开始并行执行。

  2. Series串行,就是当一个hook注册了多个回调方法,前一个执行完了才会执行下一个。

ParallelSeries的概念只存在于异步的hook中,因为同步hook全部是串行的。

10个钩子

同步:

  1. SyncHook, # 同步钩子
  2. SyncBailHook, # 同步熔断钩子
  3. SyncWaterfallHook, # 同步流水钩子
  4. SyncLoopHook, # 同步循环钩子

异步并发:

  1. AsyncParallelHook, # 异步并发钩子
  2. AsyncParallelBailHook, # 异步并发熔断钩子

异步串行

  1. AsyncSeriesHook, # 异步串行钩子
  2. AsyncSeriesBailHook, # 异步串行熔断钩子
  3. AsyncSeriesWaterfallHook # 异步串行流水钩子
  4. AsyncSeriesLoopHook # 异步串行循环钩子

tapable使用

使用简单来说就是下面步骤

  1. 实例化构造函数 Hook new SyncHooK
  2. 注册tap(一次或者多次)
  3. 执行call(传入参数)
  4. 如果有需要还可以增加对整个流程(包括注册和执行)的监听-拦截器

SyncHooks示例

tap接收两个参数: 第一个仅仅是一个注释的作用,第二个参数就是一个回调函数,

const { SyncHook } = require("tapable");

// 实例化一个hook
const accelerate = new SyncHook(["age"]);

// 注册第一个回调

accelerate.tap("fun1", (age) =>
  console.log("fun1", `age${age}`)
);

// 再注册一个回调
accelerate.tap("fun2", (age) => {
  if (age > 18) {
    console.log("fun2", "已经成年!");
  }
});

// 再注册一个回调
accelerate.tap("fun3", (age) => {
  if (age > 60) {
    console.log("fun3", "退休吧。。。");
  }
});

// 触发事件
accelerate.call(66);

//------输出------ 
fun1 age66
fun2 已经成年!
fun3 退休吧。。。

SyncBailHook

const { SyncBailHook } = require("tapable");

// 实例化一个hook
const accelerate = new SyncBailHook(["age"]);

// 注册第一个回调

accelerate.tap("fun1", (age) =>
  console.log("fun1", `age${age}`)
);

// 再注册一个回调
accelerate.tap("fun2", (age) => {
  if (age > 18) {
    console.log("fun2", "已经成年!");
    return new Error('已经成年!');
  }
});

// 再注册一个回调
accelerate.tap("fun3", (age) => {
  if (age > 60) {
    console.log("fun3", "退休吧。。。");
  }
});

// 触发事件
accelerate.call(66);

//------输出------ 
fun1 age66
fun2 已经成年!

SyncWaterfallHook

const { SyncWaterfallHook } = require("tapable");

// 实例化一个hook
const accelerate = new SyncWaterfallHook(["age"]);

// 注册第一个回调

accelerate.tap("fun1", (age) => {
  console.log("fun1", `age${age}`);
  return "fun1";
});


// 再注册一个回调
accelerate.tap("fun2", (data) => {
  console.log("fun2参数:", data)
});

// 再注册一个回调
accelerate.tap("fun3", (data) => {
  console.log("fun3参数", data)
  return 'fun3';
});

// 触发事件
const lastPlugin = accelerate.call(66);

console.log(`最后一个插件是:${lastPlugin}`);

//------输出------ 
fun1 age66
fun2参数: fun1
fun3参数: fun1
最后一个插件是: fun3

SyncLoopHook

const { SyncLoopHook } = require("tapable");

const accelerate = new SyncLoopHook(["person"]);

// Use an object so taps can mutate age across loop iterations
const person = { age: 0 };

accelerate.tap("ageChecker", (p) => {
    // When age <= 5, print it
    console.log(`age: ${p.age}`);
    p.age++;
    if (p.age <= 5) {
            return true;
    }

});

accelerate.call(person);

// 输出
age: 0
age: 1
age: 2
age: 3
age: 4
age: 5

AsyncParallelHook示例

异步: tapAsync和callAsync

const { AsyncParallelHook } = require("tapable");

const accelerate = new AsyncParallelHook(["age"]);

console.time("total time"); // 记录起始时间

// 注意注册异步事件需要使用tapAsync
// 接收的最后一个参数是done,调用他来表示当前任务执行完毕
accelerate.tapAsync("fun1", (age, done) => {
  setTimeout(() => {
    console.log("fun1", `age${age}`);

    done();
  }, 1000);
});

accelerate.tapAsync("fun2", (age, done) => {
  setTimeout(() => {
    if (age > 18) {
      console.log("fun2", "成年");
    }
    done();
  }, 2000);
});

accelerate.tapAsync("fun3", (age, done) => {
  // 2秒后检测是否损坏
  setTimeout(() => {
    if (age > 60) {
      console.log("fun3", "退休吧。。。");
    }

    done();
  }, 2000);
});

accelerate.callAsync(66, () => {
  console.log("任务全部完成");
  console.timeEnd("total time"); // 记录总共耗时
});


//------输出------ 
fun1 age66
fun2 成年
fun3 退休吧。。。
任务全部完成
total time: 2.005s

AsyncParallelHook示例 异步:tapPromise和promise

const { AsyncParallelHook } = require("tapable");

const accelerate = new AsyncParallelHook(["age"]);

console.time("total time"); // 记录起始时间

// 注意注册异步事件需要使用tapPromise
// 回调函数要返回一个promise
accelerate.tapPromise("fun1", (age) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("fun1", `age${age}`);

      resolve();
    }, 1000);
  });
});

accelerate.tapPromise("fun2", (age) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (age > 18) {
        console.log("fun2", "成年");
      }
      resolve();
    }, 2000);
  });
});

accelerate.tapPromise("fun3", (age) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (age > 60) {
        console.log("fun3", "退休吧。。。");
      }

      resolve();
    }, 2000);
  });
});

// 触发事件使用promise,直接用then处理最后的结果
accelerate.promise(66).then(() => {
  console.log("任务全部完成");
  console.timeEnd("total time"); // 记录总共耗时
});

//------输出------ 
fun1 age66
fun2 成年
fun3 退休吧。。。
任务全部完成
total time: 2.005s

还可以tapAsync和tapPromise混用

tapable实现

简单实现SyncHooks

class SyncHook {
	constructor(args = []) {
		this._args = args;
		this.taps = [];

	}

	tap(name, fn) {
		this.taps.push(fn);
	}

	call(...args) {
		for (const fn of this.taps) {
			const res = fn(...args);
                        
                        // if( res !== undefined) return res; // SyncBailHook
		}
	}
}
module.exports = {
	SyncHook
};

tapable源码考虑了哪些问题

  1. 由于在 webpack 打包构建的过程中,会有上千(数量其实是取决于自身业务复杂度)个插件钩子执行,同时同类型的钩子在执行时,函数参数固定,函数体相同,因此 tapable 针对这些业务场景进行了相应的优化。
  2. tapable 采用 new Function 动态生成函数执行体的方式,同时兼顾效率(缓存)以及灵活性(容易扩展,能支持传入数量不固定的一组方法)
  3. tapable 最主要的源码在 Hook.js 以及 HookCodeFactory.js中。Hook.js 主要是提供了 taptapAsynctapPromise等方法,每个 Hook 都在构造函数内部调用 const hook = new Hook()初始化 hook 实例。HookCodeFactory.js 主要是根据 new Function 动态生成函数执行体。
  4. 为什么需要CALL_DELEGATE? CALL_DELEGATE 动态生成函数执行体。并且 this.call 被设置成 this._createCall 的返回值缓存起来,如果 this.taps 改变了,则需要重新生成。 此时如果我们第二次调用 testhook.call 时,就不需要再重新动态生成一遍函数执行体
  5. 为什么每次调用 testhook.tap() 注册插件时,都需要重置this.call等方法? 如果我们调用了n次 testhook.call,然后又调用 testhook.tap 注册插件,此时 this.call 已经不能重用了,需要再根据 CALL_DELEGATE 重新生成一次函数执行体,
  6. 为什么需要在构造函数中绑定 this.compile this.tapthis.tapAsync 以及this.tapPromise等方法, 实际上这和 V8 引擎的 Hidden Class 有关,通过在构造函数中绑定这些方法,类中的属性形态固定,这样在查找这些方法时就能利用 V8 引擎中 Hidden Class 属性查找机制,提高性能。
  7. 源码中还加入了拦截器

tapable源码思想

tapable源码分步拆解

tapable源码地址