2. 「webpack源码分析」webpack构建的基石: tapable@1.1.3源码分析

856 阅读20分钟

通过一个demo带你深入进入webpack@4.46.0源码的世界,分析构建原理,专栏地址,共有十篇。


如果你看过webpack内部的几个核心类如Compiler、Compilation等对象,会发现有大量的钩子this.hooks = {...},这些hooks让开发者可以高度参与整个构建流程,大大的提供了构建的可扩展性。这个能力是由tapable提供的。

tapable是webpack中插件能运行的基石,是webpack与开发者交流的话筒,增强了webpack基础功能。

tapable是什么

介绍tapable之前,先说下发布-订阅,关于发布订阅,维基百科的解释如下:

在软件架构中,发布-订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

image.png

上图是发布订阅模式的原理图,会引入一个中间人;订阅者向该中间人订阅事件,发布者通过中间人发布事件,中间人存在的主要目的是为了解耦发布者和订阅者,二者只需要持有中间人的引用即可。

一个简单的发布订阅的实现和测试用来说明发布订阅的一些特点

easy pub-sub pattern

通常在发布订阅模式中(如EventEmitter3),存在以下问题:

  • 订阅函数是按照订阅的顺序顺序执行并且每个订阅函数都会被执行,执行流程不可中断
  • 订阅函数是异步时不会等待该异步任务完成以后再执行后面的订阅的函数
  • 另外订阅函数之间没有逻辑关系连接,这也是导致第一点执行流程不可中断的原因
  • 发布者拿不到订阅函数的最终执行结果

但是实际业务中可能会有一些更复杂的场景,比如需要订阅函数支持异步并且异步函数的执行是严格按照顺讯执行的,上一个异步函数状态完成后才能进入下一个异步函数的执行流程中,即保证订阅的函数严格串行执行;又比如订阅的多个函数之间可能只需要其中一个满足发布者的条件则整个流程可以中断,有点像策略模式的感觉(掘金有js设计模式的小册,有提到策略模式,可以看下)从第一个策略开始直到命中一个策略,那么后面的策略也不会执行。

那么此时发布订阅就满足不了复杂场景的要求,而webpack在构建场景是比较复杂的,因此自研的tapble来提供增强版的发布订阅来支持复杂的构建场景。

所以:tapable是一种基于发布-订阅的消息范式,但是由于webpack构建场景比较复杂,因此相较于普通版的发布订阅类库其提供了很多增强特性。


下面通过介绍tapble的具体使用案例来直观的感受一下其提供了哪些增强能力。tapable 提供多种hook,每个hook提供的能力都不一样。 tapable@1.1.3

// 同步的
SyncHook, 
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
// 异步并行
AsyncParallelHook,
AsyncParallelBailHook,
// 异步串行
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook,
AsyncSeriesLoopHook

这些hook名称中可以看出其具有哪些特性,我们将这些关键词提取出来并分类

  • SyncAsync:订阅的函数是同步的还是异步的,这里的异步支持两种形式:callback 、promise
  • ParallelSeries:多个订阅函数是并行执行还是串行执行,当然同步函数没有并行这一说,所以有AsyncParallelXxxHook而没有SyncParallelXxxHook
  • BasicBailWaterfallLoop:根据每个订阅函数执行完成返回的结果进行一些判断或者添加一些逻辑后再选择进入下一个订阅函数的执行或退出

tapbale中提供的hook有意思的地方在于当我在调用的时候才会通过new Function去动态生成执行代码。我们通过一些案例来研究下各特性之间的区别和联系,以发现这个特性在源码中是如何处理的以及为什么这么处理。


具体看各特性之前先简单介绍下整体的类组织关系以及各类的作用:

image.png

  • 两个大类:hook类和codeFactory,其中hook类提供注册和触发的能力,codeFactory类用来动态生成执行代码;具体的xxxHook继承Hook,每个具体的XxxHook都会有与之关联的XxxCodeFactory
  • hook类中的tapXxx用来注册订阅函数,callXxxpromise用来发布事件(触发订阅函数的执行),发布方法实际会调用_createCall而后调用子类(继承Hook)的compile方法来生成匿名函数,compile方法持有该hook对应的codeFactory(具体子类的差异化逻辑交个子类的compile方法),而后调用该codeFactory父类HookCodeFactroy中create来生成代码,差异化处理交给了子类的content方法

下面分别按照大的特性分类来分析。

1. Sync | Async

首先订阅函数可以是同步函数也可以是异步,并且异步函数支持callback和promise两种形式;

SyncXxxHook

用法:使用tap方法来进行订阅,通过call方法来触发事件

const synHook = new SyncHook(['arg']); // call时需要传递两个参数
// 订阅函数是同步函数,如果是异步函数,则只会同步执行
synHook.tap('test', (arg) => console.log('1', arg) )
synHook.tap('test', (arg) => console.log('2', arg) )
// 触发订阅函数执行
synHook.call('parameter');

调用call方法时动态生成的执行代码如下

image.png

每个订阅函数都会生成同步执行代码顺序执行,同步代码的状态直接取决于这个同步函数的执行过程是否出错,如果没有出错直接进入下一个订阅函数的执行。如果出错但没有捕获则执行过程中断。

每个订阅函数生成代码逻辑(几乎)完全一致,在添加某些其他特性下有些许差别,具体差别后面会再说。

AsyncXxxHook: callback | promise

  • callback形式的异步订阅函数
// callback形式的异步订阅函数: 用法:tapAsync(订阅) - callAsync(触发) 
const asyncSeriesHook = new AsyncSeriesHook(['arg']);
// 订阅函数需要一个接受一个回调,将当前订阅函数的执行结果返回给执执行流
asyncSeriesHook.tapAsync('test', (arg, callback) => setTimeout(() => { callback(null, 1) }, 1000))
asyncSeriesHook.tapAsync('test', (arg, callback) => setTimeout(() => { callback(null, 2) }, 1000))
// 关键,提供一个回调,当所有的订阅函数执行完成后(整个流程结束后)来感知流程是否结束(包括接收最终的结果,异常判断等)
asyncSeriesHook.callAsync('arg-test',(err, result) => console.log('执行结束'))
  • promise形式的异步订阅函数
// promise形式的异步订阅函数:tapPromise(订阅) - promise(触发):
const asyncSeriesHookPromise = new AsyncSeriesHook(['arg']);
// 订阅函数需要返回一个promise
asyncSeriesHookPromise.tapPromise('test', () => new Promise(resolve => setTimeout(() => { resolve(1) }, 1000)))
asyncSeriesHookPromise.tapPromise('test', () => new Promise(resolve => setTimeout(() => { resolve(2) }, 1000)))

// 返回一个promise来接受整个执行流的最终状态
const p = asyncSeriesHookPromise.promise();

const resolveHandler = () => { console.log('resolved') };
const rejectHandler = () => { console.log('rejected') }
p.then(resolveHandler, rejectHandler)

callAsync() & promise() 动态生成的代码如下:

image.png

异同点\函数形式 callback promise
差异点 每个订阅函数都会生成callback形式的执行代码即生成的代码中会传入一个回调函数给订阅函数,然后在订阅函数中来执行这个回调,通过回调实现异步状态的流转。所以callback形式的异步的关键在于生成的代码中会提供 一个回调函数给到订阅函数。 每个订阅函数都会返回一个promise,生成的代码中通过该promise来实现异步状态流转决定进入下一个订阅函数的执行还是抛出异常。
相同点 看到每一个订阅函数生成的主代码几乎完全一致。 差别在于 _fn0不是最后一个订阅函数,因此其执行完成并且没有出错的情况下执行_next()即进入下一个订阅函数的执行。 而_fn1是最后一个订阅函数,其执行完成后直接调用发布者传递的回调(callAsync传递的函数)或者直接resolve()来结束整个执行流。

小结

其实可以可以看到,每一个订阅函数都会生成各自的代码片段,核心就是为了支持自身状态的正确流转,比如成功则进入下一个订阅函数的执行,出错则结束流程。

这里涉及到两个部分:最终生成的匿名函数的整体结构单个订阅函数生成的执行代码

整体结构

其中由于整体结构一致,统一收敛到抽象类HookCodeFactory.create方法中,当然也会区分syncasyncpromise类型,比如async(callback形式的异步)形式会给生成的匿名函数添加一个callback参数用来接收发布者传递进来的回调,promise(promise形式的异步)需要在外围添加new Promise()返回给发布者。

// HookCodeFactory.js
create(options) { 
   this.init(options);
   let fn;
   switch (this.options.type) {
      case "sync":
         fn = new Function(...);
         break;
      case "async":
         fn = new Function(...);
         break;
      case "promise":
         //...
         fn = new Function(this.args(), code);
         break;
   }
   //...
   return fn;
}

看到通过switch-case来区分订阅函数类型整体结构模板,如下表提供了模板与上述案例生成代码的对照关系:

类型\结果整体结构(模板)(上述)demo生成代码
syncimage.pngimage.png
asyncimage.pngimage.png
promiseimage.pngimage.png

通过this._args()生成匿名函数形参列表;this.header()来获取头部公共部分逻辑比如var _x = this._x用来保存订阅函数列表;this.content()来实现差异化的处理,交给具体的子类实现,以SyncHook为例,如下。

class SyncHookCodeFactory extends HookCodeFactory { // 继承父类
    // content实现差异化的关键,由子类实现
   content({ onError, onDone, rethrowIfPossible }) {
       // callTapsSeries内部会调用HookCodeFactory.callTap生成单个订阅函数执行代码
      return this.callTapsSeries({ 
         onError: (i, err) => onError(err),
         onDone,
         rethrowIfPossible
      });
   }
}

const factory = new SyncHookCodeFactory();

class SyncHook extends Hook {
   //...
   // 当调用 call、callAsync、promise时,实际会调用父类Hook.lazyCompileHook
   // 第一步:调用下面的compile方法生成一个匿名函数代码
   //(调用XxxCodeFactory.create,create定义在父类HookCodeFactory中,
   // create会再调用子类content方法实现差异化处理)
   // 第二步:执行生成的匿名函数
   compile(options) {
      factory.setup(this, options);
      return factory.create(options);
   }
}

单个订阅函数生成的执行代码

对于sync,async,promise中的每一类的单个订阅函数生成执行代码的主体逻辑也是一致的,比如promise形式的订阅函数都需要接收订阅函数返回的promise,并在该promise上添加成功或者失败的回调。由于这样的性质,统一收敛在了HookCodeFactory.callTap方法上;

上面的HookCodeFactory.create方法提供了onErroronResultonDone等默认值,本小结的两个案例中的SyncHook和AsyncSeriesHook默认继承这几个值,并且由于这两个属于Basic场景没有用到onResult,用的是onDone(二者是互斥的,只会用到一个,后面会分析到)。

// HookCodeFactory.js
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
   let code = "";
   // ...
   code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
   const tap = this.options.taps[tapIndex];
   switch (tap.type) {
      case "sync":
         //...
         break;
      case "async":
         //...
         break;
      case "promise":
          //...
         break;
   }
   return code;
}

针对这两个hook的单个函数的执行模板如下表:

type\contentonDone的默认值onDone下的模板demo生成代码
syncimage.pngimage.pngimage.png
asyncimage.pngimage.pngimage.png
promiseimage.pngimage.pngimage.png

onDone下的模板和onResult的模板略有差异,这里的两个Hook都是使用onDone下的模板。将onDone的默认值带入onDone下的模板中,就可以得到单个订阅函数的生成代码

2. Basic | Bail | Waterfall | Loop

  • Xxx(Basic)Hook: 类似EventEmitter这类发布订阅库实现的效果是执行每一个订阅函数,但不关心函数的执行结果;
  • XxxBailHook: 相较于BasicHook,会对每个订阅函数的执行结果进行判断,如果是undefined则进入下一个订阅函数的执行,否则直接结束后面流程将值返回给发布者;
  • XxxWaterfallHook: 相较于BasicHook,同样会对每个订阅函数的执行结果进行判断, 如果不是undefined,则将该结果作为下一个订阅函数的第一个入参传递
  • XxxLoopHook: 同样会对每个订阅函数的执行结果进行判断,如果当前订阅函数返回结果是undefined则继续执行下一个订阅函数,不为undefined则跳转至第一个订阅函数从头开始执行。这样一直循环直至所有的订阅函数的返回结果均为undefined。另外该特性依赖Series特性,因此有SyncLoopHook和AsyncSeriesLoopHook,没有AsyncParallelLoopHook。

根据上面的特性描述,看到tapable提供的hooks可以根据订阅函数的执行结果的不同来判断后面的流程,是终止还是接着走,又或者将上一个订阅函数的执行结果传作为下一个订阅函数的入参等。

以SyncXxxHook为例对比这四个特性的表现和实现。

const synHook = new SyncHook(['arg']);
synHook.tap('test', (arg) => console.log('1', arg) )
synHook.tap('test', (arg) => console.log('2', arg) )
synHook.call('parameter');

const synBailHook = new SyncBailHook();
synBailHook.tap('test', ()=>  console.log(1))
synBailHook.tap('test', ()=>  console.log(2))
synBailHook.call();

const synWaterHook = new SyncWaterfallHook(['arg1']);
synWaterHook.tap('test', ()=>  console.log(1))
synWaterHook.tap('test', ()=>  console.log(2))
synWaterHook.call('parameter');

const synLoopHook = new SyncLoopHook();
synLoopHook.tap('test', ()=>console.log(1))
synLoopHook.tap('test', ()=>console.log(1))
synLoopHook.call()

下述表格,每行对应一个特性,通过生成的代码可以看到和逻辑图显示逻辑一致

type\content逻辑图(抽象)上面案例的生成代码
Basicimage.pngimage.png
Bailimage.pngimage.png
Waterfallimage.pngimage.png
Loopimage.pngimage.png

上一节关于Sync/Async特性时有说到HookCodeFactory.create提供了默认的onResultonDone等值,Sync(Basic)Hook特性依然继承自默认值,在这里SyncBailHookSyncWaterfallHookSyncLoopHook等子类中根据该hook自身特性提供了自己的onResult

HookCodeFactory.create对于sync类型的hook提供的onResult、onDone等默认值

// ...
onResult: result => `return ${result};\n`, // Bail会调用这个默认值返回结果
onDone: () => "",  // <=>  ()=> return ""

HookCodeFactory.createTap中提供的关于sync类型hook的模板

// onResult情况下的模板
var _result${tapIndex} = _fn${tapIndex}(${this.args(/*..参数.*/)});
${onResult(`_result${tapIndex}`)}

// onDone情况下的模板
// Sync(Basic)Hook,没有提供onResult,会调用这个版本
`_fn${tapIndex}(${this.args(/*..参数.*/)})
${onDone()}`
type\content具体子类content()的实现解释
Sync(Basic)Hookimage.pngcallTapSeries中会调用callTap生成单个函数执行代码;Basic没有提供onResult逻辑,因为不需要关心订阅函数的执行结果,使用onDone情况下的模板
SyncBailHookimage.png通过提供onResult来生成上下事件函数之间的衔接代码,如果返回的值是undefined则直接执行下一个,不为undefined则结束执行流程,返回结果给调用者。另外里面的onResult是由HookCodeFactory.create提供的默认值:result => return ${result};
SyncWaterfallHookimage.png同样,我们看到onResult中逻辑:判断当前订阅函数返回的结果如不是undefined,则将该结果作为下一个订阅函数的第一个入参,其中 this._args() 获取第一个形参的名称,将结果保存下来,见上面示例生成的代码
SyncLoopHookimage.pngLoop的实现相对来说复杂些,需要在callTapsSeries外面添加循环的逻辑,因此HookCodeFactory提供了callTapsLooping,来添加外侧do-while的逻辑,而内部串行的逻辑依然交给callTapsSeries

其他部分细节:

  • WaterfallBasic的差别是,会将上一个订阅函数的返回值(不是undefined)当做下一个订阅函数的第一个入参。Waterfall的这种特性要求每个订阅函数有一个入参,因此在XxxWaterfallHook的构造函数中会要求传递至少一个参数;
  • loop特性在最外层添加了do-while代码片段,看起来是加在了content外层的部分则认为可以在HookCodeFactory.create里面添加外围部分就像Sync|Async中看到Promise外面也添加一部分外围代码。实际上是不行的,因为create只感知sync、async、promise不感知loop这类性质,只应该提供这三个特性相关的公共逻辑,因此加不了。所以单独引入了一个方法callTapsLooping,来添加这部分逻辑。但是单个订阅函数后面的逻辑依然是用到了callTapsLooping提供的onResult被callTap调用生成衔接代码;

小结

  • 首先我们看到这四个Hook都调用了callTapsSeries方法,该方法是起到将多个订阅函数的执行代码串起来的作用,后面小结会具体分析该方法
  • 另外我们看到BailWaterfallLoop等特性都需要根据订阅函数的执行结果进行一些判断并提供了onResult用来接收执行结果生成相邻订阅函数的衔接逻辑。而Basic不要求获取执行结果只提供了onDone。可以明显感受到onResult的作用,以及和onDone的差异。

3. Parallel | Series

标题Parallel: 并行执行Series: 串行执行
订阅函数执行逻辑image.pngimage.png

上面提到的两类特性Sync|Async, Basic|Bail|Waterfall|Loop都是和单个订阅函数的执行代码生成有关主要是HookCodeFacory.callTap方法中。

  • Sync|Async特性由callTap中的switch-case来定义sycn、async、promise的模板,该模板中会预留onResult和onDone插槽,具体的逻辑交给各具体的子类实现。
  • Basic、Bail、Waterfall、Loop特性的差别由子类中的onResult决定,然后callTap调用onResult生成差异代码。

而这里的Parallel和Series主要和多个订阅函数间执行关系有关:并行 or 串行。并且 。此外对于多个同步函数来说只能串行执行,所以这里的特性都是针对异步订阅函数的。

下面我们具体看下内部串行和并行是如何设计和实现的

Series

这个特性实际上需要区分同步和异步,异步需要在回调里面去调用下一个订阅函数的执行,而同步则不需要,因为同步默认就是串行也只能是串行;同步的钩子名称省略了该关键词,实际是Sync(SeriesXxx)Hook

标题AsyncSeries(Basic)HookSync(SeriesBasic)Hook
生成的代码image.pngimage.png
初步解释先生成_fn1的代码,然后封装到_next0中;当生成_fn0代码的时候,需要知道下一步该何去何从,在这就是执行_next0。显然_next0只是为了封装,没有封装的化,则代码无限嵌套会出现回调地域现象方案1:直接按顺序生成每个订阅函数的逻辑就行了;方案2:倒序遍历先生成_fn1的代码,当生成_fn0的逻辑后在其后面追加_fn1代码,这里为了统一和异步逻辑的生成方式,采用了方案2

看下具体的源码实现

Sync(SeriesBasic)Hook AsyncSeries(Basic)Hook
源码解读
解释 同步订阅函数忽略虚线部分的代码;调用callTap时,SyncHook没有传递onResult,这里onDone生效,实际走的done,而done在初始化的时候是HookCodeFactory.create提供的默认值: () => "" ,之后done就是下一个订阅函数生成的代码如_fn1,然后看下callTap使用onDone的模板,显然就得到上面SyncHook生成的代码 同样调用callTap时,AsyncSeriesHook没有传递onResult,这里onDone生效,实际走的done,而done在初始化的时候是HookCodeFactory.create提供的默认值: () => "_resolve();" ,所以我们看到上面的最后一个订阅函数即_fn1的then回调调用_resolve();之后的done实际指向了虚框内逻辑中生成的current即 _next${i}(); 所以我们看到_fn0的then回调中调用_next0; 再看下callTap使用onDone的模板,显然就得到上面AsyncSeriesHook生成的代码

实际上由于javascript提供Function Hoisting,顺序遍历(比如这里让_next0在_fn0的后面)也是行得通的。不过得是函数声明的形式,如果是函数表达式则不行。

另外callTapSeries调用callTap的实参中看到,onResult和onDone只会有一个生效。在这里的两个Basic特性的hook中,子类都没有提供onResult,如下SyncHook。

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

这里其实看到onResultonDone的区别和用途,首先二者都是传递给callTap的参数,当需要根据当前订阅函数的执行结果进行一些判断时(如XxxBailHook等等)就传递onResult,实际上onResult是在onDone增强即添加一些条件判断,在各子类Hook中如果提供了onResult,其内部一定会调用onDone(这也解释了为什么调用callTap时二者只需其一);这一点在callTapsSeries传入的onResult也能看出,传递done(实际是传递给callTap的onDone)给子类提供的onResult函数。

以SyncBailHook为例再验证下上面的关于onResult和onDone的说法,见下图:

image.png

Parallel

当然异步才有资格谈并行,即同时执行多个异步订阅函数,并在回调中判断是否所有的订阅函数都执行完成。该特性相关的有AsyncParallelHook、AsyncParallelBailHook,这里以AsyncParallel(Basic)Hook为例介绍Parallel特性。

demo如下:

const asyncParallelHookPromise = new AsyncParallelHook();
asyncParallelHookPromise.tapPromise('test', () => new Promise(resolve => setTimeout(() => { resolve(1) }, 1000)))
asyncParallelHookPromise.tapPromise('test', () => new Promise(resolve => setTimeout(() => { resolve(2) }, 1000)))
const p1 = asyncParallelHookPromise.promise();

生成的代码,简化后如下:

(function anonymous() {
    "use strict";
    return new Promise((_resolve, _reject) => {
        // ...
        var _x = this._x;
        do {
            var _counter = 2; // 一共有两个异步函数
            // ...
            var _fn0 = _x[0];
            var _promise0 = _fn0();
            _promise0.then(_result0 => {
                // ...
                // if (--_counter === 0) _resolve(); 
            });

            var _fn1 = _x[1];
            var _promise1 = _fn1();
            _promise1.then(_result1 => {
                // ...
                // if (--_counter === 0) _resolve();
            });
        } while (false);
    });
})

上面看到实现该能力的核心是,并发执行所有的异步函数,增加计数器,每个异步订阅函数执行完成以后计数器减一,减至为0时则完成整个执行过程。并行和串行的逻辑差异较大,串行需要考虑上下相邻的订阅函数的衔接,但串行不用,因此HookCodeFactory单独提供了生成并行逻辑的方法callTapsParallelimage.png 忽略这里do-while,这里没什么作用,猜测可能为了以后扩展loop特性预留的。过程如下:

  • 外围添加了计数器相关逻辑,当前是Basic特性,没有onResult,使用onDone,看到回调中将计数器减一然后判断是否为0.
  • 一个for循环,顺序生成每个订阅函数的执行代码
  • 同样是调用callTap传递onResult、onDone生成单个订阅函数的执行代码。

小结

  • 相同点:看到callTapsSeriescallTapsParallel的主要结构都是引入一个for循环遍历所有的订阅函数,并在for循环内部调用callTap为每一个订阅函数生成执行代码
  • 差异点:callTapsSeries生成的每个订阅函数有严格的执行顺序,上一个订阅函数执行完完成以后才会进入执行第二个订阅函数的执行逻辑中;而callTapsParallel生成的各订阅函数的执行逻辑中没有严格的执行顺序,这些订阅函数只有统一的终点就是当所有的订阅函数执行完成或有任何订阅函数返回非undefined的结果(前者是Baisc特性,后者是Bail特性)

总结

从上面给出的demo中首先能够看到提供的多种多样的hook具备了很多特性,可以满足很多复杂的场景。

  • HookCodeFactory.create & HookCodeFactory.callTap

    • HookCodeFactory.create 根据sync、async、promise构造生成匿名函数的整体结构
    • HookCodeFactory.callTap 根据sync、async、promise生成单个订阅函数相关代码片段
  • Basic、Bail、Waterfall、loop:主要区别在于相邻订阅函数的衔接,由子类提供的onResult来实现这几个特性。

  • callTapsSeries、callTapsParallel:根据多个订阅函数的执行顺序来将callTap生成的代码连接起来

    • callTapsSeries:严格串行执行
    • callTapsParallel:并行