Webpack揭祕——走向高階前端的必經之路

jerryOnlyZRJ發表於2018-09-28

隨著前端工程化的不斷髮展,構建工具也在不斷完善。作為大前端時代的新寵,webpack漸漸成為新時代前端工程師不可或缺的構建工具,隨著webpack4的不斷迭代,我們享受著構建效率不斷提升帶來的快感,配置不斷減少的舒適,也一直為重寫的構建事件鉤子機制煞費苦心,為外掛各種不相容心灰意冷,雖然過程痛苦,但結果總是美好的。經歷了一番繁瑣的配置後,我常常會想,這樣一個精巧的工具,在構建過程中做了什麼?我也是抱著這樣的好奇,潛心去翻閱相關書籍和官方文件,終於對其中原理有所瞭解,那麼現在,就讓我們一起來逐步揭開webpack這個黑盒的神祕面紗,探尋其中的執行機制吧。

本文將以三部分內容:Webpack執行機制、編寫自定義webpack loader、編寫自定義webpack plugin 直擊webpack原理痛點,開啟你通向高階前端工程師之路~

本次webpack系列文章可參照專案:github.com/jerryOnlyZR…

本系列文章使用的webpack版本為4,如有其他版本問題可提issue或者直接在文章下方的評論區留言。

1.Webpack執行機制

1.1.webpack執行機制概述

在閱讀本文之前,我就預設電腦前的你已經掌握了webpack的基本配置,能夠獨立搭建一款基於webpack的前端自動化構建體系,所以這篇文章不會教你如何配置或者使用webpack,自然具體概念我就不做介紹了,直面主題,開始講解webpack原理。

webpack的執行過程可以簡單概述為如下流程:

初始化配置引數 -> 繫結事件鉤子回撥 -> 確定Entry逐一遍歷 -> 使用loader編譯檔案 -> 輸出檔案

接下來,我們將對具體流程逐一介紹。

1.2.webpack執行流程

1.2.1.webpack事件流初探

在分析webpack執行流程時,我們可以藉助一個概念,便是webpack的事件流機制。

什麼是webpack事件流?

Webpack 就像一條生產線,要經過一系列處理流程後才能將原始檔轉換成輸出結果。 這條生產線上的每個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。 外掛就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源做處理。 Webpack 通過 Tapable 來組織這條複雜的生產線。 Webpack 在執行過程中會廣播事件,外掛只需要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運作。 Webpack 的事件流機制保證了外掛的有序性,使得整個系統擴充套件性很好。 --吳浩麟《深入淺出webpack》

我們將webpack事件流理解為webpack構建過程中的一系列事件,他們分別表示著不同的構建週期和狀態,我們可以像在瀏覽器上監聽click事件一樣監聽事件流上的事件,並且為它們掛載事件回撥。我們也可以自定義事件並在合適時機進行廣播,這一切都是使用了webpack自帶的模組 Tapable 進行管理的。我們不需要自行安裝 Tapable ,在webpack被安裝的同時它也會一併被安裝,如需使用,我們只需要在檔案裡直接 require 即可。

Tapable的原理其實就是我們在前端進階過程中都會經歷的EventEmit,通過釋出者-訂閱者模式實現,它的部分核心程式碼可以概括成下面這樣:

class SyncHook{
    constructor(){
        this.hooks = [];
    }

    // 訂閱事件
    tap(name, fn){
        this.hooks.push(fn);
    }

    // 釋出
    call(){
        this.hooks.forEach(hook => hook(...arguments));
    }
}
複製程式碼

Tapable的具體內容可以參照文章:《webpack4.0原始碼分析之Tapable》 。其使用方法我們會在後文中的“3.編寫自定義webpack plugin”模組再做深入介紹。

因為webpack4重寫了事件流機制,所以如果我們翻閱 webpack hook 的官方文件會發現資訊特別繁雜,但是在實際使用中,我們只需要記住幾個重要的事件就足夠了。

1.2.2.webpack執行流程詳解

在講解webpack流程之前先附上一張我自己繪製的執行流程圖:

Webpack揭祕——走向高階前端的必經之路

  • 首先,webpack會讀取你在命令列傳入的配置以及專案裡的 webpack.config.js 檔案,初始化本次構建的配置引數,並且執行配置檔案中的外掛例項化語句,生成Compiler傳入plugin的apply方法,為webpack事件流掛上自定義鉤子。
  • 接下來到了entryOption階段,webpack開始讀取配置的Entries,遞迴遍歷所有的入口檔案
  • Webpack接下來就開始了compilation過程。會依次進入其中每一個入口檔案(entry),先使用使用者配置好的loader對檔案內容進行編譯(buildModule),我們可以從傳入事件回撥的compilation上拿到module的resource(資源路徑)、loaders(經過的loaders)等資訊;之後,再將編譯好的檔案內容使用acorn解析生成AST靜態語法樹(normalModuleLoader),分析檔案的依賴關係逐個拉取依賴模組並重覆上述過程,最後將所有模組中的require語法替換成__webpack_require__來模擬模組化操作。
  • emit階段,所有檔案的編譯及轉化都已經完成,包含了最終輸出的資源,我們可以在傳入事件回撥的compilation.assets 上拿到所需資料,其中包括即將輸出的資源、程式碼塊Chunk等等資訊。

1.2.3.什麼是AST?

在1.2.2中,我們看到了一個陌生的字眼——AST,上網一搜:

在電腦科學中,抽象語法樹(Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是原始碼語法結構的一種抽象表示。它以樹狀的形式表現程式語言的語法結構,樹上的每個節點都表示原始碼中的一種結構。之所以說語法是“抽象”的,是因為這裡的語法並不會表示出真實語法中出現的每個細節。比如,巢狀括號被隱含在樹的結構中,並沒有以節點的形式呈現;而類似於 if-condition-then 這樣的條件跳轉語句,可以使用帶有兩個分支的節點來表示。 --維基百科

其實,你只要記著,AST是一棵樹,像這樣:

Webpack揭祕——走向高階前端的必經之路

轉換成AST的目的就是將我們書寫的字串檔案轉換成計算機更容易識別的資料結構,這樣更容易提取其中的關鍵資訊,而這棵樹在計算機上的表現形式,其實就是一個單純的Object。

Webpack揭祕——走向高階前端的必經之路

示例是一個簡單的宣告賦值語句,經過AST轉化後各部分內容的含義就更為清晰明瞭了。

1.2.4.webpack輸出結果解析

接下來,我們來看看webpack的輸出內容。如果我們沒有設定splitChunk,我們只會在dist目錄下看到一個main.js輸出檔案,過濾掉沒用的註釋還有一些目前不需要去考慮的Funciton,得到的程式碼大概是下面這樣:

(function (modules) {
  //  快取已經載入過的module的exports
  //  module在exports之前還是有js需要執行的,快取的目的就是優化這一過程
  // The module cache
  var installedModules = {};

  // The require function
  /**
   * 模擬CommonJS require()
   * @param {String} moduleId 模組路徑
   */
  function __webpack_require__(moduleId) {

    // Check if module is in cache
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    // 執行單個module JS Function並填充installedModules與module
    // function mudule(module, __webpack_exports__[, __webpack_require__])
    // Execute the module function
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    // Flag the module as loaded
    module.l = true;

    // Return the exports of the module
    return module.exports;
  }

  // expose the modules object (__webpack_modules__)
  __webpack_require__.m = modules;

  // expose the module cache
  __webpack_require__.c = installedModules;

 ......

  // __webpack_public_path__
  __webpack_require__.p = "";

  // 載入Entry並返回Entry的exports
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
  // modules其實就是一個物件,鍵是模組的路徑,值就是模組的JS Function
  ({
    "./src/index.js": function (module, __webpack_exports__, __webpack_require__) {
      "use strict";
      eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _module_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./module.js */ \"./src/module.js\");\n/* harmony import */ var _module_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_module_js__WEBPACK_IMPORTED_MODULE_0__);\n{};\nconsole.log(_module_js__WEBPACK_IMPORTED_MODULE_0___default.a.s);\n\n//# sourceURL=webpack:///./src/index.js?");
    },
    "./src/module.js": function (module, exports) {
      eval("{};var s = 123;\nconsole.log(s);\nmodule.exports = {\n  s: s\n};\n\n//# sourceURL=webpack:///./src/module.js?");
    }
  });
複製程式碼

我們都知道其實webpack在瀏覽器實現模組化的本質就是將所有的程式碼都注入到同一個JS檔案裡,現在我們可以清晰明瞭地看出webpack最後生成的也不過只是一個IIFE,我們引入的所有模組都被一個function給包起來組裝成一個物件,這個物件作為IIFE的實參被傳遞進去。

但如果我們配置了splitChunk,這時候輸出的檔案就和你的Chunk掛鉤了,程式碼也變了模樣:

 //@file: dist/common/runtime.js
 // 當配置了splitChunk之後,此時IIFE的形參modules就成了擺設,
 // 真正的module還有chunk都被存放在了一個掛載在window上的全域性陣列`webpackJsonp`上了
 (function(modules) { // webpackBootstrap
	 // install a JSONP callback for chunk loading
	 /**
	  * webpackJsonpCallback 處理chunk資料
	  * @param {Array} data  [[chunkId(chunk名稱)], modules(Object), [...other chunks(所有需要的chunk)]]
	  */
 	function webpackJsonpCallback(data) {
        // chunk的名稱,如果是entry chunk也就是我們entry的key
 		var chunkIds = data[0];
        // 依賴模組
 		var moreModules = data[1];
 		var executeModules = data[2];

 		// add "moreModules" to the modules object,
 		// then flag all "chunkIds" as loaded and fire callback
 		var moduleId, chunkId, i = 0, resolves = [];
 		for(;i < chunkIds.length; i++) {
 			chunkId = chunkIds[i];
 			if(installedChunks[chunkId]) {
 				resolves.push(installedChunks[chunkId][0]);
 			}
 			installedChunks[chunkId] = 0;
 		}
 		for(moduleId in moreModules) {
 			if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
 				modules[moduleId] = moreModules[moduleId];
 			}
 		}
 		if(parentJsonpFunction) parentJsonpFunction(data);

 		while(resolves.length) {
 			resolves.shift()();
 		}

 		// add entry modules from loaded chunk to deferred list
 		deferredModules.push.apply(deferredModules, executeModules || []);

 		// run deferred modules when all chunks ready
 		return checkDeferredModules();
 	};
 	function checkDeferredModules() {
 		var result;
 		for(var i = 0; i < deferredModules.length; i++) {
 			var deferredModule = deferredModules[i];
 			var fulfilled = true;
 			for(var j = 1; j < deferredModule.length; j++) {
 				var depId = deferredModule[j];
 				if(installedChunks[depId] !== 0) fulfilled = false;
 			}
 			if(fulfilled) {
 				deferredModules.splice(i--, 1);
 				result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
 			}
 		}
 		return result;
 	}

 	// The module cache
 	var installedModules = {};

	// 快取chunk,同理module
 	// object to store loaded and loading chunks
 	// undefined = chunk not loaded, null = chunk preloaded/prefetched
 	// Promise = chunk loading, 0 = chunk loaded
 	var installedChunks = {
 		"common/runtime": 0
 	};

 	var deferredModules = [];

 	// The require function
 	function __webpack_require__(moduleId) {
 		// Check if module is in cache
 		if(installedModules[moduleId]) {
 			return installedModules[moduleId].exports;
 		}
 		// Create a new module (and put it into the cache)
 		var module = installedModules[moduleId] = {
 			i: moduleId,
 			l: false,
 			exports: {}
 		};
 		// Execute the module function
 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
 		// Flag the module as loaded
 		module.l = true;
 		// Return the exports of the module
 		return module.exports;
 	}


 	// expose the modules object (__webpack_modules__)
 	__webpack_require__.m = modules;

 	// expose the module cache
 	__webpack_require__.c = installedModules;

 	......

 	// __webpack_public_path__
 	__webpack_require__.p = "";

 	var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
 	var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
 	jsonpArray.push = webpackJsonpCallback;
 	jsonpArray = jsonpArray.slice();
 	for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
 	var parentJsonpFunction = oldJsonpFunction;


 	// run deferred modules from other chunks
 	checkDeferredModules();
 })([]);
複製程式碼
//@file: dist/common/utils.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["common/utils"], {
  "./src/index.js": function (module, __webpack_exports__, __webpack_require__) {
    "use strict";
    eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _module_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./module.js */ \"./src/module.js\");\n/* harmony import */ var _module_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_module_js__WEBPACK_IMPORTED_MODULE_0__);\n{};\nconsole.log(_module_js__WEBPACK_IMPORTED_MODULE_0___default.a.s);\n\n//# sourceURL=webpack:///./src/index.js?");
  },
  "./src/module.js": function (module, exports) {
    eval("{};var s = 123;\nconsole.log(s);\nmodule.exports = {\n  s: s\n};\n\n//# sourceURL=webpack:///./src/module.js?");
  }
}]);
複製程式碼

這時候,IIFE的形參也變成了擺設,所有我們的模組都被放在了一個名為 webpackJsonp 的全域性陣列上,通過IIFE裡的 webpackJsonpCallback 來處理資料。

1.3.總結

縱觀webpack構建流程,我們可以發現整個構建過程主要花費時間的部分也就是遞迴遍歷各個entry然後尋找依賴逐個編譯的過程,每次遞迴都需要經歷 String->AST->String 的流程,經過loader還需要處理一些字串或者執行一些JS指令碼,介於node.js單執行緒的壁壘,webpack構建慢一直成為它飽受詬病的原因。這也是happypack之所以能大火的原因,我們可以來看一段happypack的示例程式碼:

// @file: webpack.config.js
const HappyPack = require('happypack');
const os = require('os');
// 開闢一個執行緒池
// 拿到系統CPU的最大核數,讓happypack將編譯工作灌滿所有CPU核
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });

module.exports = {
  // ...
  plugins: [
    new HappyPack({
      id: 'js',
      threadPool: happyThreadPool,
      loaders: [ 'babel-loader' ]
    }),

    new HappyPack({
      id: 'styles',
      threadPool: happyThreadPool,
      loaders: [ 'style-loader', 'css-loader', 'less-loader' ]
    })
  ]
};
複製程式碼

大家如果有用過pm2的話就能很容易明白了,其實原理是一致的,都是利用了node.js原生的cluster模組去開闢多程式執行構建,不過在4之後大家就可以不用去糾結這一問題了,多程式構建已經被整合在webpack本身上了,除了增量編譯,這也是4之所以能大幅度提升構建效率的原因之一。

2.編寫自定義webpack loader

2.1.讓webpack loader現出原型

在webpack中,真正起編譯作用的便是我們的loader,也就是說,平時我們進行babel的ES6編譯,SCSS、LESS等編譯都是在loader裡面完成的,在你不知道loader的本質之前你一定會覺得這是個很高大上的東西,正如計算機學科裡的編譯原理一樣,裡面一定有許多繁雜的操作。但實際上,loader只是一個普通的funciton,他會傳入匹配到的檔案內容(String),你只需要對這些字串做些處理就好了。一個最簡單的loader大概是這樣:

/**
 * loader Function
 * @param {String} content 檔案內容
 */
module.exports = function(content){
    return "{};" + content
}
複製程式碼

使用它的方式和babel-loader一樣,只需要在webpack.config.jsmodule.rules陣列裡加上這麼一個物件就好了:

{
    test: /\.js$/,
    exclude: /node_modules/,
       use: {
           //這裡是我的自定義loader的存放路徑
           loader: path.resolve('./loaders/index.js'),
           options: {
              test: 1
           }
       }
}
複製程式碼

這樣,loader會去匹配所有以.js字尾結尾的檔案並在內容前追加{};這樣一段程式碼,我們可以在輸出檔案中看到效果:

Webpack揭祕——走向高階前端的必經之路

所以,拿到了檔案內容,你想對字串進行怎樣得處理都由你自定義~你可以引入babel庫加個 babel(content) ,這樣就實現了編譯,也可以引入uglifyjs對檔案內容進行字串壓縮,一切工作都由你自己定義。

2.2.Loader實戰常用技巧

2.2.1.拿到loader的使用者自定義配置

Webpack揭祕——走向高階前端的必經之路

在我們在webpack.config.js書寫loader配置時,經常會見到 options 這樣一個配置項,這就是webpack為使用者提供的自定義配置,在我們的loader裡,如果要拿到這樣一個配置資訊,只需要使用這個封裝好的庫 loader-utils 就可以了:

const loaderUtils = require("loader-utils");

module.exports = function(content){
    // 獲取使用者配置的options
    const options = loaderUtils.getOptions(this);
    console.log('***options***', options)
    return "{};" + content
}
複製程式碼

2.2.2.loader匯出資料的形式

在前面的示例中,因為我們一直loader是一個Funtion,所以我們使用了return的方式匯出loader處理後的資料,但其實這並不是我們最推薦的寫法,在大多數情況下,我們還是更希望使用 this.callback 方法去匯出資料。如果改成這種寫法,示例程式碼可以改寫為:

module.exports = function(content){
    //return "{};" + content
    this.callback(null, "{};" + content)
}
複製程式碼

this.callback 可以傳入四個引數(其中後兩個引數可以省略),他們分別是:

  • error:Error | null,當loader出錯時向外跑出一個Error
  • content:String | Buffer,經過loader編譯後需要匯出的內容
  • sourceMap:為方便除錯生成的編譯後內容的source map
  • ast: 本次編譯生成的AST靜態語法樹,之後執行的loader可以直接使用這個AST,可以省去重複生成AST的過程

2.2.3.非同步loader

經過2.2.2我們可以發現,不論是使用return還是 this.callback 的方式,匯出結果的執行都是同步的,假如我們的loader裡存在非同步操作,比如拉取請求等等又該怎麼辦呢?

熟悉ES6的朋友都知道最簡單的解決方法便是封裝一個Promise,然後用async-await完全無視非同步問題,示例程式碼如下:

module.exports = async function(content){
    function timeout(delay) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve("{};" + content)
            }, delay)
        })
    }
    const data = await timeout(1000)
    return data
}
複製程式碼

但如果node的版本不夠,我們還有原始的土方案 this.async ,呼叫這個方法會返回一個callback Function,在適當時候執行這個callback就可以了,上面的示例程式碼可以改寫為:

module.exports = function(content){
    function timeout(delay) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve("{};" + content)
            }, delay)
        })
    }
    const callback = this.async()
    timeout(1000).then(data => {
        callback(null, data)
    })
}
複製程式碼

更老版本的node同此。

2.2.4.loaders的執行順序

還記得我們配置CSS編譯時寫的loader嘛,它們是長這樣的:

Webpack揭祕——走向高階前端的必經之路

在很多時候,我們的 use 裡不只有一個loader,這些loader的執行順序是從後往前的,你也可以把它理解為這個loaders陣列的出棧過程。

2.2.5.loader快取

webpack增量編譯機制會觀察每次編譯時的變更檔案,在預設情況下,webpack會對loader的執行結果進行快取,這樣能夠大幅度提升構建速度,不過我們也可以手動關閉它(雖然我不知道為什麼要關閉它,既然留了這麼個API就蠻介紹下吧,歡迎補充),示例程式碼如下:

module.exports = function(content){
    //關閉loader快取
    this.cacheable(false);
    return "{};" + content
}
複製程式碼

2.2.6.pitch鉤子全程傳參

在loader檔案裡你可以exports一個命名為 pitch 的函式,它會先於所有的loader執行,就像這樣:

module.exports.pitch = (remaining, preceding, data) => {
    console.log('***remaining***', remaining)
    console.log('***preceding***', preceding)
    // data會被掛在到當前loader的上下文this上在loaders之間傳遞
    data.value = "test"
}
複製程式碼

它可以接受三個引數,最重要的就是第三個引數data,你可以為其掛在一些所需的值,一個rule裡的所有的loader在執行時都能拿到這個值。

module.exports = function(content){
    //***this data*** test
    console.log('***this data***', this.data.value)
    return "{};" + content
}

module.exports.pitch = (remaining, preceding, data) => {
    data.value = "test"
}
複製程式碼

2.3.總結

通過上述介紹,我們明白了,loader其實就是一個“平平無奇”的Funtion,能夠傳入本次匹配到的檔案內容供我們自定義修改。

3.編寫自定義webpack plugin

3.1.溫習一下webpack事件流

還記得我們在前文講到的webpack事件流,你還記得webpack有哪些常用的事件嗎?webpack外掛起到的作用,就是為這些事件掛載回撥,或者執行指定指令碼。

我們在文章裡也提到,webpack的事件流是通過 Tapable 實現的,它就和我們的EventEmit一樣,是這一系列的事件的生成和管理工具,它的部分核心程式碼就像下面這樣:

class SyncHook{
    constructor(){
        this.hooks = [];
    }

    // 訂閱事件
    tap(name, fn){
        this.hooks.push(fn);
    }

    // 釋出
    call(){
        this.hooks.forEach(hook => hook(...arguments));
    }
}
複製程式碼

webpack hook 上的所有鉤子都是 Tapable 的示例,所以我們可以通過 tap 方法監聽事件,使用 call 方法廣播事件,就像官方文件介紹的這樣:

compiler.hooks.someHook.tap(/* ... */);
複製程式碼

幾個比較常用的hook我們也已經在前文介紹過了,如果大家不記得了,可以回過頭再看看哦~

3.2.什麼是webpack plugin

如果剖析webpack plugin的本質,它實際上和webpack loader一樣簡單,其實它只是一個帶有apply方法的class。

//@file: plugins/myplugin.js
class myPlugin {
    constructor(options){
        //使用者自定義配置
        this.options = options
        console.log(this.options)
    }
    apply(compiler) {
        console.log("This is my first plugin.")
    }
}

module.exports = myPlugin
複製程式碼

這樣就實現了一個簡單的webpack plugin,如果我們要使用它,只需要在webpack.config.jsrequire 並例項化就可以了:

const MyPlugin = require('./plugins/myplugin-4.js')

module.exports = {
    ......,
    plugins: [
        new MyPlugin("Plugin is instancing.")
    ]
}
複製程式碼

大家現在肯定也都想起來了,每次我們需要使用某個plugin的時候都需要new一下例項化,自然,例項過程中傳遞的引數,也就成為了我們的建構函式裡拿到的options了。

而例項化所有plugin的時機,便是在webpack初始化所有引數的時候,也就是事件流開始的時候。所以,如果配合 shell.js 等工具庫,我們就可以在這時候執行檔案操作等相關指令碼,這就是webpack plugin所做的事情。

如果你想在指定時機執行某些指令碼,自然可以使用在webpack事件流上掛載回撥的方法,在回撥裡執行你所需的操作。

3.3.Tapable新用

如果我們想賦予webpack事件流我們的自定義事件能夠實現嘛?

答案當然是必須可以啊老鐵!

自定義webpack事件流事件需要幾步?四步:

  • 引入Tapable並找到你想用的hook,同步hook or 非同步hook 在這裡應有盡有 -> webpack4.0原始碼分析之Tapable

    const { SyncHook } = require("tapable");
    複製程式碼
  • 例項化Tapable中你所需要的hook並掛載在compiler或compilation上

    compiler.hooks.myHook = new SyncHook(['data'])
    複製程式碼
  • 在你需要監聽事件的位置tap監聽

    compiler.hooks.myHook.tap('Listen4Myplugin', (data) => {
        console.log('@Listen4Myplugin', data)
    })
    複製程式碼
  • 在你所需要廣播事件的時機執行call方法並傳入資料

    compiler.hooks.environment.tap(pluginName, () => {
           //廣播自定義事件
           compiler.hooks.myHook.call("It's my plugin.")
    });
    複製程式碼

完整程式碼實現可以參考我在文章最前方貼出的專案,大概就是下面這樣:

現在我的自定義外掛裡例項化一個hook並掛載在webpack事件流上

// @file: plugins/myplugin.js
const pluginName = 'MyPlugin'
// tapable是webpack自帶的package,是webpack的核心實現
// 不需要單獨install,可以在安裝過webpack的專案裡直接require
// 拿到一個同步hook類
const { SyncHook } = require("tapable");
class MyPlugin {
    // 傳入webpack config中的plugin配置引數
    constructor(options) {
        // { test: 1 }
        console.log('@plugin constructor', options);
    }

    apply(compiler) {
        console.log('@plugin apply');
        // 例項化自定義事件
        compiler.hooks.myPlugin = new SyncHook(['data'])

        compiler.hooks.environment.tap(pluginName, () => {
            //廣播自定義事件
            compiler.hooks.myPlugin.call("It's my plugin.")
            console.log('@environment');
        });

        // compiler.hooks.compilation.tap(pluginName, (compilation) => {
            // 你也可以在compilation上掛載hook
            // compilation.hooks.myPlugin = new SyncHook(['data'])
            // compilation.hooks.myPlugin.call("It's my plugin.")
        // });
    }
}
module.exports = MyPlugin
複製程式碼

在監聽外掛裡監聽我的自定義事件

// @file: plugins/listen4myplugin.js
class Listen4Myplugin {
    apply(compiler) {
        // 在myplugin environment 階段被廣播
        compiler.hooks.myPlugin.tap('Listen4Myplugin', (data) => {
            console.log('@Listen4Myplugin', data)
        })
    }
}

module.exports = Listen4Myplugin
複製程式碼

在webpack配置裡引入兩個外掛並例項化

// @file: webpack.config.js
const MyPlugin = require('./plugins/myplugin-4.js')
const Listen4Myplugin = require('./plugins/listen4myplugin.js')

module.exports = {
    ......,
    plugins: [
        new MyPlugin("Plugin is instancing."),
        new Listen4Myplugin()
    ]
}
複製程式碼

輸出結果就是這樣:

Webpack揭祕——走向高階前端的必經之路

我們拿到了call方法傳入的資料,並且成功在environment時機裡成功輸出了。

3.4.實戰剖析

來看一看已經被眾人玩壞的 html-webpack-plugin ,我們發現在readme底部有這樣一段demo:

function MyPlugin(options) {
  // Configure your plugin with options...
}

MyPlugin.prototype.apply = function (compiler) {
  compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
    console.log('The compiler is starting a new compilation...');

    compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(
      'MyPlugin',
      (data, cb) => {
        data.html += 'The Magic Footer'

        cb(null, data)
      }
    )
  })
}

module.exports = MyPlugin
複製程式碼

如果你認真讀完了上個板塊的內容,你會發現,這個 htmlWebpackPluginAfterHtmlProcessing 不就是這個外掛自己掛載在webpack事件流上的自定義事件嘛,它會在生成輸出檔案準備注入HTML時呼叫你自定義的回撥,並向回撥裡傳入本次編譯後生成的資原始檔的相關資訊以及待注入的HTML檔案的內容(字串形式)供我們自定義操作。在專案搜一下這個鉤子:

Webpack揭祕——走向高階前端的必經之路

這不和我們在3.2裡說的一樣嘛,先例項化我們所需要的hook,從名字就可以看出來只有第一個是同步鉤子,另外幾個都是非同步鉤子。然後再找找事件的廣播:

Webpack揭祕——走向高階前端的必經之路

Webpack揭祕——走向高階前端的必經之路

和我們剛剛介紹的一模一樣對吧,只不過非同步鉤子使用promise方法去廣播,其他不就完全是我們自定義事件的流程。大家如果有興趣可以去打下console看看 htmlWebpackPluginAfterHtmlProcessing 這個鉤子向回撥傳入的資料,或許你能發現一片新大陸哦。

相關文章