webpack核心模块tapable源码解析

上一篇文章我写了tapable的基本用法,咱们知道他是一个加强版版的发布订阅模式,本文想来学习下他的源码。tapable的源码我读了一下,发现他的抽象程度比较高,直接扎进去反而会让人云里雾里的,因此本文会从最简单的SyncHook发布订阅模式入手,再一步一步抽象,慢慢变成他源码的样子。javascript

本文可运行示例代码已经上传GitHub,你们拿下来一边玩一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-source-code前端

SyncHook的基本实现

上一篇文章已经讲过SyncHook的用法了,我这里就再也不展开了,他使用的例子就是这样子:java

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

// 实例化一个加速的hook
const accelerate = new SyncHook(["newSpeed"]);

// 注册第一个回调,加速时记录下当前速度
accelerate.tap("LoggerPlugin", (newSpeed) =>
  console.log("LoggerPlugin", `加速到${newSpeed}`)
);

// 再注册一个回调,用来检测是否超速
accelerate.tap("OverspeedPlugin", (newSpeed) => {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin", "您已超速!!");
  }
});

// 触发一下加速事件,看看效果吧
accelerate.call(500);

其实这种用法就是一个最基本的发布订阅模式,我以前讲发布订阅模式的文章讲过,咱们能够仿照那个很快实现一个SyncHookwebpack

class SyncHook {
    constructor(args = []) {
        this._args = args;       // 接收的参数存下来
        this.taps = [];          // 一个存回调的数组
    }

    // tap实例方法用来注册回调
    tap(name, fn) {
        // 逻辑很简单,直接保存下传入的回调参数就行
        this.taps.push(fn);
    }

    // call实例方法用来触发事件,执行全部回调
    call(...args) {
        // 逻辑也很简单,将注册的回调一个一个拿出来执行就行
        const tapsLength = this.taps.length;
        for(let i = 0; i < tapsLength; i++) {
            const fn = this.taps[i];
            fn(...args);
        }
    }
}

这段代码很是简单,是一个最基础的发布订阅模式,使用方法跟上面是同样的,将SyncHooktapable导出改成使用咱们本身的:git

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

运行效果是同样的:github

image-20210323153234354

注意: 咱们构造函数里面传入的args并无用上,tapable主要是用它来动态生成call的函数体的,在后面讲代码工厂的时候会看到。web

SyncBailHook的基本实现

再来一个SyncBailHook的基本实现吧,SyncBailHook的做用是当前一个回调返回不为undefined的值的时候,阻止后面的回调执行。基本使用是这样的:segmentfault

const { SyncBailHook } = require("tapable");    // 使用的是SyncBailHook

const accelerate = new SyncBailHook(["newSpeed"]);

accelerate.tap("LoggerPlugin", (newSpeed) =>
  console.log("LoggerPlugin", `加速到${newSpeed}`)
);

// 再注册一个回调,用来检测是否超速
// 若是超速就返回一个错误
accelerate.tap("OverspeedPlugin", (newSpeed) => {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin", "您已超速!!");

    return new Error('您已超速!!');
  }
});

// 因为上一个回调返回了一个不为undefined的值
// 这个回调不会再运行了
accelerate.tap("DamagePlugin", (newSpeed) => {
  if (newSpeed > 300) {
    console.log("DamagePlugin", "速度实在太快,车子快散架了。。。");
  }
});

accelerate.call(500);

他的实现跟上面的SyncHook也很是像,只是call在执行的时候不同而已,SyncBailHook须要检测每一个回调的返回值,若是不为undefined就终止执行后面的回调,因此代码实现以下:数组

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

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

    // 其余代码跟SyncHook是同样的,就是call的实现不同
    // 须要检测每一个返回值,若是不为undefined就终止执行
    call(...args) {
        const tapsLength = this.taps.length;
        for(let i = 0; i < tapsLength; i++) {
            const fn = this.taps[i];
            const res = fn(...args);

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

而后改下SyncBailHook从咱们本身的引入就行:架构

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

运行效果是同样的:

image-20210323155857678

抽象重复代码

如今咱们只实现了SyncHookSyncBailHook两个Hook而已,上一篇讲用法的文章里面总共有9个Hook,若是每一个Hook都像前面这样实现也是能够的。可是咱们再仔细看下SyncHookSyncBailHook两个类的代码,发现他们除了call的实现不同,其余代码如出一辙,因此做为一个有追求的工程师,咱们能够把这部分重复的代码提出来做为一个基类:Hook类。

Hook类须要包含一些公共的代码,call这种不同的部分由各个子类本身实现。因此Hook类就长这样:

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

// Hook是SyncHook和SyncBailHook的基类
// 大致结构是同样的,不同的地方是call
// 不一样子类的call是不同的
// tapable的Hook基类提供了一个抽象接口compile来动态生成call函数
class Hook {
    constructor(args = []) {
        this._args = args;       
        this.taps = [];          

        // 基类的call初始化为CALL_DELEGATE
        // 为何这里须要这样一个代理,而不是直接this.call = _createCall()
        // 等咱们后面子类实现了再一块儿讲
        this.call = CALL_DELEGATE;
    }

    // 一个抽象接口compile
    // 由子类实现,基类compile不能直接调用
    compile(options) {
      throw new Error("Abstract: should be overridden");
    }

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

    // _createCall调用子类实现的compile来生成call方法
    _createCall() {
      return this.compile({
        taps: this.taps,
        args: this._args,
      });
    }
}

官方对应的源码看这里:https://github.com/webpack/tapable/blob/master/lib/Hook.js

子类SyncHook实现

如今有了Hook基类,咱们的SyncHook就须要继承这个基类重写,tapable在这里继承的时候并无使用class extends,而是手动继承的:

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

function SyncHook(args = []) {
    // 先手动继承Hook
      const hook = new Hook(args);
    hook.constructor = SyncHook;

    // 而后实现本身的compile函数
    // compile的做用应该是建立一个call函数并返回
        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;

注意:咱们在基类Hook构造函数中初始化this.callCALL_DELEGATE这个函数,这是有缘由的,最主要的缘由是确保this的正确指向。思考一下假如咱们不用CALL_DELEGATE,而是直接this.call = this._createCall()会发生什么?咱们来分析下这个执行流程:

  1. 用户使用时,确定是使用new SyncHook(),这时候会执行const hook = new Hook(args);
  2. new Hook(args)会去执行Hook的构造函数,也就是会运行this.call = this._createCall()
  3. 这时候的this指向的是基类Hook的实例,this._createCall()会调用基类的this.compile()
  4. 因为基类的complie函数是一个抽象接口,直接调用会报错Abstract: should be overridden

那咱们采用this.call = CALL_DELEGATE是怎么解决这个问题的呢

  1. 采用this.call = CALL_DELEGATE后,基类Hook上的call就只是被赋值为一个代理函数而已,这个函数不会立马调用。
  2. 用户使用时,一样是new SyncHook(),里面会执行Hook的构造函数
  3. Hook构造函数会给this.call赋值为CALL_DELEGATE,可是不会当即执行。
  4. new SyncHook()继续执行,新建的实例上的方法hook.complie被覆写为正确方法。
  5. 当用户调用hook.call的时候才会真正执行this._createCall(),这里面会去调用this.complie()
  6. 这时候调用的complie已是被正确覆写过的了,因此获得正确的结果。

子类SyncBailHook的实现

子类SyncBailHook的实现跟上面SyncHook的也是很是像,只是hook.compile实现不同而已:

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;

抽象代码工厂

上面咱们经过对SyncHookSyncBailHook的抽象提炼出了一个基类Hook,减小了重复代码。基于这种结构子类须要实现的就是complie方法,可是若是咱们将SyncHookSyncBailHookcomplie方法拿出来对比下:

SyncHook:

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];
      fn(...args);
    }
  }

  return call;
};

SyncBailHook

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) return res;
    }
  }

  return call;
};

咱们发现这两个complie也很是像,有大量重复代码,因此tapable为了解决这些重复代码,又进行了一次抽象,也就是代码工厂HookCodeFactoryHookCodeFactory的做用就是用来生成complie返回的call函数体,而HookCodeFactory在实现时也采用了Hook相似的思路,也是先实现了一个基类HookCodeFactory,而后不一样的Hook再继承这个类来实现本身的代码工厂,好比SyncHookCodeFactory

建立函数的方法

在继续深刻代码工厂前,咱们先来回顾下JS里面建立函数的方法。通常咱们会有这几种方法:

  1. 函数申明

    function add(a, b) {
      return a + b;
    }
  2. 函数表达式

    const add = function(a, b) {
      return a + b;
    }

可是除了这两种方法外,还有种不经常使用的方法:使用Function构造函数。好比上面这个函数使用构造函数建立就是这样的:

const add = new Function('a', 'b', 'return a + b;');

上面的调用形式里,最后一个参数是函数的函数体,前面的参数都是函数的形参,最终生成的函数跟用函数表达式的效果是同样的,能够这样调用:

add(1, 2);    // 结果是3

注意:上面的ab形参放在一块儿用逗号隔开也是能够的:

const add = new Function('a, b', 'return a + b;');    // 这样跟上面的效果是同样的

固然函数并非必定要有参数,没有参数的函数也能够这样建立:

const sayHi = new Function('alert("Hello")');

sayHi(); // Hello

这样建立函数和前面的函数申明和函数表达式有什么区别呢?使用Function构造函数来建立函数最大的一个特征就是,函数体是一个字符串,也就是说咱们能够动态生成这个字符串,从而动态生成函数体。由于SyncHookSyncBailHookcall函数很像,咱们能够像拼一个字符串那样拼出他们的函数体,为了更简单的拼凑,tapable最终生成的call函数里面并无循环,而是在拼函数体的时候就将循环展开了,好比SyncHook拼出来的call函数的函数体就是这样的:

"use strict";
var _x = this._x;
var _fn0 = _x[0];
_fn0(newSpeed);
var _fn1 = _x[1];
_fn1(newSpeed);

上面代码的_x其实就是保存回调的数组taps,这里重命名为_x,我想是为了节省代码大小吧。这段代码能够看到,_x,也就是taps里面的内容已经被展开了,是一个一个取出来执行的。

SyncBailHook最终生成的call函数体是这样的:

"use strict";
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了,后面的回调函数就没有机会执行了。

建立代码工厂基类

基于这个目的,咱们的代码工厂基类应该能够生成最基本的call函数体。咱们来写个最基本的HookCodeFactory吧,目前他只能生成SyncHookcall函数体:

class HookCodeFactory {
    constructor() {
        // 构造函数定义两个变量
        this.options = undefined;
        this._args = undefined;
    }

    // init函数初始化变量
    init(options) {
        this.options = options;
        this._args = options.args.slice();
    }

    // deinit重置变量
    deinit() {
        this.options = undefined;
        this._args = undefined;
    }

    // args用来将传入的数组args转换为New Function接收的逗号分隔的形式
    // ['arg1', 'args'] --->  'arg1, arg2'
    args() {
        return this._args.join(", ");
    }

    // setup其实就是给生成代码的_x赋值
    setup(instance, options) {
        instance._x = options.taps.map(t => t);
    }

    // create建立最终的call函数
    create(options) {
        this.init(options);
        let fn;

        // 直接将taps展开为平铺的函数调用
        const { taps } = options;
        let code = '';
        for (let i = 0; i < taps.length; i++) {
            code += `
                var _fn${i} = _x[${i}];
                _fn${i}(${this.args()});
            `
        }

        // 将展开的循环和头部链接起来
        const allCodes = `
            "use strict";
            var _x = this._x;
        ` + code;

        // 用传进来的参数和生成的函数体建立一个函数出来
        fn = new Function(this.args(), allCodes);

        this.deinit();  // 重置变量

        return fn;    // 返回生成的函数
    }
}

上面代码最核心的其实就是create函数,这个函数会动态建立一个call函数并返回,因此SyncHook能够直接使用这个factory建立代码了:

// SyncHook.js

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

const factory = new HookCodeFactory();

// COMPILE函数会去调用factory来生成call函数
const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};

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

    // 使用HookCodeFactory来建立最终的call函数
    hook.compile = COMPILE;

    return hook;
}

SyncHook.prototype = null;

让代码工厂支持SyncBailHook

如今咱们的HookCodeFactory只能生成最简单的SyncHook代码,咱们须要对他进行一些改进,让他可以也生成SyncBailHookcall函数体。你能够拉回前面再仔细观察下这两个最终生成代码的区别:

  1. SyncBailHook须要对每次执行的result进行处理,若是不为undefined就返回
  2. SyncBailHook生成的代码实际上是if...else嵌套的,咱们生成的时候能够考虑使用一个递归函数

为了让SyncHookSyncBailHook的子类代码工厂可以传入差别化的result处理,咱们先将HookCodeFactory基类的create拆成两部分,将代码拼装的逻辑单独拆成一个函数:

class HookCodeFactory {
    // ...
      // 省略其余同样的代码
      // ...

    // create建立最终的call函数
    create(options) {
        this.init(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;
    }

    // 拼装函数体
      // callTapsSeries也没在基类调用,而是子类调用的
    callTapsSeries() {
        const { taps } = this.options;
        let code = '';
        for (let i = 0; i < taps.length; i++) {
            code += `
                var _fn${i} = _x[${i}];
                _fn${i}(${this.args()});
            `
        }

        return code;
    }
}

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

// SyncHook.js

// ... 省略其余同样的代码 ...

// SyncHookCodeFactory继承HookCodeFactory并实现content函数
class SyncHookCodeFactory extends HookCodeFactory {
    content() {
        return this.callTapsSeries();    // 这里的callTapsSeries是基类的
    }
}

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

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

注意这里:子类实现的content其实又调用了基类的callTapsSeries来生成最终的函数体。因此这里这几个函数的调用关系实际上是这样的:

image-20210401111739814

那这样设计的目的是什么呢为了让子类content可以传递参数给基类callTapsSeries,从而生成不同的函数体。咱们立刻就能在SyncBailHook的代码工厂上看到了。

为了可以生成SyncBailHook的函数体,咱们须要让callTapsSeries支持一个onResult参数,就是这样:

class HookCodeFactory {
    // ... 省略其余相同的代码 ...

    // 拼装函数体,须要支持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;
}

如今运行下代码,效果跟以前同样的,大功告成~

其余Hook的实现

到这里,tapable的源码架构和基本实现咱们已经弄清楚了,可是本文只用了SyncHookSyncBailHook作例子,其余的,好比AsyncParallelHook并无展开讲。由于AsyncParallelHook之类的其余Hook的实现思路跟本文是同样的,好比咱们能够先实现一个独立的AsyncParallelHook类:

class AsyncParallelHook {
    constructor(args = []) {
        this._args = args;
        this.taps = [];
    }
    tapAsync(name, task) {
        this.taps.push(task);
    }
    callAsync(...args) {
        // 先取出最后传入的回调函数
        let finalCallback = args.pop();

        // 定义一个 i 变量和 done 函数,每次执行检测 i 值和队列长度,决定是否执行 callAsync 的最终回调函数
        let i = 0;
        let done = () => {
            if (++i === this.taps.length) {
                finalCallback();
            }
        };

        // 依次执行事件处理函数
        this.taps.forEach(task => task(...args, done));
    }
}

而后对他的callAsync函数进行抽象,将其抽象到代码工厂类里面,使用字符串拼接的方式动态构造出来就好了,总体思路跟前面是同样的。具体实现过程能够参考tapable源码:

Hook类源码

SyncHook类源码

SyncBailHook类源码

HookCodeFactory类源码

总结

本文可运行示例代码已经上传GitHub,你们拿下来一边玩一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-source-code

下面再对本文的思路进行一个总结:

  1. tapable的各类Hook其实都是基于发布订阅模式。
  2. 各个Hook本身独立实现其实也没有问题,可是由于都是发布订阅模式,会有大量重复代码,因此tapable进行了几回抽象。
  3. 第一次抽象是提取一个Hook基类,这个基类实现了初始化和事件注册等公共部分,至于每一个Hookcall都不同,须要本身实现。
  4. 第二次抽象是每一个Hook在实现本身的call的时候,发现代码也有不少类似之处,因此提取了一个代码工厂,用来动态生成call的函数体。
  5. 整体来讲,tapable的代码并不难,可是由于有两次抽象,整个代码架构显得不那么好读,通过本文的梳理后,应该会好不少了。

文章的最后,感谢你花费宝贵的时间阅读本文,若是本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是做者持续创做的动力。

欢迎关注个人公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

参考资料

tapable用法介绍:http://www.javashuo.com/article/p-rgjasasi-vg.html

tapable源码地址:https://github.com/webpack/tapable

相关文章
相关标签/搜索