在上篇文章《Webpack源碼解讀:理清編譯主流程》中,大致瞭解了webpack的編譯主流程,其中咱們跳過了一個重要內容Tapable
。webpack 插件向第三方開發者提供了鉤入webpack引擎中的編譯流程的方式,而Tapable是插件的最核心基礎。javascript
本文首先分析Tapable的基本原理,在此基礎上編寫一個自定義插件。前端
若是你閱讀了 webpack 的源碼,必定不會對 tapable 不陌生。絕不誇張的說, tapable是webpack控制事件流的超級管家。java
Tapable的核心功能就是依據不一樣的鉤子將註冊的事件在被觸發時按序執行。它是典型的」發佈訂閱模式「。Tapable提供了兩大類共九種鉤子類型,詳細類型以下思惟導圖:webpack
除了Sync
和Async
分類外,你應該也注意到了Bail
、Waterfall
、Loop
等關鍵詞,它們指定了註冊的事件回調handler
觸發的順序。web
Basic hook
:按照事件註冊順序,依次執行handler
,handler
之間互不干擾;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', () => {
console.log('A:', name)
})
sh.tap({
name: 'B',
before: 'A' // 影響該回調的執行順序, 回調B比回調A先執行
}, () => {
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
來給回調排序。服務器
從上面的例子中,咱們看到鉤子上有兩個對外的接口:tap
和 call
,tap
負責註冊事件回調,call
負責觸發事件。閉包
雖然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
方法註冊回調時,又如何執行的呢? 在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
數組中,並依據before
或stage
參數來調整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暫存全部註冊的回調函數
}
複製代碼
不管是調用tap
,tapAsync
或者tapPromise
,都會將回調handler
暫存至taps
數組中,清空以前已經生成的call
方法(this.call = this._call
)。
註冊好事件回調後,接下來該如何觸發事件了。一樣的,call
也存在三種調用方式:call
,callAsync
,promise
,分別對應三種tap
註冊方式。觸發同步Sync
鉤子事件時直接使用call
方法,觸發異步Async
鉤子事件時須要使用callAsync
或promise
方法,繼續看看在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
會調用工廠類HookCodeFactory
的create
方法,這裏對create
的內部暫時不表,factory.create
返回編譯好的function
,最終賦值給this.call
方法。
這裏Hook
使用了一個技巧——惰性函數,當第一次指定this.call
方法時,此時會運行到CALL_DELEGATE
函數體中,CALL_DELEGATE
會從新賦值this.call
,這樣在下一次執行時,直接執行賦值後的this.call
方法,而不用再次進行生成call
的過程,從而優化了性能。
惰性函數有兩個主要優勢:
嗅探程序
。好比能夠用下面的方式使用惰性載入重寫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);
}
複製代碼
在上節提到,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
一個合乎規範的插件應知足如下條件:
apply
方法;Compiler
或 Compilation
);其中條件四、5
並非必需的,只有功能複雜的插件會同時知足以上五個條件。
在文章《Webpack源碼解讀:理清編譯主流程》中咱們知道 webpack 中有兩個很是重要的內部對象,compiler
和compilation
對象,在二者的hooks
上都事先定義好了不一樣類型的鉤子,這些鉤子會在編譯的整個過程當中在相應時間點時觸發。而自定義插件就是「鉤住」這個時間點,並執行相關邏輯。
使用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
就有它的應用場景。
碼字不易,若是:
您的支持與關注,是我持續創做的最大動力!