Webpack tapable 使用研究

獵戶座小陳發表於2019-07-23

起因

想研究一下Webpack執行原理,發現都提到了tapable,搞得我雲裡霧裡,那我們就好好研究一番,這到底是個啥庫。

在Webpack官方文件上,檢視Webpack的宣告週期鉤子函式,可以看到下圖的內容:

image

可以看到run函式是AsyncSeriesHook型別的鉤子函式,這個就是tapable提供的鉤子型別了。

想理解Webpack的執行流程,先要了解這個鉤子的使用,近而瞭解Webpack在執行的過程中,是如何呼叫各種外掛的。

開始研究

先搭建一個最簡單的專案

依照慣例,我們先搭建個最簡單的專案:

image

安裝必要的庫:

npm install --save-dev wepback
npm install --save-dev webpack-cli
npm install --save-dev webpack-dev-server

npm install --save tapable
複製程式碼

我們在src下寫我們的測試程式碼,然後執行起來,看我們的實驗結果。 webpack.config.js的配置如下:

module.exports = {
  entry: {
    index: __dirname + "/src/index.js",
  },
  output: {
    path: __dirname + "/dist",//打包後的檔案存放的地方
    filename: "[name].js", //打包後輸出檔案的檔名
    chunkFilename: '[name].js',
  },
  mode: 'development',
  devtool: false,

  devServer: {
    contentBase: "./dist",//本地伺服器所載入的頁面所在的目錄
    historyApiFallback: true,//不跳轉
    inline: true//實時重新整理
  },
}
複製程式碼

在package.json中配置好啟動指令碼,使用npm run server即可檢視執行結果:

"scripts": {
    "start": "webpack",
    "server": "webpack-dev-server --open"
},
複製程式碼

同步鉤子

第一個鉤子 SyncHook

tapable的github地址是:github.com/webpack/tap…

這裡給出的是tapable-1分支的地址,我看這個分支才是Webpack現在使用的。

依照它readme.md中介紹,tapable暴露了很多的Hook類,可以幫助我們為外掛建立鉤子。

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

這麼多Hook,我們一個一個來,先看看SyncHook怎麼使用,在index.js中寫下:

import { SyncHook } from 'tapable';

const hook = new SyncHook(); // 建立鉤子物件
hook.tap('logPlugin', () => console.log('被勾了')); // tap方法註冊鉤子回撥
hook.call(); // call方法呼叫鉤子,列印出‘被勾了’三個字
複製程式碼

使用npm run server,在瀏覽器中執行成功。也成功列印‘被勾了’。用起來 還是很簡單的。

這就是經典的事件註冊和觸發機制啊。實際使用的時候,宣告事件和觸發事件的程式碼通常在一個類中,註冊事件的程式碼在另一個類(我們的外掛)中。程式碼如下:

// Car.js
import { SyncHook } from 'tapable';

export default class Car {
  constructor() {
    this.startHook = new SyncHook();
  }

  start() {
    this.startHook.call();
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.startHook.tap('startPlugin', () => console.log('我係一下安全帶'));
car.start();
複製程式碼

鉤子的使用基本就是這個意思,Car中只負責宣告和呼叫鉤子,真正的執行邏輯,不再Car中,而是在註冊它的index.js之中,是在Car之外。這樣就做到了很好的解耦。

對於Car而言,通過這種註冊外掛的方式,豐富自己的功能。

向外掛傳遞引數

我希望這樣:

// index.js
import Car from './Car';

const car = new Car();
car.accelerateHook.tap('acceleratePlugin', (speed) => console.log(`加速到${speed}`));
car.accelerate(100); // 呼叫時,將100傳給外掛回撥的speed
複製程式碼

可以這樣寫Car類:

import { SyncHook } from 'tapable';

export default class Car {
  constructor() {
    this.startHook = new SyncHook();
    this.accelerateHook = new SyncHook(["newSpeed"]); // 在宣告的時候,說明我這個Hook需要一個引數即可。
  }

  start() {
    this.startHook.call();
  }

  accelerate(speed) {
    this.accelerateHook.call(speed);
  }
}
複製程式碼

這樣就完成了帶引數的Hook,SyncHook引數是傳遞個陣列,就是說也可以讓我們傳遞多個引數,如 new SyncHook(["arg1","arg2","arg3"])。這樣在call的時候也可以傳遞三個引數,在回撥函式,也能接收到call的三個引數。

我們的Car類,就是一個Tapable類,事件的宣告和呼叫中心。

第二個鉤子 SyncBailHook

Hook的註冊/呼叫機制我們大致瞭解了,SyncHook的工作很完美,但是tapable還提供了很多Hook,這些Hook又是解決什麼問題的呢?

原因在於對於某一個事件,我們可以註冊多次,如下:

const car = new Car();
car.hooks.brake.tap('brakePlugin1', () => console.log(`剎車1`));
car.hooks.brake.tap('brakePlugin2', () => console.log(`剎車2`));
car.hooks.brake.tap('brakePlugin3', () => console.log(`剎車3`));

car.brake(); // 會列印‘剎車1’‘剎車2’‘剎車3’
複製程式碼

這裡我們為Car類新增了hooks.brake的鉤子,和一個brake方法。brake的鉤子被註冊了3次,我們呼叫brake方式的時候,3個外掛都接受到了事件。

我們稍微重構了一下Car類,據說這種寫法,更符合tapable使用的最佳實踐,其實就是將鉤子都放到一個hooks欄位裡。Car程式碼如下:

import { SyncHook, SyncBailHook } from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      start: new SyncHook(),
      accelerate: new SyncHook(["newSpeed"]),
      brake: new SyncBailHook(), // 這裡我們要使用SyncBailHook鉤子啦
    };
  }

  start() {
    this.hooks.start.call();
  }

  accelerate(speed) {
    this.hooks.accelerate.call(speed);
  }

  brake() {
    this.hooks.brake.call();
  }
}
複製程式碼

我們現在要滿足這樣一個需求,不管你註冊多少外掛,我只想被剎兩次,就不通知別的外掛了。這時候就SyncBailHook就可以,程式碼如下:

import Car from './Car';

const car = new Car();
car.hooks.brake.tap('brakePlugin1', () => console.log(`剎車1`));
// 只需在不想繼續往下走的外掛return非undefined即可。
car.hooks.brake.tap('brakePlugin2', () => { console.log(`剎車2`); return 1; }); 
car.hooks.brake.tap('brakePlugin3', () => console.log(`剎車3`));

car.brake(); // 只會列印‘剎車1’‘剎車2’
複製程式碼

SyncBailHook就是根據每一步返回的值來決定要不要繼續往下走,如果return了一個非undefined的值 那就不會往下走,注意 如果什麼都不return 也相當於return了一個undefined。

由此推測,tabable提供各類鉤子,目的是處理這些外部外掛的關係。

第三個鉤子 SyncWaterfallHook

搞明白了第二個鉤子,接下來的鉤子就很好理解了,這裡直接給出SyncWaterfallHook的定義:它的每一步都依賴上一步的執行結果,也就是上一步return的值就是下一步的引數。

我們改造一下accelerate鉤子為SyncWaterfallHook:

import { SyncHook, SyncBailHook, SyncWaterfallHook } from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      start: new SyncHook(),
      accelerate: new SyncWaterfallHook(["newSpeed"]), // 重點在這裡
      brake: new SyncBailHook(),
    };
  }

  start() {
    this.hooks.start.call();
  }

  accelerate(speed) {
    this.hooks.accelerate.call(speed);
  }

  brake() {
    this.hooks.brake.call();
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.hooks.accelerate.tap('acceleratePlugin1', (speed) => { console.log(`加速到${speed}`); return speed + 100; });
car.hooks.accelerate.tap('acceleratePlugin2', (speed) => { console.log(`加速到${speed}`); return speed + 100; });
car.hooks.accelerate.tap('acceleratePlugin3', (speed) => { console.log(`加速到${speed}`); });

car.accelerate(50); // 列印‘加速到50’‘加速到150’‘加速到250’

複製程式碼

第四個鉤子 SyncLoopHook

SyncLoopHook是同步的迴圈鉤子,它的外掛如果返回一個非undefined。就會一直執行這個外掛的回撥函式,直到它返回undefined。

我們把start的鉤子改成SyncLoopHook。

import { SyncHook, SyncBailHook, SyncWaterfallHook, SyncLoopHook } from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      start: new SyncLoopHook(), // 重點看這裡
      accelerate: new SyncWaterfallHook(["newSpeed"]),
      brake: new SyncBailHook(),
    };
  }

  start() {
    this.hooks.start.call();
  }

  accelerate(speed) {
    this.hooks.accelerate.call(speed);
  }

  brake() {
    this.hooks.brake.call();
  }
}
複製程式碼
// index.js
import Car from './Car';

let index = 0;
const car = new Car();
car.hooks.start.tap('startPlugin1', () => {
  console.log(`啟動`);
  if (index < 5) {
    index++;
    return 1;
  }
}); // 這回我們得到一輛破車,啟動6次才會啟動成功。

car.hooks.start.tap('startPlugin2', () => {
  console.log(`啟動成功`);
});

car.start(); // 列印‘啟動’6次,列印‘啟動成功’一次。

複製程式碼

非同步鉤子

當外掛的回撥函式,存在非同步的時候。就需要使用非同步的鉤子了。

第五個鉤子 AsyncParallelHook

AsyncParallelHook處理非同步並行執行的外掛。

我們在Car類中新增calculateRoutes,使用AsyncParallelHook。再寫一個calculateRoutes方法,呼叫callAsync方法時會觸發鉤子執行。這裡可以傳遞一個回撥,當所有外掛都執行完畢的時候,被呼叫。

// Car.js
import {
  ...
  AsyncParallelHook,
} from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      ...
      calculateRoutes: new AsyncParallelHook(),
    };
  }

  ...
  
  calculateRoutes(callback) {
    this.hooks.calculateRoutes.callAsync(callback);
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.hooks.calculateRoutes.tapAsync('calculateRoutesPlugin1', (callback) => {
  setTimeout(() => {
    console.log('計算路線1');
    callback();
  }, 1000);
});

car.hooks.calculateRoutes.tapAsync('calculateRoutesPlugin2', (callback) => {
  setTimeout(() => {
    console.log('計算路線2');
    callback();
  }, 2000);
});

car.calculateRoutes(() => { console.log('最終的回撥'); }); // 會在1s的時候列印‘計算路線1’。2s的時候列印‘計算路線2’。緊接著列印‘最終的回撥’
複製程式碼

我覺得AsyncParallelHook的精髓就在於這個最終的回撥。當所有的非同步任務執行結束後,再最終的回撥中執行接下來的程式碼。可以確保所有的外掛的程式碼都執行完畢後,再執行某些邏輯。如果不需要這個最終的回撥來執行某些程式碼,那使用SyncHook就行了啊,反正你又不關心外掛中的程式碼什麼時候執行完畢。

AsyncParallelHook的Promise方式

除了使用tapAsync/callAsync的方式使用AsyncParallelHook。還可以使用tapPromise/promise的方式。

程式碼如下:

// Car.js
import {
  SyncHook, SyncBailHook, SyncWaterfallHook, SyncLoopHook,
  AsyncParallelHook,
} from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      ...
      calculateRoutes: new AsyncParallelHook(),
    };
  }

  ...

  calculateRoutes() {
    return this.hooks.calculateRoutes.promise();
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin1', () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線1');
      resolve();
    }, 1000);
  });
});

car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin2', () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線2');
      resolve();
    }, 2000);
  });
});

car.calculateRoutes().then(() => { console.log('最終的回撥'); });
複製程式碼

只是用法上有區別,效果同tapAsync/callAsync一樣的。

第六個鉤子 AsyncParallelBailHook

這個我靠猜都知道它是怎麼回事了,外掛都並行執行,有一個執行成功並且傳遞的值不是undefined,就呼叫最終的回撥。

來驗證一下猜想:

// Car.js
import {
  AsyncParallelBailHook,
} from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      drift: new AsyncParallelBailHook(),
    };
  }

  drift(callback) {
    this.hooks.drift.callAsync(callback);
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.hooks.drift.tapAsync('driftPlugin1', (callback) => {
  setTimeout(() => {
    console.log('計算路線1');
    callback(1); // 這裡傳遞個1,不是undefined
  }, 1000);
});

car.hooks.drift.tapAsync('driftPlugin2', (callback) => {
  setTimeout(() => {
    console.log('計算路線2');
    callback(2); // 這裡傳遞個2,不是undefined
  }, 2000);

});

car.drift((result) => { console.log('最終的回撥', result); }); 
// 列印結果是,等1s列印'計算路線1' ,馬上列印‘最終的回撥 1’,再到第2s,列印'計算路線2'
複製程式碼

我們來分析下列印結果,說明AsyncParallelBailHook在外掛呼叫callback時,如果給callback傳引數,就會立馬呼叫最終的回撥函式。但並不會阻止其他外掛繼續執行自己的非同步,只不過最終的回撥拿不到這些比較慢的外掛的回撥結果了。

同樣的AsyncParallelBailHook也有promise的呼叫方式,與AsyncParallelHook類似,我們們就不實驗了。

第七個鉤子 AsyncSeriesHook

說完了並行,那一定有序列。就是外掛一個一個的按順序執行。

實驗程式碼如下:

// Car.js
import {
  AsyncSeriesHook,
} from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      calculateRoutes: new AsyncSeriesHook(),
    };
  }

  calculateRoutes() {
    return this.hooks.calculateRoutes.promise();
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin1', () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線1');

      resolve();
    }, 1000);
  });
});

car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin2', () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線2');
      resolve();
    }, 2000);
  });
});

car.calculateRoutes().then(() => { console.log('最終的回撥'); });
// 1s過後,列印計算路線1,再過2s(而不是到了第2s,而是到了第3s),列印計算路線2,再立馬列印最終的回撥。
複製程式碼

我們這裡直接使用promise的格式,一樣執行。

第八個鉤子 AsyncSeriesBailHook

序列執行,並且只要一個外掛有返回值,立馬呼叫最終的回撥,並且不會繼續執行後續的外掛。

實驗程式碼如下:

// Car.js
import {
  AsyncSeriesBailHook,
} from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      calculateRoutes: new AsyncSeriesBailHook(),
    };
  }

  calculateRoutes() {
    return this.hooks.calculateRoutes.promise();
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin1', () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線1');

      resolve(1);
    }, 1000);
  });
});

car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin2', () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線2');
      resolve(2);
    }, 2000);
  });
});

car.calculateRoutes().then(() => { console.log('最終的回撥'); });
// 1s過後,列印計算路線1,立馬列印最終的回撥,不會再執行計算路線2了。
複製程式碼

第九個鉤子 AsyncSeriesWaterfallHook

序列執行,並且前一個外掛的返回值,會作為後一個外掛的引數。

程式碼如下:

// Car.js
import {
  AsyncSeriesWaterfallHook,
} from 'tapable';

export default class Car {
  constructor() {
    this.hooks = {
      calculateRoutes: new AsyncSeriesWaterfallHook(['home']), // 要標註一下,要傳引數啦
    };
  }

  calculateRoutes() {
    return this.hooks.calculateRoutes.promise();
  }
}
複製程式碼
// index.js
import Car from './Car';

const car = new Car();
car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin1', (result) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線1', result);

      resolve(1);
    }, 1000);
  });
});

car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin2', (result) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('計算路線2', result);
      resolve(2);
    }, 2000);
  });
});

car.calculateRoutes().then(() => { console.log('最終的回撥'); });
// 1s過後,列印計算路線1 undefined,再過2s列印計算路線2 北京,然後立馬列印最終的回撥。
複製程式碼

列印結果如圖:

image

封裝外掛

我們將註冊外掛的邏輯單獨封裝出來,如下:

export default class CalculateRoutesPlugin {
  // 呼叫apply方法就可以完成註冊
  apply(car) {
    car.hooks.calculateRoutes.tapPromise('calculateRoutesPlugin', (result) => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          console.log('計算路線1', result);

          resolve('北京');
        }, 1000);
      });
    });
  }
}
複製程式碼

在index.js中呼叫:

// index.js
import Car from './Car';
import CalculateRoutesPlugin from './CalculateRoutesPlugin';
const car = new Car();
const calculateRoutesPlugin = new CalculateRoutesPlugin();

calculateRoutesPlugin.apply(car); // 此節重點邏輯

car.calculateRoutes().then(() => { console.log('最終的回撥'); });
// 執行正常,會列印'計算路線1'
複製程式碼

看到這裡,程式碼和Webpack的使用方式就差不多了,car類似Webpack中的Compiler/Compilation。index.js比作是Webpack的執行類,使用我們的Car(類比Compiler/Compilation),使用注入來的CalculateRoutesPlugin(類比Webpack的各種外掛)。完成打包工作。

Tapable

tapable的readme中沒有介紹一個類,就是Tapable,但是是可以使用到的,如下

const {
  Tapable
} = require("tapable");
 
export default class Car extends Tapable {
    ...
}
複製程式碼

如果看tapable原始碼的話,看不到這個類,但是切換到tapable-1分支,可以看到。

在Webpack原始碼中,Compiler和Compilation都和上面的Car一樣,繼承自Tapable。

那Tapable究竟幹了啥啊,看了一下它原始碼,發現它啥也沒幹,就是一個標誌,表示我這個類是一個可以註冊外掛的類。

雖然沒有什麼增強的功能,但是此時的Car有了兩個限制。如下:

const car = new Car();
car.apply(); // 報錯  Tapable.apply is deprecated. Call apply on the plugin directly instead
car.plugin(); // 報錯 Tapable.plugin is deprecated. Use new API on `.hooks` instead
複製程式碼

這兩個方法不讓用了,我理解是為Webpack而做的限制,提醒外掛作者升級自己的外掛,使用最新的實踐。

鉤子型別(Hook Types)

上面我們研究了鉤子們的使用,接下來做一些總結。首先來說鉤子的型別。

按被註冊外掛們的執行邏輯來分鉤子

  1. 基本鉤子。註冊的外掛順序執行。如SyncHook、AsyncParallelHook、AsyncSeriesHook。

  2. 瀑布流鉤子。前一個外掛的返回值,是後一個外掛的入參。如SyncWaterfallHook,AsyncSeriesWaterfallHook。

  3. Bail鉤子。Bail鉤子是指一個外掛返回非undefined的值,就不繼續執行後續的外掛。我理解這裡Bail是取迅速離開的意思。如:SyncBailHook,AsyncSeriesBailHook

  4. 迴圈鉤子。迴圈呼叫外掛,直到外掛的返回值是undefined。如SyncLoopHook。

按時序來區分鉤子

  1. 同步鉤子。Sync開頭的鉤子
  2. 非同步序列鉤子。AsyncSeries開頭的鉤子。
  3. 非同步並行鉤子。AsyncParallel開頭的鉤子。

攔截器(Interception)

我們還可以為鉤子新增攔截器。 一個外掛從對鉤子註冊,到鉤子呼叫,再到外掛響應。我們都可以通過攔截器監聽到。

car.hooks.calculateRoutes.intercept({
  call: (...args) => {
    console.log(...args, 'intercept call');
  }, // 外掛被call時響應。
  //
  register: (tap) => {
    console.log(tap, 'ntercept register');

    return tap;
  },// 外掛用tap方法註冊時響應。
  loop: (...args) => {
    console.log(...args, 'intercept loop')
  },// loop hook的外掛被呼叫時響應。
  tap: (tap) => {
    console.log(tap, 'intercept tap')
  } // hook的外掛被呼叫時響應。
})
複製程式碼

上下文(Context)

外掛和攔截器都可以往裡面傳一個上下文物件的引數,該物件可用於向後續外掛和攔截器傳遞任意值。

myCar.hooks.accelerate.intercept({
	context: true, // 這裡配置啟用上下文物件
	tap: (context, tapInfo) => {
		if (context) { // 這裡就可以拿到上下文物件
			context.hasMuffler = true;
		}
	}
});

myCar.hooks.accelerate.tap({
	name: "NoisePlugin",
	context: true
}, (context, newSpeed) => {
    // 這裡可以拿到攔截器裡的上下文物件,然後我們在外掛裡利用它的值做相應操作。
	if (context && context.hasMuffler) {
		console.log("Silence...");
	} else {
		console.log("Vroom!");
	}
});
複製程式碼

結束語

tapable的簡單使用,就研究到這裡。它為外掛機制提供了很強大的支援,不但讓我們對主體(Car)註冊各種外掛,還能控制外掛彼此的關係,控制自身相應的時機。

在Webpack中使用這樣的庫,再合適不過,Webpack是一個外掛的集合,通過tapable,有效的將外掛們組織起來,在合理的時機,合理的呼叫。

相關文章