tapable原理详解,你真的不学起来吗?

92 阅读3分钟

前言

学习tapable的实现原理之前,我们先来回顾下SyncHookSyncBailHook的执行机制,通过他们的运行机制和实现上的共同点,反推一下他们的实现。

image.png

SyncHook vs SyncBailHook

SyncHook钩子按照注册函数的注册顺序执行,其实现就是最基础的发布订阅模式,SyncHook钩子拥有一个订阅者仓库,当其被触发后,会循环仓库,并依次调用订阅者,实现其钩子的逻辑!

class SyncHook {
  constructor(args = []) {
    this._args = args //
    this.taps = [] // 订阅者仓库
  }

  // 入仓
  tap(name, fn) {
    this.taps.push(fn)
  }
  
  // 触发
  call(...args) {
    const tapsLength = this.taps.length
    // 依次回调
    for (let i = 0; i < tapsLength; i++) {
      const fn = this.taps[i]
      fn(...args)
    }
  }
}

SyncBailHook的执行顺序与基本类型钩子一致,不同的是其加了一层保险逻辑,即如果任意一个钩子函数的返回值为非undefined,整个钩子的执行过程会立即中断,之后注册的钩子函数将不会再执行!

class SyncBailHook {
    constructor(args = []) {
        this._args = args;       
        this.taps = [];          
    }
    tap(name, fn) {
        this.taps.push(fn);
    }

   
    call(...args) {
        const tapsLength = this.taps.length;
        for(let i = 0; i < tapsLength; i++) {
            const fn = this.taps[i];
            const res = fn(...args);
            // 检测每个注册函数的返回值,如果!==undefined就终止执行
            if( res !== undefined) return res;
        }
    }
}

通过观察SyncHookSyncBailHook的实现,我们发现两种钩子都使用了订阅者模式,唯一的不同之处在于钩子被触发时,回调函数之间的执行逻辑不一致。这样我们可以来进行第一步抽象,将两种钩子的发布订阅模式做为基类抽取出来,以减少重复的代码!

// 重写子类call方法,生成并调用回掉函数
const CALL_DELEGATE = function(...args) {
	this.call = this._createCall();
	return this.call(...args);
}

class Hook {
    constructor(args = []) {
        this._args = args;       
        this.taps = [];
        // 回调函数
        this.call = CALL_DELEGATE;
    }

    // 抽象接口compile,由子类实现,根据不同钩子的执行逻辑生成回调函数
    compile(options) {
      throw new Error("Abstract: should be overridden");
    }
    
    // 入仓
    tap(name, fn) {
        this.taps.push(fn);
    }
    
    // 调用子类compile生成回调函数
    _createCall() {
        return this.compile({
            taps: this.taps,
            args: this._args,
        })
    }
}

我们简单总结一下基类Hook,我们重点关注下基类和子类的职责即可:

  1. 基类抽取子类SyncHookSyncBailHook的重复逻辑,实现了发布订阅逻辑
  2. 基类抽象了compile方法,该方法用来实现生成钩子函数的回调函数(不同钩子由于执行机制不同,所以由注册函数生成回调函数的逻辑也不同,所以其实现逻辑交由子类实现)

image.png

基于基类hook,我们来看下SyncHookSyncBailHook的实现

SyncHook实现了基类的compile方法,compile定义了回调函数的生成逻辑,当调用钩子函数的call方法后,其会动态的生成钩子的回调函数并执行。

具体的执行逻辑如下:

  1. 首先是执行new SyncHook(),里面会执行Hook的构造函数
  2. Hook构造函数会给this.call赋值为CALL_DELEGATE,定义发布函数。
  3. new SyncHook()继续执行,覆写hook.complie方法。
  4. 当用户调用hook.call的时候,执行this._createCall()this.complie()生成回调函数并调用
const Hook = require('./Hook');

function SyncHook(args = []) {
    const hook = new Hook(args);
    hook.constructor = SyncHook;

    // 重写compile函数
    hook.compile = function(options) {
        // 这里call函数的实现跟前面实现是一样的
        const { taps } = options;
        const call = function(...args) {
            const tapsLength = taps.length;
            for(let i = 0; i < tapsLength; i++) {
                const fn = this.taps[i];
                fn(...args);
            }
        }

        return call;
    };
    
	return hook;
}

SyncHook.prototype = null;

接下来我们看下SyncBailHook的实现,道理是一样的,只是其complie实现不一样,也就是其回调函数的生成逻辑不一样

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

function SyncBailHook(args = []) {
    // 基本结构跟SyncHook都是一样的  
    const hook = new Hook(args);
    hook.constructor = SyncBailHook;
    // 只是compile的实现是Bail版的
    hook.compile = function(options) {
        const { taps } = options;
        const call = function(...args) {
            const tapsLength = taps.length;
            for(let i = 0; i < tapsLength; i++) {
                const fn = this.taps[i];
                const res = fn(...args);

                if( res !== undefined) break;
            }
        }

        return call;
    };
    
	return hook;
}

SyncBailHook.prototype = null;

工厂模式-抽象complie函数

上面我们通过对SyncHookSyncBailHook的抽象,提炼出了一个基类Hook,减少了重复代码。基于这种结构,子类需要实现的就是complie方法,下面我们将SyncHookSyncBailHookcomplie方法拿出来对比下看看会有什么发现:

download.jpg

通过观察,发现SyncHookSyncBailHookcomplie方法也非常像,有大量重复代码,所以tapable为了解决这些重复代码,又进行了一次抽象,也就是代码工厂HookCodeFactory

HookCodeFactory的作用就是用来生成complie返回的call函数体,而HookCodeFactory在实现时也采用了Hook类似的思路,也是先实现了一个基类HookCodeFactory,然后不同的Hook再继承这个类来实现自己的代码工厂,比如SyncHookCodeFactory。在学习HookCodeFactory之前,我们先来了解下new Function()方法。

你知道new Function()方法吗?譬如通过下面的方式,我们也可以创建一个函数:

const add = new Function('a', 'b', 'return a + b;');
add(1, 2) // 结果是3

new Function函数的参数中,最后一个参数是函数体,前面的参数是函数的参数,所以上面我们就定义了一个求和函数。

了解了new Function函数的使用,我们就可以通过拼接字符串的方式,动态的生成钩子的回调函数。

SyncHookSyncBailHookcall函数很像,我们可以像拼一个字符串那样拼出他们的函数体,为了更简单的拼凑,tapable最终生成的call函数里面并没有循环,而是在拼函数体的时候就将循环展开了,比如SyncHook拼出来的call函数的函数体就是这样的:

下面代码的_x其实就是保存回调的数组taps,这段代码可以看到,taps里面的内容已经被展开了,是一个一个取出来执行的。

var _x = this._x;
var _fn0 = _x[0];
_fn0(newSpeed);
var _fn1 = _x[1];
_fn1(newSpeed);
var _x = this._x;
var _fn0 = _x[0];
var _result0 = _fn0(newSpeed);
if (_result0 !== undefined) {
    return _result0;
    ;
} else {
    var _fn1 = _x[1];
    var _result1 = _fn1(newSpeed);
    if (_result1 !== undefined) {
        return _result1;
    } else {
    }
}

这段生成的代码主体逻辑其实跟SyncHook是一样的,都是将_x展开执行了,他们的区别是SyncBailHook会对每次执行的结果进行检测,如果结果不是undefined就直接return了,后面的回调函数就没有机会执行了。

了解了SyncHookSyncBailHookcall函数的生成逻辑,下面我们写个基类HookCodeFactory,用来生产钩子的调用函数--call函数

class HookCodeFactory {
    // create创建最终的call函数
    create(options) {
    
        // ...
        let fn;

        // 拼装代码头部
        const header = `
            "use strict";
            var _x = this._x;
        `;

        // 用传进来的参数和函数体创建一个函数出来
        fn = new Function(this.args(),
            header +
            this.content());  // 注意这里的content函数并没有在基类HookCodeFactory实现,而是子类实现的       
            

        this.deinit();

        return fn;
    }
    
    // ... 省略其他相同的代码 ...
    // 拼装函数体,需要支持options.onResult参数
    callTapsSeries(options) {
        const { taps } = this.options;
        let code = '';
        let i = 0;
        const onResult = options && options.onResult;
        // 写一个next函数来开启有onResult回调的函数体生成
        // next和onResult相互递归调用来生成最终的函数体
        const next = () => {
            if(i >= taps.length) return '';
            const result = `_result${i}`;
            const code = `
                var _fn${i} = _x[${i}];
                var ${result} = _fn${i}(${this.args()});
                ${onResult(i++, result, next)}
            `;

            return code;
        }
        // 支持onResult参数
        if(onResult) {
            code = next();
        } else {
          	// 没有onResult参数的时候,即SyncHook跟之前保持一样
            for(; i< taps.length; i++) {
                code += `
                    var _fn${i} = _x[${i}];
                    _fn${i}(${this.args()});
                `
            }
        }
        return code;
    }
}

然后我们的SyncBailHook的代码工厂在继承工厂基类的时候需要传一个onResult参数,就是这样:

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

// SyncBailHookCodeFactory继承HookCodeFactory并实现content函数
// content里面传入定制的onResult函数,onResult回去调用next递归生成嵌套的if...else...
class SyncBailHookCodeFactory extends HookCodeFactory {
    content() {
        return this.callTapsSeries({
            onResult: (i, result, next) =>
                `if(${result} !== undefined) {\nreturn ${result};\n} else {\n${next()}}\n`,
        });
    }
}

// 使用SyncHookCodeFactory来创建factory
const factory = new SyncBailHookCodeFactory();

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


function SyncBailHook(args = []) {
    // 基本结构跟SyncHook都是一样的
    const hook = new Hook(args);
    hook.constructor = SyncBailHook;
    // 使用HookCodeFactory来创建最终的call函数
    hook.compile = COMPILE;
    return hook;
}

上面代码里面要特别注意create函数里面生成函数体的时候调用的是this.content,但是this.content并没与在基类实现,这要求子类在使用HookCodeFactory的时候都需要继承他并实现自己的content函数,所以这里的content函数也是一个抽象接口。那SyncHook的代码就应该改成这样:

那这样设计的目的是什么呢**?**为了让子类content能够传递参数给基类callTapsSeries,从而生成不一样的函数体。我们马上就能在SyncBailHook的代码工厂上看到了。

为了能够生成SyncBailHook的函数体,我们需要让callTapsSeries支持一个onResult参数,就是这样:

下面我们来梳理一下调用函数call的生成过程: 1、当执行SyncBailHook的complie时,就会调用HookCodeFactory工厂基类的create函数,执行调用函数生产逻辑

2、基类的create函数函数会调用子类的content函数,传入本钩子特殊的执行逻辑,然后执行基类的this.callTapsSeries函数生产回调函数

3、子类的content函数用来差异化钩子,基类的callTapsSeries函数用来根据子类传递的差异函数和注册函数,动态的生成钩子的回调函数

image.png

tapable的实现原理总结

image.png

  1. tapable的各种Hook其实都是基于发布订阅模式。
  2. 各个Hook自己独立实现其实也没有问题,但是因为都是发布订阅模式,会有大量重复代码,所以tapable进行了几次抽象,我们来看两个例子。
  3. 第一次抽象是提取一个Hook基类,这个基类实现了初始化和事件注册等公共部分,至于每个Hookcall都不一样,需要自己实现。
  4. 第二次抽象是每个Hook在实现自己的call的时候,发现代码也有很多相似之处,所以提取了一个代码工厂,用来动态生成call的函数体。
  5. 总体来说,tapable的代码并不难,但是因为有两次抽象,整个代码架构显得不那么好读,经过本文的梳理后,应该会好很多了。