在上篇文章《Webpack源码解读:理清编译主流程》中,大体了解了webpack的编译主流程,其中我们跳过了一个重要内容
本文首先分析Tapable的基本原理,在此基础上编写一个自定义插件。
Tapable
如果你阅读了 webpack 的源码,一定不会对 tapable 不陌生。毫不夸张的说, tapable是webpack控制事件流的超级管家。
Tapable的核心功能就是依据不同的钩子将注册的事件在被触发时按序执行。它是典型的”观察者模式“。Tapable提供了两大类共九种钩子类型,详细类型如下思维导图:
除了
Basic hook :按照事件注册顺序,依次执行handler ,handler 之间互不干扰;Bail hook :按照事件注册顺序,依次执行handler ,若其中任一handler 返回值不为undefined ,则剩余的handler 均不会执行;Waterfall hook :按照事件注册顺序,依次执行handler ,前一个handler 的返回值将作为下一个handler 的入参;Loop hook :按照事件注册顺序,依次执行handler ,若任一handler 的返回值不为undefined ,则该事件链再次从头开始执行,直到所有handler 均返回undefined
基本用法
我们以
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | const { SyncHook } = require("../lib/index"); let sh = new SyncHook(["name"]) sh.tap('A', () => { console.log('A:', name) }) sh.tap({ name: 'B', before: 'A' // 影响该回调的执行顺序, 回调B比回调A先执行 }, () => { console.log('B:', name) }) sh.call('Tapable') // output: B:Tapable A:Tapable |
这里我们定义了一个同步钩子
通过钩子的
在注册回调
源码解读
Hook基类
从上面的例子中,我们看到钩子上有两个对外的接口:
虽然Tapable提供多个类型的钩子,但所有钩子都是继承于一个基类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | // 工厂类的作用是生成不同的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方法
当执行
在
1 2 3 4 5 6 7 8 9 10 11 12 | class Hook{ constructor(args = [], name = undefined){ this.taps = [] } tap(options, fn) { this._tap("sync", options, fn); } _tap(type, options, fn) { // 这里省略入参预处理部分代码 this._insert(options); } } |
我们看到最终会执行到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | _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暂存所有注册的回调函数 } |
不论是调用
call方法
注册好事件回调后,接下来该如何触发事件了。同样的,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | 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 }); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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; } |
在
这里
惰性函数有两个主要优点:
- 效率高:惰性函数仅在第一次运行时执行计算逻辑,之后函数再次运行时都返回第一次执行的结果,节约了很多执行时间;
- 延迟执行:在某些场景下,需要判断一些环境信息,一旦确定后就不再需要重新判断。可以理解为
嗅探程序 。比如可以用下面的方式使用惰性载入重写addEvent :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 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工厂类
在上节提到,
每个类型的钩子都会构造一个工厂类负责拼接调度回调
延伸:new Function
在 JavaScript 中有三种函数定义的方式:
1 2 3 4 5 6 7 8 9 10 11 12 | // 定义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 个数相加的函数:
1 2 3 4 5 6 7 8 9 10 | 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)) |
打印函数字符串
1 2 3 | function anonymous(x0,x1,x2,x3) { return x0+x1+x2+x3; } |
函数
1 2 3 4 5 6 7 8 9 10 11 12 13 | 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 |
究其原因是因为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | fn = new Function( this.args(), '"use strict"; ' + this.header() + this.content({ onError: err => `throw ${err}; `, onResult: result => `return ${result}; `, resultReturns: true, onDone: () => "", rethrowIfPossible: true }) ); |
我们以
1 2 3 4 5 6 7 8 9 10 11 | 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(); |
可以得到如下的函数字符串:
1 2 3 4 5 6 7 8 9 10 11 | 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); } |
其中
更多Hook示例,可以查看RunKit
自定义 webpack plugin
一个插件的自我修养
一个合乎规范的插件应满足以下条件:
- 它是一个具名的函数或者JS类;
- 在原型链上指定
apply 方法; - 指定一个明确的事件钩子并注册回调;
- 处理 webpack 内部实例的特定数据(
Compiler 或Compilation ); - 完成功能后调用webpack传入的回调等;
其中
在文章《Webpack源码解读:理清编译主流程》中我们知道 webpack 中有两个非常重要的内部对象,
compiler钩子列表
compilation钩子列表
自动上传资源的插件
使用webpack打包资源后都会在本地项目中生成一个
当你明确插件的功能时,你需要在合适的钩子上去注册你的回调。在本例中,我们需要将已经打包输出后的静态文件上传至CDN,通过在
按照五个基本条件来实现这个插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | 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 |
在
1 2 3 4 5 6 7 8 9 | const AssetUploadPlugin = require('./AssetUploadPlugin') const config = { //... plugins: [ new AssetUploadPlugin({ ignore: [] }) ] } |
总结
webpack的灵活配置得益于
最后
码字不易,如果:
- 这篇文章对你有用,请不要吝啬你的小手为我点赞;
- 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
- 期望与我一同持续学习前端技术知识,请关注我吧;
- 转载请注明出处;
您的支持与关注,是我持续创作的最大动力!
参考
- Function - MDN
- "new Function" 语法