编写自定义webpack插件从理解Tapable开始

在上篇文章《Webpack源码解读:理清编译主流程》中,大致了解了webpack的编译主流程,其中咱们跳过了一个重要内容Tapable。webpack 插件向第三方开发者提供了钩入webpack引擎中的编译流程的方式,而Tapable是插件的最核心基础。javascript

本文首先分析Tapable的基本原理,在此基础上编写一个自定义插件。前端

Tapable

若是你阅读了 webpack 的源码,必定不会对 tapable 不陌生。绝不夸张的说, tapable是webpack控制事件流的超级管家。java

Tapable的核心功能就是依据不一样的钩子将注册的事件在被触发时按序执行。它是典型的”发布订阅模式“。Tapable提供了两大类共九种钩子类型,详细类型以下思惟导图:webpack

除了SyncAsync分类外,你应该也注意到了BailWaterfallLoop等关键词,它们指定了注册的事件回调handler触发的顺序。web

  • Basic hook:按照事件注册顺序,依次执行handlerhandler之间互不干扰;
  • Bail hook:按照事件注册顺序,依次执行handler,若其中任一handler返回值不为undefined,则剩余的handler均不会执行;
  • Waterfall hook:按照事件注册顺序,依次执行handler,前一个handler的返回值将做为下一个handler的入参;
  • Loop hook:按照事件注册顺序,依次执行handler,若任一handler的返回值不为undefined,则该事件链再次从头开始执行,直到全部handler均返回undefined

基本用法

咱们以SyncHook为例:api

const {
    SyncHook
} = require("../lib/index");
let sh = new SyncHook(["name"])
sh.tap('A', (name) => {
    console.log('A:', name)
})
sh.tap({
    name: 'B',
    before: 'A'  // 影响该回调的执行顺序, 回调B比回调A先执行
}, (name) => {
    console.log('B:', name)
})
sh.call('Tapable')

// output:
B:Tapable
A:Tapable
复制代码

这里咱们定义了一个同步钩子sh,注意到它的构造函数接收一个数组类型入参["name"],表明了它的注册事件将接收到的参数列表,以此来告知调用方在编写回调handler时将会接收到哪些参数。示例中,每一个事件回调都会接收name的参数。数组

经过钩子的tap方法能够注册回调handler,调用call方法来触发钩子,依次执行注册的回调函数。promise

在注册回调B时,传入了before参数,before: 'A',它直接影响了该回调的执行顺序,即回调B会在回调A以前触发。此外,你也能够指定回调的stage来给回调排序。服务器

源码解读

Hook基类

从上面的例子中,咱们看到钩子上有两个对外的接口:tapcalltap负责注册事件回调,call负责触发事件。markdown

虽然Tapable提供多个类型的钩子,但全部钩子都是继承于一个基类Hook,且它们的初始化过程都是类似的。这里咱们仍以SyncHook为例:

// 工厂类的做用是生成不一样的compile方法,compile本质根据事件注册顺序返回控制流代码的字符串。最后由`new Function`生成真实函数赋值到各个钩子对象上。
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}
const factory = new SyncHookCodeFactory();
// 覆盖Hook基类中的tapAsync方法,由于`Sync`同步钩子禁止以tapAsync的方式调用
const TAP_ASYNC = () => {
    throw new Error("tapAsync is not supported on a SyncHook");
};
// 覆盖Hook基类中的tapPromise方法,由于`Sync`同步钩子禁止以tapPromise的方式调用
const TAP_PROMISE = () => {
    throw new Error("tapPromise is not supported on a SyncHook");
};
// compile是每一个类型hook都须要实现的,须要调用各自的工厂函数来生成钩子的call方法。
const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);  // 实例化父类Hook,并修饰hook
    hook.constructor = SyncHook;
    hook.tapAsync = TAP_ASYNC;
    hook.tapPromise = TAP_PROMISE;
    hook.compile = COMPILE;
    return hook;
}
复制代码

tap方法

当执行tap方法注册回调时,又如何执行的呢? 在Hook基类中,关于tap的代码以下:

class Hook{
    constructor(args = [], name = undefined){
        this.taps = []
    }
    tap(options, fn) {
        this._tap("sync", options, fn);
    }
    _tap(type, options, fn) {
        // 这里省略入参预处理部分代码
        this._insert(options);
    }
}
复制代码

咱们看到最终会执行到this._insert方法中,而this._insert的工做就是将回调fn插入到内部的taps数组中,并依据beforestage参数来调整taps数组的排序。具体代码以下:

_insert(item) {
	// 每次注册事件时,将call重置,须要从新编译生成call方法
  this._resetCompilation();
  let before;
  if (typeof item.before === "string") {
    before = new Set([item.before]);
  } else if (Array.isArray(item.before)) {
    before = new Set(item.before);
  }
  let stage = 0;
  if (typeof item.stage === "number") {
    stage = item.stage;
  }
  let i = this.taps.length;
  // while循环体中,依据before和stage调整回调顺序
  while (i > 0) {
    i--;
    const x = this.taps[i];
    this.taps[i + 1] = x;
    const xStage = x.stage || 0;
    if (before) {
      if (before.has(x.name)) {
        before.delete(x.name);
        continue;
      }
      if (before.size > 0) {
        continue;
      }
    }
    if (xStage > stage) {
      continue;
    }
    i++;
    break;
  }
  this.taps[i] = item;  // taps暂存全部注册的回调函数
}
复制代码

不管是调用taptapAsync或者tapPromise,都会将回调handler暂存至taps数组中,清空以前已经生成的call方法(this.call = this._call)。

call方法

注册好事件回调后,接下来该如何触发事件了。一样的,call也存在三种调用方式:callcallAsyncpromise,分别对应三种tap注册方式。触发同步Sync钩子事件时直接使用call方法,触发异步Async钩子事件时须要使用callAsyncpromise方法,继续看看在Hook基类中call是如何定义的:

const CALL_DELEGATE = function(...args) {
    // 在第一次执行call时,会依据钩子类型和回调数组生成真实执行的函数fn。并从新赋值给this.call
    // 在第二次执行call时,直接运行fn,再也不重复调用_createCall
    this.call = this._createCall("sync");
    return this.call(...args);
};
class Hoook {
    constructor(args = [], name = undefined){
        this.call = CALL_DELEGATE
        this._call = CALL_DELEGATE
    }
	
    compile(options) {
        throw new Error("Abstract: should be overridden");
    }
	
    _createCall(type) {
        // 进入该函数体意味是第一次执行call或call被重置,此时须要调用compile去生成call方法
        return this.compile({
            taps: this.taps,
            interceptors: this.interceptors,
            args: this._args,
            type: type
        });
    }
}
复制代码

_createCall会调用this.compile方法来编译生成真实调用的call方法,但在Hook基类中compile是空实现。它要求继承Hook父类的子类必须实现这个方法(即抽象方法)。回到SyncHook中查看compiler的实现:

const HookCodeFactory = require("./HookCodeFactory");
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}
const factory = new SyncHookCodeFactory();
const COMPILE = function(options) {
    // 调用工厂类中的setup和create方法拼接字符串,以后实例化 new Function 获得函数fn
    factory.setup(this, options);
    return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);
    hook.compile = COMPILE;
    return hook;
}
复制代码

SyncHook类中compile会调用工厂类HookCodeFactorycreate方法,这里对create的内部暂时不表,factory.create返回编译好的function,最终赋值给this.call方法。

这里Hook使用了一个技巧——惰性函数,当第一次指定this.call方法时,此时会运行到CALL_DELEGATE函数体中,CALL_DELEGATE会从新赋值this.call,这样在下一次执行时,直接执行赋值后的this.call方法,而不用再次进行生成call的过程,从而优化了性能。

惰性函数有两个主要优势:

  1. 效率高:惰性函数仅在第一次运行时执行计算逻辑,以后函数再次运行时都返回第一次执行的结果,节约了不少执行时间;
  2. 延迟执行:在某些场景下,须要判断一些环境信息,一旦肯定后就再也不须要从新判断。能够理解为嗅探程序。好比能够用下面的方式使用惰性载入重写addEvent
function addEvent(type, element, fun) {
    if (element.addEventListener) {
        addEvent = function(type, element, fun) {
            element.addEventListener(type, fun, false);
        };
    } else if (element.attachEvent) {
        addEvent = function(type, element, fun) {
            element.attachEvent("on" + type, fun);
        };
    } else {
        addEvent = function(type, element, fun) {
            element["on" + type] = fun;
        };
    }
    return addEvent(type, element, fun);
}
复制代码

HookCodeFactory工厂类

在上节提到,factory.create返回编译好的function赋值给call方法。 每一个类型的钩子都会构造一个工厂类负责拼接调度回调handler时序的函数字符串,经过new Function()的实例化方式来生成执行函数。

延伸:new Function

在 JavaScript 中有三种函数定义的方式:

// 定义1. 函数声明
function add(a, b){
    return a + b
}

// 定义2. 函数表达式
const add = function(a, b){
    return a + b
}

// 定义3. new Function
const add = new Function('a', 'b', 'return a + b')
复制代码

前两种函数定义方式是”静态“的,之所谓是”静态“的是函数定义之时,它的功能就肯定下来了。而第三种函数定义方式则是”动态“,所谓”动态“是函数功能能够在程序运行过程当中变化。

定义1 与 定义2也是有区别的哦,最关键的区别在于 JavaScript 函数和变量声明的“提早”(hoist)行为。这里就不作展开了。

好比,我须要动态构造一个 n 个数相加的函数:

let nums = [1,2,3,4]
let len = nums.length
let params = Array(len).fill('x').map((item, idx)=>{
    return '' + item + idx
})
const add = new Function(params.join(','), ` return ${params.join('+')}; `)
console.log(add.toString())
console.log(add.apply(null, nums))
复制代码

打印函数字符串add.toString(),能够获得:

function anonymous(x0,x1,x2,x3) {
    return x0+x1+x2+x3;
}
复制代码

函数add的函数入参和函数体会根据nums的长度而动态生成,这样你能够根据实际状况来控制传入参数的个数,而且函数也只处理这几个入参。

new Function的函数声明方式较前二者首先性能上会有点吃亏,每次实例化都会消耗性能。其次,new Function声明的函数不支持”闭包“,对好比下代码:

function bar(){
    let name = 'bar'
    let func = function(){return name}
    return func
}
bar()()  // "bar", func中name读取到bar词法做用域中的name变量

function foo(){
    let name = 'foo'
    let func = new Function('return name')
    return func
}
foo()()  // ReferenceError: name is not defined
复制代码

究其缘由是由于new Function的词法做用域指向的是全局做用域。

factory.create的主要逻辑是根据钩子类型type,拼接回调时序控制字符串,以下:

fn = new Function(
  this.args(),
  '"use strict";\n' +
    this.header() +
    this.content({
      onError: err => `throw ${err};\n`,
      onResult: result => `return ${result};\n`,
      resultReturns: true,
      onDone: () => "",
      rethrowIfPossible: true
    })
);
复制代码

咱们以SyncHook为例:

let sh = new SyncHook(["name"]);
sh.tap("A", (name) => {
    console.log("A");
});
sh.tap('B', (name) => {
    console.log("B");
});
sh.tap("C", (name) => {
    console.log("C");
});
sh.call();
复制代码

能够获得以下的函数字符串:

function anonymous(name) {
 "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(name);
    var _fn1 = _x[1];
    _fn1(name);
    var _fn2 = _x[2];
    _fn2(name);
}
复制代码

其中_x则指向this.taps数组,按序访问到每一个handler,并执行handler

更多Hook示例,能够查看RunKit

Tapable为什么要如此“费劲”的动态生成函数体呢?由于它是“执行效率最优化”的拥趸,尽量的不产生新的调用堆栈的函数才是执行效率最优的。

自定义 webpack plugin

一个插件的自我修养

一个合乎规范的插件应知足如下条件:

  1. 它是一个具名的函数或者JS类;
  2. 在原型链上指定apply方法;
  3. 指定一个明确的事件钩子并注册回调;
  4. 处理 webpack 内部实例的特定数据(CompilerCompilation);
  5. 完成功能后调用webpack传入的回调等;

其中条件四、5并非必需的,只有功能复杂的插件会同时知足以上五个条件。

在文章《Webpack源码解读:理清编译主流程》中咱们知道 webpack 中有两个很是重要的内部对象,compilercompilation对象,在二者的hooks上都事先定义好了不一样类型的钩子,这些钩子会在编译的整个过程当中在相应时间点时触发。而自定义插件就是“钩住”这个时间点,并执行相关逻辑。

compiler钩子列表 compilation钩子列表

自动上传资源的插件

使用webpack打包资源后都会在本地项目中生成一个dist文件夹用于存放打包后的静态资源,此时能够写一个自动上传资源文件到CDN的webpack插件,每次打包成功后及时的上传至CDN。

当你明确插件的功能时,你须要在合适的钩子上去注册你的回调。在本例中,咱们须要将已经打包输出后的静态文件上传至CDN,经过在compiler钩子列表中查询知道compiler.hooks.afterEmit是符合要求的钩子,它是一个AsyncSeriesHook类型。

按照五个基本条件来实现这个插件:

const assert = require("assert");
const fs = require("fs");
const glob = require("util").promisify(require("glob"));

// 1. 它是一个具名的函数或者JS类
class AssetUploadPlugin {
    constructor(options) {
        // 这里能够校验传入的参数是否合法等初始化操做
        assert(
            options,
            "check options ..."
        );
    }
    // 2. 在原型链上指定`apply`方法
    // apply方法接收 webpack compiler 对象入参
    apply(compiler) {
        // 3. 指定一个明确的事件钩子并注册回调
        compiler.hooks.afterEmit.tapAsync(  // 由于afterEmit是AsyncSeriesHook类型的钩子,须要使用tapAsync或tapPromise钩入回调
            "AssetUploadPlugin",
            (compilation, callback) => {
                const {
                    outputOptions: { path: outputPath }
                } = compilation;  // 4. 处理 webpack 内部实例的特定数据
                uploadDir(
                    outputPath,
                    this.options.ignore ? { ignore: this.options.ignore } : null
                )
                .then(() => {
                    callback();  // 5. 完成功能后调用webpack传入的回调等;
                })
                .catch(err => {
                    callback(err);
                });
            });
    }
};
// uploadDir就是这个插件的功能性描述
function uploadDir(dir, options) {
    if (!dir) {
        throw new Error("dir is required for uploadDir");
    }
    if (!fs.existsSync(dir)) {
        throw new Error(`dir ${dir} is not exist`);
    }
    return fs
        .statAsync(dir)
        .then(stat => {
            if (!stat.isDirectory()) {
                throw new Error(`dir ${dir} is not directory`);
            }
        })
        .then(() => {
            return glob(
                "**/*",
                Object.assign(
                    {
                        cwd: dir,
                        dot: false,
                        nodir: true
                    },
                    options
                )
            );
        })
        .then(files => {
            if (!files || !files.length) {
                return "未找到须要上传的文件";
            }
            // TODO: 这里将资源上传至你的静态云服务器中,如京东云、腾讯云等
            // ...
        });
}

module.exports = AssetUploadPlugin
复制代码

webpack.config.js中能够引入这个插件并实例化:

const AssetUploadPlugin = require('./AssetUploadPlugin')
const config = {
    //...
    plugins: [
        new AssetUploadPlugin({
            ignore: []
        })
    ]
}
复制代码

总结

webpack的灵活配置得益于 Tapable 提供强大的钩子体系,让编译的每一个过程均可以“钩入”,如虎添翼。正所谓“三人成众”,将一个系统作到插件化时,它的可扩展性将大大提升。 Tapable也能够应用到具体的业务场景中,好比流程监控日志记录埋点上报等,凡是须要“钩入”到具体流程中时,Tapable就有它的应用场景。

最后

码字不易,若是:

  • 这篇文章对你有用,请不要吝啬你的小手为我点赞;
  • 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
  • 指望与我一同持续学习前端技术知识,请关注我吧;
  • 转载请注明出处;

您的支持与关注,是我持续创做的最大动力!

参考