阅读 535

深入Webpack: Tapable

为什么 CleanWebpackPlugin 没有把 HtmlWebpackPlugin 生成的html给清掉

Tapable 是什么

Tapable 的官方描述为:

Just a little module for plugins.

Tapable 是一个发布订阅的事件系统,相对于node原生events,Tapable更关注于发布订阅中,订阅者的流程处理。

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

来看一下webpack官方示例

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('webpack 构建正在启动!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;
复制代码

上面的示例代码中,我们订阅了compiler对象的run节点,在webpack流程运行到此处时,console.log会被打印出来。

Tapable 通过tap订阅,通过call来发布,对应于原生events中的on 和 emit。

Tapable的种类

Tapable钩子可以按照不同的方式分类,这也是Tapable与众不同的地方。

const {
  SyncHook,
  SyncBailHook,
  SyncWaterfallHook,
  SyncLoopHook,

  AsyncParallelHook,
  AsyncParallelBailHook,

  AsyncSeriesHook,
  AsyncSeriesBailHook,
  AsyncSeriesWaterfallHook,
  AsyncSeriesLoopHook,
} = require("tapable");
复制代码

从Tapable的钩子的调用时机上,可以分为了两个大类,同步和异步钩子。 在异步钩子中,又分为串行和并行。

从Tapable的钩子的功能上,可以分为四种钩子类型,普通型,中断型,流水型和循环型。

而按照这些分类,在钩子的注册和调用上也有所不同。

钩子的订阅分为 tap、tapAsync、tapPromise。

钩子的发布分为call、callAsync、promise。

同步和异步钩子

同步钩子

SyncHook是一个普通的同步钩子,订阅后,在发布之后就会按顺序执行。

例子一

const sh = new SyncHook(["name"]);

sh.tap("one", name => {
  console.log(name);
});
sh.tap("second", name => {
  console.log(name);
});
sh.call("tapable");
// tapable
// tapable
复制代码

同步的钩子只能使用tap订阅,如果使用tapAsync、tapPromise订阅,会报错, 钩子在继承的时候就重写了

const TAP_ASYNC = () => {
  throw new Error("tapAsync is not supported on a SyncHook");
};

const TAP_PROMISE = () => {
  throw new Error("tapPromise is not supported on a SyncHook");
};


function SyncHook(args = [], name = undefined) {
  const hook = new Hook(args, name);
  hook.constructor = SyncHook;
  hook.tapAsync = TAP_ASYNC;
  hook.tapPromise = TAP_PROMISE;
  return hook;
}
复制代码

同步的订阅可以使用异步的发布,比如callAsync和promise,和同步发布相比,异步发布是当订阅者全部执行完毕,发布者会得到通知。

sh.callAsync('tapable', () => {
    console.log('all done')
})
// or

sh.promise().then(() => {
    console.log('all done')
})
复制代码

node原生的事件系统的需要自己扩展一些代码才能实现。

异步钩子

异步钩子分为两种, 串行和并行。

例子二, 异步并行
const sh = new AsyncParallelHook(["name"]);

console.time("AsyncParallelHook");

sh.tapAsync("one", (name, cb) => {
  console.log("one start");
  setTimeout(() => {
    console.log("one done ", name);
    cb(null);
  }, 4000);
});
sh.tapPromise("two", name => {
  return new Promise((resolve, reject) => {
    console.log("two start");
    setTimeout(() => {
      console.log("two done ", name);
      resolve();
    }, 1000);
  });
});
sh.promise("tapable").then(() => {
  console.log("all done");
  console.timeEnd("AsyncParallelHook");
});

//one start
//two start
//two done  tapable
//one done  tapable
//all done
//AsyncParallelHook: 4.015s
复制代码

并行钩子依次订阅,当发布的时候,会同时进行,当有一个报错或者全部执行完毕后,会执行统一的回调。需要注意的是,这里cb函数的第一个参数是error,传入非真值将会直接终止,执行统一的回调,但不会影响其余钩子的执行,因为异步钩子一旦执行,就无法终止。

例子三, 异步串行
const sh = new AsyncSeriesHook(["name"]);

console.time("AsyncSeriesHook");

sh.tapAsync("one", (name, cb) => {
  console.log("one start");
  setTimeout(() => {
    console.log("one done ", name);
    cb(null);
  }, 4000);
});
sh.tapPromise("two", name => {
  return new Promise((resolve, reject) => {
    console.log("two start");
    setTimeout(() => {
      console.log("two done ", name);
      resolve();
    }, 1000);
  });
});
sh.callAsync("tapable", error => {
  if (error) console.log(error);
  console.log("all done");
  console.timeEnd("AsyncSeriesHook");
});

//one start
//one done  tapable
//two start
//two done  tapable
//all done
//AsyncParallelHook: 5.013s
复制代码

串行钩子和并行钩子差不多,区别在于,串行钩子会按照订阅顺序(如果不指定顺序)执行,下一个钩子需要等待前一个钩子执行完毕才会开始执行。

普通型,中断型,流水型和循环型

Tapable相比于其他的事件系统,最强大的地方是它各种的功能型钩子,功能型钩子各分为同步和异步方式,上面举的都是普通型钩子,接下来介绍下其余三种钩子。

中断型钩子

const sh = new SyncBailHook(["name"]);

sh.tap("one", (name) => {
  console.log("one");
});
sh.tap("two", name => {
  console.log("two");
  return null;
});
sh.tap("three", name => {
  console.log("three");
});
sh.callAsync("tapable", error => {
  if (error) console.log(error);
  console.log("all done");
});

//one
//two
//all done
复制代码

在顺序执行的订阅者函数中,如果有一个订阅者函数返回了非undefined值, 则会中断后面的订阅者函数的执行, 直接到达call的回调函数。

上述是同步的例子,对于异步的中断钩子,同样会直接到达call的回调函数, 但无法中断后面订阅者的执行。

流水型钩子

const sh = new SyncWaterfallHook(["type"]);

sh.tap("createBody", type => {
  return type === "add" ? `a + b` : "a - b";
});
sh.tap("createReturn", str => {
  return `return ${str}`;
});
sh.tap("exFn", str => {
  return new Function("a", "b", str);
});

sh.callAsync("add", (error, fn) => {
  if (error) console.log(error);
  const res = fn(3, 6);
  console.log(res);
});
// 9
复制代码

流水型钩子的订阅者执行的时候,依赖上一个订阅者的返回值, 如果上一个订阅者没有返回值的话,那它会一直往上寻找,直到找到了那个未返回undefined的返回值,上述代码通过参数来确认构造一个求和函数或者求差函数。

循环型钩子

const sh = new SyncLoopHook(["type"]);

let count = 0;
function random() {
  return Math.floor(Math.random() * 10) + "";
}
sh.tap("one", res => {
  count++;
  console.log("I am trying one");
  if (res[0] !== random()) {
    return true;
  }
});
sh.tap("two", res => {
  count++;
  console.log("I am trying two");
  if (res[1] !== random()) {
    return true;
  }
});
sh.tap("three", res => {
  count++;
  console.log("I am trying three");
  if (res[2] !== random()) {
    return true;
  }
});

sh.callAsync("777", error => {
  if (error) console.log(error);
  console.log(count);
});
复制代码

循环型钩子是指任何一个订阅事件在未返回undefined的情况下,总会从订阅顺序上重新执行,直到所有的订阅事件都返回undefined。 上面函数计算了随机中到777的需要执行的次数。

思考与实现

基础型

实现一个简单的发布订阅很简单,下面是一个基础的发布订阅:

class Hook {
  constructor() {
    this.taps = [];
  }
  tap(fn) {
    this.taps.push(fn);
  }
  call(done) {
    for (let i = 0; i < this.taps.length; i++) {
      const fn = this.taps[i];
      fn();
    }
    done();
  }
}

const hook = new Hook();
hook.tap(() => {
  console.log("one");
});
hook.tap(() => {
  console.log("two");
});
hook.call(() => {
  console.log("done");
});
// one 
// two
// done

复制代码

思考下, 其他类型怎么实现呢

中断型

实现一个中断型的发布订阅:

...
  callBail(done) {
    for (let i = 0; i < this.taps.length; i++) {
      const fn = this.taps[i];
      const res = fn();
      if (res !== undefined) {
        done();
        return;
      }
    }
    done();
  }
...
复制代码

流水型

实现一个流水型发布订阅

  callWaterfall(done) {
    let res = "init value";
      for (let i = 0; i < this.taps.length; i++) {
        const fn = this.taps[i];
        const r = fn(res);
        if (r !== undefined) {
          res = r;
        }
     }
     done(res);
}
复制代码

循环型

实现一个循环型发布订阅:

  callLoop(done) {
    for (let i = 0; i < this.taps.length; ) {
      const fn = this.taps[i];
      const res = fn();
      i = res !== undefined ? 0 : i + 1;
    }
    done();
  }
复制代码

源码分析

可以发现, 上述的同步形式的功能型钩子实现起来看起来不难,不同的钩子不同的地方主要在于call函数。因此实现call函数是要解决的第一个问题。

另外,除了call函数外,各个Hooks的其余的代码都是相同的, 如果不同的Hooks都要实现这些逻辑的话,会显得非常的冗余, 因此tapable 声明了一个基类Hook,来处理一些通用逻辑。

Hook的实现

Hook定义了几个属性和方法, 这些属性和方法都是所有Hooks共用的:

属性:

  • _args:代表 订阅者的入参。

  • taps / _x: 订阅者列表。

  • ...

方法:

  • _tap (tap、tapAsync、tapPromise): 订阅者注册方法, 通过调用_insert 将订阅者一个个加入taps

  • _insert: 添加订阅者,会按照订阅者的参数来决定调用的顺序。

  • _createCall: 创建不同的call函数,不同的类型钩子主要是call函数不同,_createCall函数通过complie函数来生成call函数。

  • compile: 是一个方法,但基类没有实现,会交给子类实现。如果子类没有实现,则会报错。

基类Hook定义了通用的方法和属性,各个Hooks通过继承它来复用它的功能。

function SyncHook(args = [], name = undefined) {
  const hook = new Hook(args, name);
  hook.constructor = SyncHook;
  hook.tapAsync = TAP_ASYNC;
  hook.tapPromise = TAP_PROMISE;
  hook.compile = COMPILE;
  return hook;
}

SyncHook.prototype = null;

module.exports = SyncHook;
复制代码

HookCodeFactory的实现

从上面的Hook的可以知道,不同的Hooks功能主要通过子类重写compile方法实现,HookCodeFactory 主要来构造不同的call方法。

即使我们发现了各种钩子的不同主要在于call函数的不同,并且我们也把它的实现交给了各个钩子,但是我们会发现,即使是不同的call函数的实现, 也是有大部分重复代码的,比如for循环,每个同步的call函数都是有的,另外,call函数对于错误的处理、回调函数的处理,回调结果的处理,都是大同小异的,还是避免不了冗余。

另外一点,这么多钩子需要考虑很多种情况,比如我们在思考与实现还未考虑异步的情况,常规的编程实现起来难度很大。

因此tapable采用了用字符串拼接的方法来实现各种call函数。

文章分类
前端
文章标签