Webpack 核心庫 Tapable 的使用與原理解析

前言

Webpack 本質上是一種事件流的機制,它的工做流程就是將各個插件串聯起來,而實現這一切的核心就是 TapableWebpack 中最核心的負責編譯的 Compiler 和負責建立 bundlesCompilation 都是 Tapable 的實例,而且實例內部的生命週期也是經過 Tapable 庫提供的鉤子類實現的。javascript

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {
            shouldEmit: new SyncBailHook(["compilation"]),
            done: new AsyncSeriesHook(["stats"]),
            additionalPass: new AsyncSeriesHook([]),
            beforeRun: new AsyncSeriesHook(["compiler"]),
            run: new AsyncSeriesHook(["compiler"]),
            emit: new AsyncSeriesHook(["compilation"]),
            assetEmitted: new AsyncSeriesHook(["file", "content"]),
            afterEmit: new AsyncSeriesHook(["compilation"]),
            thisCompilation: new SyncHook(["compilation", "params"]),
            compilation: new SyncHook(["compilation", "params"]),
            normalModuleFactory: new SyncHook(["normalModuleFactory"]),
            contextModuleFactory: new SyncHook(["contextModulefactory"]),
            beforeCompile: new AsyncSeriesHook(["params"]),
            compile: new SyncHook(["params"]),
            make: new AsyncParallelHook(["compilation"]),
            afterCompile: new AsyncSeriesHook(["compilation"]),
            watchRun: new AsyncSeriesHook(["compiler"]),
            failed: new SyncHook(["error"]),
            invalid: new SyncHook(["filename", "changeTime"]),
            watchClose: new SyncHook([]),
            infrastructureLog: new SyncBailHook(["origin", "type", "args"]),
            environment: new SyncHook([]),
            afterEnvironment: new SyncHook([]),
            afterPlugins: new SyncHook(["compiler"]),
            afterResolvers: new SyncHook(["compiler"]),
            entryOption: new SyncBailHook(["context", "entry"])
        };
  }
}複製代碼

Tapable 是什麼?

咱們知道 Node.js 的特色是事件驅動,它是經過內部的 EventEmitter 類實現的,這個類可以進行事件的監聽與觸發。java

const { EventEmitter } = require('events');
const event = new EventEmitter();

event.on('eventName', value => {
  console.log('eventName 觸發:', value);
});

event.emit('eventName', 'Hello, eventName');複製代碼

Tapable 的功能與 EventEmitter 相似,可是更增強大,它包含了多種不一樣的監聽和觸發事件的方式。node

Tapable 的 Hook 類

經過上文 Compiler 類內部能看到 Tapable 提供的類都是給生命週期實例化的,所以咱們叫它鉤子類。webpack

Tapable 導出的鉤子類:git

const {
  SyncHook,
  SyncBailHook,
  SyncWaterfallHook,
  SyncLoopHook,
  AsyncSeriesHook,
  AsyncSeriesBailHook,
  AsyncSeriesWaterfallHook,
  AsyncParallelHook,
  AsyncParallelBailHook,
} = require('tapable');複製代碼

Hook 的類型能夠按照 事件回調的運行邏輯 或者 觸發事件的方式 來分類。github

事件回調的運行邏輯:web

類型npm

描述json

Basicsegmentfault

基礎類型,單純的調用註冊的事件回調,並不關心其內部的運行邏輯。

Bail

保險類型,當一個事件回調在運行時返回的值不爲 undefined 時,中止後面事件回調的執行。

Waterfall

瀑布類型,若是當前執行的事件回調返回值不爲 undefined,那麼就把下一個事件回調的第一個參數替換成這個值。

Loop

循環類型,若是當前執行的事件回調的返回值不是 undefined,從新從第一個註冊的事件回調處執行,直到當前執行的事件回調沒有返回值。下文有詳細解釋。

觸發事件的方式:

類型

描述

Sync

Sync 開頭的 Hook 類只能用 tap 方法註冊事件回調,這類事件回調會同步執行;若是使用 tapAsync 或者 tapPromise 方法註冊則會報錯。

AsyncSeries

Async 開頭的 Hook 類,無法用 call 方法觸發事件,必須用 callAsync 或者 promise 方法觸發;這兩個方法都能觸發 taptapAsynctapPromise 註冊的事件回調。AsyncSeries 按照順序執行,當前事件回調若是是異步的,那麼會等到異步執行完畢纔會執行下一個事件回調;而 AsyncParalle 會串行執行全部的事件回調。

AsyncParalle

使用方式

在開始對源碼進行解析以前,咱們首先來看下 Tapable 一些重要的使用方式。

註冊事件回調

註冊事件回調有三個方法: taptapAsynctapPromise,其中 tapAsynctapPromise 不能用於 Sync 開頭的鉤子類,強行使用會報錯。tapAsynctapPromisetap 的使用方法相似,我單獨以 tap 舉例。

const { SyncHook } = require('tapable');
const hook = new SyncHook();

// 註冊事件回調
// 註冊事件回調的方法,例如 tap,它們的第一個參數能夠是事件回調的名字,也能夠是配置對象
hook.tap('first', () => {
  console.log('first');
});

hook.tap(
  // 配置對象
  {
    name: 'second',
  }, 
  () => {
    console.log('second');
  }
);複製代碼

執行順序

在註冊事件回調時,配置對象有兩個能夠改變執行順序的屬性:

  • stage:這個屬性的類型是數字,數字越大事件回調執行的越晚。
const { SyncHook } = require('tapable');
const hook = new SyncHook();

hook.tap('first', () => {
  console.log('first');
});

hook.tap({
    name: 'second',
  // 默認 stage 是 0,會按註冊順序添加事件回調到隊列尾部
  // 順序提早,stage 能夠置爲負數(比零小)
  // 順序提後,stage 能夠置爲正數(比零大)
  stage: 10,
}, () => {
  console.log('second');
});

hook.tap('third', () => {
  console.log('third');
});

hook.call('call');

/** * Console output: * * first * third * second */複製代碼
  • before:這個屬性的類型能夠是數組也能夠是一個字符串,傳入的是註冊事件回調的名稱。
const { SyncHook } = require('tapable');
const hook = new SyncHook();

hook.tap('first', (name) => {
  console.log('first', name);
});

hook.tap('second', (name) => {
  console.log('second', name);
});

hook.tap({
  name: 'third',
  // 把 third 事件回調放到 second 以前執行
  before: 'second',
}, (name) => {
  console.log('third', name);
});

hook.call('call');

/** * Console output: * * first * third * second */複製代碼

另外,這兩個屬性最好不要同時使用,比較容易混亂。

觸發事件

觸發事件的三個方法是與註冊事件回調的方法一一對應的,這點從方法的名字上也能看出來:call 對應 tapcallAsync 對應 tapAsyncpromise 對應 tapPromise。通常來講,咱們註冊事件回調時用了什麼方法,觸發時最好也使用對應的方法。

call

call 傳入參數的數量須要與實例化時傳遞給鉤子類構造函數的數組長度保持一致。

const { SyncHook } = require('tapable');
// 1.實例化鉤子類時傳入的數組,實際上只用上了數組的長度,名稱是爲了便於維護
const hook = new SyncHook(['name']);

// 3.other 會是 undefined,由於這個參數並無在實例化鉤子類的數組中聲明
hook.tap('first', (name, other) => {
  console.log('first', name, other);
});

// 2.實例化鉤子類的數組長度爲 1,這裏卻傳了 2 個傳入參數
hook.call('call', 'test');

/** * Console output: * * first call undefined */複製代碼

callAsync

callAsynccall 不一樣的是:在傳入了與實例化鉤子類的數組長度一致個數的傳入參數時,還須要在最後添加一個回調函數,不然在事件回調中執行回調函數可能會報錯。

const { AsyncSeriesHook } = require('tapable');
const hook = new AsyncSeriesHook(['name']);

// 事件回調接收到 callback
hook.tapAsync('first', (name, callback) => {
  console.log('first', name, callback);
  callback();
});

// 最後一個傳入參數是回調函數
hook.callAsync('callAsync', (error) => {
    console.log('callAsync', error);
});

/** * Console output: * * first callAsync [Function] * callAsync first */複製代碼

另外,事件回調中接收的 callback 必需要執行,不然不會執行後續的事件回調和 callAsync 傳入的回調,這是由於事件回調接收的 callback 已是對 callAsync 傳入的回調作了一層封裝的結果了,其內部有一個判斷邏輯:

  • 若是 callback 執行時不傳入值,就會繼續執行後續的事件回調。
  • 若是傳入錯誤信息,就會直接執行 callAsync 傳入的回調,再也不執行後續的事件回調;這實際上意味着事件回調執行有錯誤,也就是說 callAsync 傳入的是一個錯誤優先回調,既然是錯誤優先回調,那它是能夠接收第二個參數的,這個參數將被傳入正確的值,在這邊先不用管第二個參數,下文會有更詳盡的介紹。
hook.tapAsync('first', (name, callback) => {
  console.log('first', name, callback);
  // 繼續執行 second 事件回調
  callback();
});

hook.tapAsync('second', (name, callback) => {
  console.log('second', name, callback);
  // 執行 callAsync 傳入的回調
  // 第二個參數傳入沒有效果,由於 Sync 類型的 Hook 不對第二個參數作處理
  callback('second error', 'second result');
});

hook.tapAsync('third', (name, callback) => {
  console.log('third', name, callback);
  callback('third');
});

// 錯誤優先回調
// result 打印 undefined
hook.callAsync('callAsync', (error, result) => {
    console.log('callAsync', error, result);
});

/** * Console output: * * first callAsync [Function] * second callAsync [Function] * callAsync second error undefined */複製代碼

promise

promise 執行以後會返回一個 Promise 對象。在使用 tapPromise 註冊事件回調時,事件回調必須返回一個 Promise 對象,不然會報錯,這是爲了確保事件回調可以按照順序執行。

const { AsyncSeriesHook } = require('tapable');
const hook = new AsyncSeriesHook(['name']);

hook.tapPromise('first', (name) => {
  console.log('first', name);
  
  return Promise.resolve('first');
});

hook.tapPromise('second', (name) => {
  console.log('second', name);

  return Promise.resolve('second');
});

const promise = hook.promise('promise');

console.log(promise);

promise.then(value => {
  // value 是 undefined,不會接收到事件回調中傳入的值
  console.log('value', value);
}, reason => {
  // 事件回調返回的 Promise 對象狀態是 Rejected
  // reason 會有事件回調中傳入的錯誤信息
  console.log('reason', reason);
});

/** * Console output: * * first promise * Promise { <pending> } * second promise * value undefined */複製代碼

攔截器

咱們能夠給鉤子類添加攔截器,這樣就能對事件回調的註冊、調用以及事件的觸發進行監聽。

const { SyncHook } = require('tapable');
const hook = new SyncHook();

hook.intercept({
  // 註冊時執行
  register(tap) {
    console.log('register', tap);
    return tap;
  },
  // 觸發事件時執行
  call(...args) {
    console.log('call', args);
  },
  // 在 call 攔截器以後執行
  loop(...args) {
    console.log('loop', args);
  },
  // 事件回調調用前執行
  tap(tap) {
    console.log('tap', tap);
  },
});複製代碼

上下文

tap 或者其餘方法註冊事件回調以及添加攔截器時,能夠把配置對象中的 context 設置爲 true,這將讓咱們在事件回調或者攔截器方法中獲取 context 對象,這個對象會變成它們的第一個參數。

const { SyncHook } = require('tapable');
// 鉤子類的構造函數接收一個數組做爲參數,數組中是事件回調的參數名,代表事件回調須要幾個參數
const hook = new SyncHook(['name']);

hook.intercept({
  // 在添加攔截器的配置對象中啓用 context
  context: true,
  register(tap) {
    console.log('register', tap);
    return tap;
  },
  call(...args) {
    // args[0] 會變成 context 對象
    console.log('call', args);
  },
  loop(...args) {
    // args[0] 會變成 context 對象
    console.log('loop', args);
  },
  tap(context, tap) {
    // 第一個參數變成 context 對象
    context.fileChanged = true;
    console.log('tap', context, tap);
  },
});

hook.tap(
  {
    name: 'first',
    context: true,
  },
  // 第一個參數變成 context 對象
  (context, name) => {
    // context 中將會有 fileChanged 屬性
    // context: { fileChanged: true }
    console.log('first', context, name);
  }
);

hook.call('call');

/** * Console output: * * register { type: 'sync', fn: [Function], name: 'first', context: true } * call [ {}, 'call' ] * tap { fileChanged: true } { type: 'sync', fn: [Function], name: 'first', context: true } * first { fileChanged: true } call */複製代碼

鉤子類

Tapable 暴露的全部鉤子類都是繼承自 Hook 的,所以它們的構造函數統一隻接收一個數組參數,這個數組中是事件回調的參數名,主要做用是代表事件回調須要幾個參數。

接下來我會着重介紹 SyncHookAsyncSeriesBailHookAsyncSeriesWaterfallHookSyncLoopHookAsyncParallelHookAsyncParallelBailHook 這六個鉤子類,其餘鉤子的用法與它們相似。

SyncHook

Basic 類型的鉤子類很簡單就是按照順序執行事件回調,沒有任何其餘功能。

const { SyncHook } = require('tapable');
const hook = new SyncHook(['name']);

// 註冊事件回調
hook.tap('first', name => {
  console.log('first', name);
});

hook.tap('second', name => {
  console.log('second', name);
});

// 觸發事件
hook.call('call');

/** * Console output: * * first call * second call */複製代碼

AsyncSeriesBailHook

image.png

Bail 類型的鉤子類在事件回調有返回值時,會終止後續事件回調的運行,可是這隻對 tap 方法有效,下面來看下不一樣的註冊事件回調的方法是怎麼觸發這一功能的。

const { AsyncSeriesBailHook } = require('tapable');
const hook = new AsyncSeriesBailHook(['name']);

hook.tap('first', (name) => {
  console.log('first', name);
  // return 不爲 undefined 的值
  // return 'first return';
  /** * Console output: * * first callAsync * end null first return */
})

hook.tapAsync('second', (name, callback) => {
  console.log('second', name);
  // callback 的第一個參數須要傳入 null,代表沒有錯誤;
  // 第二個參數須要傳入不爲 undefined 的值;
  // 這即是錯誤優先回調的標準格式。
  // callback(null, 'second return');
  /** * Console output: * * first callAsync * second callAsync * end null second return */
  callback();
})

hook.tapPromise('third', (name, callback) => {
  console.log('third', name);
  // Promise 最終狀態被置爲 Fulfilled,而且值不爲 undefined
  // return Promise.resolve('third return');
  /** * Console output: * * first callAsync * second callAsync * third callAsync * end null third return */
  return Promise.resolve();
})

hook.tap('fourth', (name) => {
  console.log('fourth', name);
})

hook.callAsync('callAsync', (error, result) => {
  console.log('end', error, result);
});

// 使用 promise 方法觸發事件,事件回調中也是用同樣的方式來中止後續事件回調執行的;
// 區別主要在於處理錯誤和值的方式而已,這即是異步回調和 Promise 的不一樣之處了,
// 並不在本文探討範圍以內。
// const promise = hook.promise('promise');
// promise.then(value => {
// console.log('value', value);
// }, reason => {
// console.log('reason', reason);
// });複製代碼

AsyncSeriesWaterfallHook

image.png

Waterfall 類型的鉤子類在當前事件回調返回不爲 undefined 的值時,會把下一個事件回調的第一個參數替換成這個值,固然這也是針對 tap 註冊的事件回調,其餘註冊方法觸發這一功能的方式以下:

const { AsyncSeriesWaterfallHook } = require('tapable');
const hook = new AsyncSeriesWaterfallHook(['name']);

hook.tap('first', name => {
  console.log('first', name);
  // 返回不爲 undefined 的值
  return name + ' - ' + 'first';
})

hook.tapAsync('second', (name, callback) => {
  // 由於 tap 註冊的事件回調返回了值,因此 name 爲 callAsync - first
  console.log('second', name);
  // 在第二個參數中傳入不爲 undefined 的值
  callback(null, name + ' - ' + ' second');
})

hook.tapPromise('third', name => {
  console.log('third', name);
  // Promise 最終狀態被置爲 Fulfilled,而且值不爲 undefined
  return Promise.resolve(name + ' - ' + 'third');
})

hook.tap('fourth', name => {
  // 當前事件回調沒有返回不爲 undefined 的值,所以 name 沒有被替換
  console.log('fourth', name);
})

hook.callAsync('callAsync', (error, result) => {
  console.log('end', error, result);
});

/** * Console output: * * first callAsync * second callAsync - first * third callAsync - first - second * fourth callAsync - first - second - third * end null callAsync - first - second - third */複製代碼

SyncLoopHook

image.png

Loop 類型的鉤子類在當前執行的事件回調的返回值不是 undefined 時,會從新從第一個註冊的事件回調處執行,直到當前執行的事件回調沒有返回值。在下面的代碼中,我作了一些處理,使得它的打印值更爲直觀。

const { SyncLoopHook } = require('tapable');
const hook = new SyncLoopHook(['name']);
const INDENT_SPACE = 4;
let firstCount = 0;
let secondCount = 0;
let thirdCount = 0;
let indent = 0;

function indentLog(...text) {
  console.log(new Array(indent).join(' '), ...text);
}

hook.tap('first', name => {
  if (firstCount === 1) {
    firstCount = 0;
    indent -= INDENT_SPACE;
    indentLog('</callback-first>');
    return;
  }
  firstCount++;
  indentLog('<callback-first>');
  indent += INDENT_SPACE;
  return true;
})

hook.tap('second', name => {
  if (secondCount === 1) {
    secondCount = 0;
    indent -= INDENT_SPACE;
    indentLog('</callback-second>');
    return;
  }
  secondCount++;
  indentLog('<callback-second>');
  indent += INDENT_SPACE;
  return true;
})

hook.tap('third', name => {
  if (thirdCount === 1) {
    thirdCount = 0;
    indent -= INDENT_SPACE;
    indentLog('</callback-third>');
    return;
  }
  thirdCount++;
  indentLog('<callback-third>');
  indent += INDENT_SPACE;
  return true;
})

hook.call('call');

/** * Console output: * * <callback-first> * </callback-first> * <callback-second> * <callback-first> * </callback-first> * </callback-second> * <callback-third> * <callback-first> * </callback-first> * <callback-second> * <callback-first> * </callback-first> * </callback-second> * </callback-third> */複製代碼

AsyncParallelHook

AsyncParallel 類型的鉤子類會串行執行全部的事件回調,所以異步的事件回調中的錯誤並不會阻止其餘事件回調的運行。

const { AsyncParallelHook } = require('tapable');
const hook = new AsyncParallelHook(['name']);

hook.tap('first', (name) => {
  console.log('first', name);
})

hook.tapAsync('second', (name, callback) => {
  setTimeout(() => {
    console.log('second', name);
    callback();
  }, 2000);
})

hook.tapPromise('third', (name) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('third', name);
      // 拋出了錯誤,可是隻是提早執行了 callAsync 傳入回調函數,並不會阻止其餘事件回調運行
      reject('third error');
    }, 1000);
  });
})

hook.callAsync('callAsync', (error) => {
  console.log('end', error);
});

/** * Console output: * * first callAsync * third callAsync * end third error * second callAsync */複製代碼

AsyncParallelBailHook

這個類型的鉤子類看起來很讓人疑惑,以 AsyncParallel 開頭的鉤子類會串行執行全部事件回調,而 Bail 類型的鉤子類在事件回調返回不爲 undefined 時會終止後續事件回調的運行,這兩個結合起來要怎麼使用呢?

實際上,AsyncParallelBailHook 確實會串行執行全部事件回調,可是這個鉤子類中的事件回調返回值若是不爲 undefined,那麼 callAsync 傳入的回調函數的第二參數會是最早擁有返回值(這裏的返回值有多種方式:return resultcallback(null, result)return Promise.resolve(result))邏輯的事件回調的那個返回值,看以下代碼:

const { AsyncParallelBailHook } = require('tapable');
const hook = new AsyncParallelBailHook(['name']);

hook.tap('first', (name) => {
  console.log('first', name);
})

// 最早擁有返回值邏輯的事件回調
hook.tapAsync('second', (name, callback) => {
  setTimeout(() => {
    console.log('second', name);
    // 使用 callback 傳入了不是 undefined 的返回值。
    callback(null, 'second result');
  }, 1000);
})

// 雖然這個異步的事件回調中的 Promise 對象會比第二個異步的事件回調早執行完畢,
// 可是由於第二個事件回調中已經擁有了返回值的邏輯,
// 所以這個事件回調不會執行 callAsync 傳入的回調函數。
hook.tapPromise('third', (name) => {
  console.log('third', name);
  // 返回了一個 Promise 對象,而且它的狀態是 Fulfilled, 值不爲 undefined。
  return Promise.resolve('third result');
})

hook.callAsync('callAsync', (error, result) => {
  console.log('end', error, result);
});

/** * Console output: * * first callAsync * third callAsync * second callAsync * end null second result */複製代碼

原理解析

經過上文咱們已經大體瞭解了 Tapable 的使用方式,接下來咱們來看下 Tapable 到底是如何運做的。要探尋這個祕密,咱們須要從 tapcall 這兩個方法開始分析,這兩個方法都是創建在同步的前提下,所以會簡單一些。

另外,咱們說過全部的鉤子類都是繼承自 Hook 類,可是 Tapable 並無暴露它而且它也無法直接使用,所以下面主要把 SyncHook 鉤子類做爲入口進行解析。在解析的過程當中,咱們也須要在本地經過 npm 安裝 Tapable 庫,並寫一些簡單的 DEMO 進行調試,以便於理解。

// 安裝 Tapable
npm i -D tapable複製代碼
// index.js
// DEMO
const { SyncHook } = require('tapable');

const hook = new SyncHook(['name']);

hook.tap('run', (name) => {
  console.log('run', name);
});

hook.call('call');複製代碼

Tapable 的版本問題:

寫文章時 Tapable 最新的 latest 版本是 1.1.3,可是 Webpack 團隊已經在開發 2.0.0 版本,如今是 beta 階段,可能 API 還會有所變更,而且就目前來看 beta 版本對比 1.0.0 版本也沒啥大的改動,因此文章依舊選用 1.1.3 版本進行解析。

鉤子類位置

經過查看 Tapable 庫的 package.json,能夠找到 main: lib/index.js。下面是這個文件的代碼:

// tapable/lib/index.js
...
exports.SyncHook = require("./SyncHook");
...複製代碼

能夠看到 SyncHook 是從 SyncHook.js 中暴露出來的,SyncHook 類中暫時沒有發現什麼有價值的代碼,可是我能夠看到 tapAsynctapPromise 被重寫了,內部是拋出錯誤的邏輯,所以解釋了 SyncHook 類爲何不容許執行這兩個方法。

// tapable/lib/SyncHook.js
...
class SyncHook extends Hook {
    tapAsync() {
        throw new Error("tapAsync is not supported on a SyncHook");
    }

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

    compile(options) {
        factory.setup(this, options);
        return factory.create(options);
    }
}
...複製代碼

實例化

咱們要使用鉤子類,那必需要先進行實例化。SyncHook 中代碼很簡單,大部分邏輯都繼承了 Hook,咱們繼續向上追蹤。下面是 Hook 實例化的代碼,雖然給予了註釋,可是還有一些是須要結合詳細流程來看的代碼,下文有詳細解析,所以暫時沒必要理會。

// tapable/lib/Hook.js
...
class Hook {
    constructor(args) {
        if (!Array.isArray(args)) args = [];
    // 事件回調的接收參數名數組,主要用到了數組的長度,
    // 由於須要知道傳入了幾個參數,因此參數名主要是爲了便於維護。
        this._args = args;
    // 註冊的事件回調都會放到這個數組中,數組裏面已是排序好的事件回調。
        this.taps = [];
    // 保存着攔截器配置對象。
        this.interceptors = [];
    
    // 下面三個觸發事件的方法都通過了一層封裝。
        this.call = this._call;
        this.promise = this._promise;
        this.callAsync = this._callAsync;
    
    // 拼接代碼時使用。
        this._x = undefined;
    }
  ...
}
...複製代碼

註冊

實例化以後,咱們就要正式開始使用了,首先確定要先註冊事件回調,以後觸發事件纔有意義。下面是 tap 方法在 Hook 中的代碼:

// tapable/lib/Hook.js
...
class Hook {
    ...
    tap(options, fn) {
    // tap 的第一個參數能夠是當前事件回調的名字,也能夠是一個配置對象,
    // 下面是對這個的處理和一些容錯。
        if (typeof options === "string") options = { name: options };
        if (typeof options !== "object" || options === null)
            throw new Error(
                "Invalid arguments to tap(options: Object, fn: function)"
            );
    // 最後把 options 和 fn 等都合併到一個對象中去,
    // 其中有一個 type 屬性,在以後的處理時會根據 type 的不一樣觸發不一樣的邏輯
        options = Object.assign({ type: "sync", fn: fn }, options);
        if (typeof options.name !== "string" || options.name === "")
            throw new Error("Missing name for tap");
        options = this._runRegisterInterceptors(options);
        this._insert(options);
    }

    _runRegisterInterceptors(options) {
        for (const interceptor of this.interceptors) {
            if (interceptor.register) {
            // options 若是在 register 攔截器中從新返回,那它就會把 options 替換掉
                const newOptions = interceptor.register(options);
                if (newOptions !== undefined) options = newOptions;
            }
        }
        return options;
    }

    _resetCompilation() {
        this.call = this._call;
        this.callAsync = this._callAsync;
        this.promise = this._promise;
    }

    _insert(item) {
    // 重置三個調用事件的方法,暫時不用管它,解析完觸發流程以後就會知道它的做用。
        this._resetCompilation();
        let before;
    // 若是 before 屬性存在,把它轉換成 Set 數據結構
        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 (i > 0) {
            i--;
            const x = this.taps[i];
      // 第一次遍歷會添加到數組尾部。
      // taps 數組中每次都會存在相鄰的兩個相同的值,
      // 靠後的下標就是最後要被賦值的下標。
            this.taps[i + 1] = x;
            const xStage = x.stage || 0;
      // 若是碰到傳入 before 中有當前 name 的,就繼續遍歷,直到把 before 所有清空。
            if (before) {
                if (before.has(x.name)) {
                    before.delete(x.name);
                    continue;
                }
        // 若是 before 中的值沒被刪乾淨,
        // 新加入的事件回調最終會在最前面執行。
                if (before.size > 0) {
                    continue;
                }
            }
      // 若是當前 stage 大於傳入的 stage,那麼繼續遍歷。
            if (xStage > stage) {
                continue;
            }
            i++;
            break;
        }
    // 循環結束的時候 i 已是要賦值的那個下標了。
        this.taps[i] = item;
    }
}
  ...
}
...複製代碼

觸發

註冊流程並無什麼特殊之處,主要目的無非是把包含事件回調的配置對象放入一個數組中存儲並進行排序;而下來的觸發流程,其主體思想是執行編譯拼接成的靜態腳本,這樣可能會更加快速,具體能夠看 #86

經過上文咱們瞭解到 call 方法是在 Hook 類中定義的,在 Hook 類的構造函數中咱們看到 call 方法的值是 _call 方法。


// tapable/lib/Hook.js
...
class Hook {
    constructor(args) {
    ...
        this.call = this._call;
    ...
    }
  ...
}
...複製代碼

Hook 類文件的最下方找到下面代碼,會發現 _call 方法是經過 Object.defineProperties 定義到 Hook.prototype 上的,它的值是經過 createCompileDelegate 函數返回的。

// tapable/lib/Hook.js
...
function createCompileDelegate(name, type) {
    return function lazyCompileHook(...args) {
        this[name] = this._createCall(type);
        return this[name](...args);
    };
}

Object.defineProperties(Hook.prototype, {
    _call: {
        value: createCompileDelegate("call", "sync"),
        configurable: true,
        writable: true
    },
  ...
});複製代碼

createCompileDelegate 函數返回的最終結果:

this._call = function lazyCompileHook(...args) {
    this.call = this._createCall('sync');
    return this.call(...args);
};複製代碼

_call 方法執行以後,會去調用 _createCall 方法,_createCall 方法內部又會調用 compile 方法。

// tapable/lib/Hook.js
...
class Hook {
  ...
    compile(options) {
    // 提示必須在子類中重寫 compile 方法
        throw new Error("Abstract: should be overriden");
    }

    _createCall(type) {
        return this.compile({
            taps: this.taps,
            interceptors: this.interceptors,
            args: this._args,
            type: type
        });
    }
  ...
}
...複製代碼

可是,咱們看到 Hook 類中的 compile 方法裏面是拋出錯誤的邏輯,提示咱們必需要在子類中重寫這個方法,所以咱們須要到 SyncHook 類中查看重寫 Hook 類的 compile 方法。

// tapable/lib/SyncHook.js
...
class SyncHook extends Hook {
  ...
    compile(options) {
        factory.setup(this, options);
        return factory.create(options);
    }
}
...複製代碼

能夠看到 compile 內部使用了 factory.create 來返回值,到此咱們先停一停,回過頭來看 Hook 類中的 _createCall 方法,它的返回值(compile 咱們並無分析完,可是從它的名字也能看出來,它會編譯生成靜態腳本)最終會賦值給 call 方法,也就是說在第二次及以後執行 call 方法會直接執行已經編譯好的靜態腳本,這裏用到了惰性函數來優化代碼的運行性能。

class Hook {
    constructor(args) {
    // 第一次執行的時候 call 仍是等於 _call 的。
        this.call = this._call;
    }
}

this._call = function lazyCompileHook(...args) {
    // 第二次執行的時候,call 已是 this._createCall(...) 返回的已經編譯好的靜態腳本了。
    this.call = this._createCall('sync');
    return this.call(...args);
};複製代碼

compile 方法爲止,咱們來看下 call 方法的流程圖:

image.png

另外,咱們在解析註冊流程時,在添加事件回調的 _insert 方法開頭處看到了 _resetCompilation 方法,當時並無談到它的做用,可是在大體解析了 call 方法以後,咱們能夠談一談了。

...
class Hook {
    ...
  _resetCompilation() {
        this.call = this._call;
    ...
    }

    _insert(item) {
        this._resetCompilation();
    ...
  }
  ...
}
...複製代碼

能夠看到,在 _resetCompilation 方法內部把 call 方法的值重置成了 _call 方法,這是由於咱們執行 call 方法時執行的是編譯好的靜態腳本,因此若是註冊事件回調時不重置成 _call 方法,那麼由於惰性函數的緣故,執行的靜態腳本就不會包含當前註冊的事件回調了。

編譯

咱們屢次提到了編譯生成的靜態腳本,那它究竟是如何編譯?又長什麼樣呢?爲了揭開這個神祕面紗,讓咱們從新回到 SyncHook 中的 compile 方法中去,它內部使用到了 factory 變量,這個變量是實例化 SyncHookCodeFactory 類的結果,而 SyncHookCodeFactory 類繼承自 HookCodeFactory 類。

// tapable/lib/SyncHook.js
...
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}

const factory = new SyncHookCodeFactory();

class SyncHook extends Hook {
  ...
    compile(options) {
        factory.setup(this, options);
        return factory.create(options);
    }
}
...複製代碼

接下來咱們來看下 SyncHookCodeFactory 是如何被實例化的,SyncHookCodeFactory 自己並無構造函數,咱們向上查看它的父類 HookCodeFactoryHookCodeFactory 類的構造函數就是聲明瞭一些屬性,並無什麼特殊之處,另外 config 在目前版本的 Tapable 代碼中並無用上,因此不用管它。

// tapable/lib/HookCodeFactory.js
class HookCodeFactory {
    constructor(config) {
        this.config = config;
        this.options = undefined;
        this._args = undefined;
    }
}
...複製代碼

咱們繼續看 compile 方法,它內部調用了 factorysetup 方法和 create 方法,這兩個方法都在 HookCodeFactory 類中。咱們先看 setup 方法,在調用時,instance 接收的是 SyncHook 的實例,options 接收的是 Hook 類中 _createCell 方法中傳入的對象。

class HookCodeFactory {
  ...
  // factory.setup(this, options);
  // 註釋中的 this 都是 SyncHook 的實例
  // instance = this
    // options = {
    // taps: this.taps,
    // interceptors: this.interceptors,
    // args: this._args,
    // type: type
    // }
    setup(instance, options) {
        instance._x = options.taps.map(t => t.fn);
    }
  ...
}複製代碼

setup 方法內部的邏輯是把 taps 數組中的配置對象轉換成只包含事件回調的數組並返回給 SyncHook 實例的 _x 屬性,這個 _x 屬性會在靜態腳本內部派上大用場,咱們以後再看。

接下來,咱們來看 create 方法,compile 方法最終返回的就是這個方法執行後結果。咱們最主要關心的是使用 new Function 來建立函數的這一段邏輯,這正是 Tapable 的核心所在,也就是它生成了靜態腳本。

class HookCodeFactory {
  ...
    create(options) {
    // 初始化
        this.init(options);
        let fn;
        switch (this.options.type) {
            case "sync":
                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
                        })
                );
                break;
      ...
        }
    // 清除初始化的操做
        this.deinit();
        return fn;
    }
    
  init(options) {
        this.options = options;
    // 複製一份事件回調參數聲明數組
        this._args = options.args.slice();
    }

    deinit() {
        this.options = undefined;
        this._args = undefined;
    }
  ...
}複製代碼

new Function 的第一個參數是函數須要的形參,這個形參接收是用 , 分隔的字符串,可是實例化 SyncHook 類時傳入的參數聲明是數組類型,所以經過 args 方法拼接成字符串;args 方法接收一個對象,對象中有 beforeafterbefore 主要用於拼接 contextafter 主要用於拼接回調函數(例如 callAsync 傳入的回調函數)。

class HookCodeFactory {
  ...
    args({ before, after } = {}) {
        let allArgs = this._args;
        if (before) allArgs = [before].concat(allArgs);
        if (after) allArgs = allArgs.concat(after);
        if (allArgs.length === 0) {
            return "";
        } else {
            return allArgs.join(", ");
        }
    }
    ...
}複製代碼

new Function 的第二個參數即是函數體了,它是由 header 方法和 content 方法執行的結果拼接而成,咱們先看 header 方法,它內部就是聲明瞭一些以後須要用到變量,好比 _context 就是存儲 context 的對象,固然 _context 是對象仍是 undefined,取決於 taps 的配置對象是否啓用了 context,啓用那麼 _context 就是對象了。

另外,_x_taps_interceptors 的值實際上都是 SyncHook 類的實例上對應的屬性。這邊的 this 由於 new Function 生成的函數最終是賦值給 SyncHook 類的實例的 call 方法,因此是指向 SyncHook 類的實例的。

class HookCodeFactory {
  ...   
    header() {
        let code = "";
        if (this.needContext()) {
            code += "var _context = {};\n";
        } else {
            code += "var _context;\n";
        }
        code += "var _x = this._x;\n";
        if (this.options.interceptors.length > 0) {
            code += "var _taps = this.taps;\n";
            code += "var _interceptors = this.interceptors;\n";
        }
        for (let i = 0; i < this.options.interceptors.length; i++) {
            const interceptor = this.options.interceptors[i];
            if (interceptor.call) {
                code += `${this.getInterceptor(i)}.call(${this.args({ before: interceptor.context ? "_context" : undefined })});\n`;
            }
        }
        return code;
    }

    needContext() {
    // 找到一個配置對象啓用了 context 就返回 true
        for (const tap of this.options.taps) if (tap.context) return true;
        return false;
    }
    ...
}複製代碼

最後就是 content 方法了,這個方法並不在 HookCodeFactory 類中,所以咱們前往繼承它的子類,也就是 SyncHookCodeFactory 類中查看。

// tapable/lib/SyncHookCodeFactory.js
...
class SyncHookCodeFactory extends HookCodeFactory {
    // {
    // onError: err => `throw ${err};\n`,
    // onResult: result => `return ${result};\n`,
    // resultReturns: true,
    // onDone: () => "",
    // rethrowIfPossible: true
    // }
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}
...複製代碼

從上面代碼中能夠看到 content 方法內部調用了 HookCodeFactory 類的 callTapsSeries 方法,咱們須要繼續返回到 HookCodeFactory 類中。這邊有點繞的緣故在於:不一樣的鉤子它們的拼接邏輯是不同的,所以須要在子類中定義 content 方法,讓子類本身去寫拼接的邏輯。

下面是 callTapsSeries 方法的主體邏輯,其餘跟 SyncHook 不相關的代碼我給去掉了。

// tapable/lib/HookCodeFactory.js
class HookCodeFactory {
  ...   
    // {
    // onDone: () => ""
    // }
    callTapsSeries({
        onDone,
    }) {
        if (this.options.taps.length === 0) return onDone();
        let code = "";
        let current = onDone;
    // 從後向前遍歷
        for (let j = this.options.taps.length - 1; j >= 0; j--) {
            const i = j;
      // current 第一次的值是傳入的 onDone 函數,以後每次都是上一次拼接結果的箭頭函數,
      // 這樣可以保證總體的事件回調是按照順序拼接的。
            const done = current;
            const content = this.callTap(i, {
                onDone: done,
            });
            current = () => content;
        }
        code += current();
        return code;
    }
  ...
}
...複製代碼

每一個事件回調的調用代碼都是經過 callTap 方法拼接的,下面是它的代碼:

class HookCodeFactory {
  ...   
    callTap(tapIndex, { onDone }) {
        let code = "";
        code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
        const tap = this.options.taps[tapIndex];
        switch (tap.type) {
            case "sync":
        // 拼接調用代碼
        code += `_fn${tapIndex}(${this.args({ before: tap.context ? "_context" : undefined })});\n`;
                if (onDone) {
                    code += onDone();
                }
                break;
        }
        return code;
    }
  ...
}複製代碼

在全部的事件回調都遍歷以後,callTapsSeries 方法中的 current 變量的值會相似下面這樣,current 執行以後就會獲得執行事件回調的腳本了。

// 3.最終 current 的值
current = () => {
  var code = ` var _fn0 = _x[0]; _fn0(name); `

  // 2.第二次循環 current 的值
  code += () => {
    var code = ` var _fn1 = _x[1]; _fn1(name); `;

    // 1.第一次循環 current 的值
    code += () => {
      var code = ` var _fn2 = _x[2]; _fn2(name); `;
      
        return code;
    };

    return code;
  };
  
  return code;
}複製代碼

到此爲止,函數體解析完畢,這也就表明着靜態腳本已經所有拼接,下面是拼接好的一份靜態腳本示例:

// 靜態腳本
(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);
})複製代碼

剩下的事,即是把 new Function 生成的函數返回給 call 方法,而後讓 call 方法去執行靜態腳本了。咱們經過流程圖來回顧一下建立靜態腳本的過程:

image.png

總結

實際上我只是介紹了 Tapable 大部分主要的使用方式,像 MultiHook 之類的鉤子或者方法並無說明,這是由於文章的主要內容仍是在於鉤子類的使用以及下半部分原理解析相關,所以我只須要把它們涉及到的 API 和概念講解清楚即可,詳細的使用方式能夠看 Tapable Github 的 README.md

原理解析部分我把 SyncHook 鉤子類做爲入口,一步步深刻解析了整個註冊和觸發流程,最讓人印象深入的即是 Tapable 的編譯生成靜態腳本了。在大致流程瞭解以後,若是想要了解其餘鉤子類,我建議能夠先調試把靜態腳本取出來,看看它生成的是什麼腳本,這樣反推會更容易理解代碼。

參考

  1. 這纔是官方的tapable中文文檔
  2. Webpack tapable 使用研究
  3. 深刻源碼解析 tapable 實現原理

關於我

相關文章
相關標籤/搜索