【W(wǎng)ebpack】1065- 手把手帶你入門Webpack Plugin
關(guān)于 Webpack
在講 Plugin 之前,我們先來了解下 Webpack。本質(zhì)上,Webpack 是一個用于現(xiàn)代 JavaScript 應用程序的靜態(tài)模塊打包工具。它能夠解析我們的代碼,生成對應的依賴關(guān)系,然后將不同的模塊達成一個或多個 bundle。

Webpack 的基本概念包括了如下內(nèi)容:
Entry:Webpack 的入口文件,指的是應該從哪個模塊作為入口,來構(gòu)建內(nèi)部依賴圖。 Output:告訴 Webpack 在哪輸出它所創(chuàng)建的 bundle 文件,以及輸出的 bundle 文件該如何命名、輸出到哪個路徑下等規(guī)則。 Loader:模塊代碼轉(zhuǎn)化器,使得 Webpack 有能力去處理除了 JS、JSON 以外的其他類型的文件。 Plugin:Plugin 提供執(zhí)行更廣的任務(wù)的功能,包括:打包優(yōu)化,資源管理,注入環(huán)境變量等。 Mode:根據(jù)不同運行環(huán)境執(zhí)行不同優(yōu)化參數(shù)時的必要參數(shù)。 Browser Compatibility:支持所有 ES5 標準的瀏覽器(IE8 以上)。
了解完 Webpack 的基本概念之后,我們再來看下,為什么我們會需要 Plugin。
Plugin 的作用
我先舉一個我們政采云內(nèi)部的案例:
在 React 項目中,一般我們的 Router 文件是寫在一個項目中的,如果項目中包含了許多頁面,不免會出現(xiàn)所有業(yè)務(wù)模塊 Router 耦合的情況,所以我們開發(fā)了一個 Plugin,在構(gòu)建打包時,該 Plugin 會讀取所有的文件夾下的 index.js 文件,再合并到一起形成一個統(tǒng)一的 Router 文件,輕松解決業(yè)務(wù)耦合問題。這就是 Plugin 的應用(具體實現(xiàn)會在最后一小節(jié)說明)。
來看一下我們合成前項目代碼結(jié)構(gòu):
├── package.json
├── README.md
├── zoo.config.js
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .stylelintrc
├── build (Webpack 配置目錄)
│ └── webpack.dev.conf.js
├── src
│ ├── index.hbs
│ ├── main.js (入口文件)
│ ├── common (通用模塊,包權(quán)限,統(tǒng)一報錯攔截等)
│ └── ...
│ ├── components (項目公共組件)
│ └── ...
│ ├── layouts (項目頂通)
│ └── ...
│ ├── utils (公共類)
│ └── ...
│ ├── routes (頁面路由)
│ │ ├── Hello (對應 Hello 頁面的代碼)
│ │ │ ├── config (頁面配置信息)
│ │ │ └── ...
│ │ │ ├── models (dva數(shù)據(jù)中心)
│ │ │ └── ...
│ │ │ ├── services (請求相關(guān)接口定義)
│ │ │ └── ...
│ │ │ ├── views (請求相關(guān)接口定義)
│ │ │ └── ...
│ │ │ └── index.js (router定義的路由信息)
├── .eslintignore
├── .eslintrc
├── .gitignore
└── .stylelintrc
再看一下經(jīng)過 Plugin 合成 Router 之后的結(jié)構(gòu):
├── package.json
├── README.md
├── zoo.config.js
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .stylelintrc
├── build (Webpack 配置目錄)
│ └── webpack.dev.conf.js
├── src
│ ├── index.hbs
│ ├── main.js (入口文件)
│ ├── router-config.js (合成后的router文件)
│ ├── common (通用模塊,包權(quán)限,統(tǒng)一報錯攔截等)
│ └── ...
│ ├── components (項目公共組件)
│ └── ...
│ ├── layouts (項目頂通)
│ └── ...
│ ├── utils (公共類)
│ └── ...
│ ├── routes (頁面路由)
│ │ ├── Hello (對應 Hello 頁面的代碼)
│ │ │ ├── config (頁面配置信息)
│ │ │ └── ...
│ │ │ ├── models (dva數(shù)據(jù)中心)
│ │ │ └── ...
│ │ │ ├── services (請求相關(guān)接口定義)
│ │ │ └── ...
│ │ │ ├── views (請求相關(guān)接口定義)
│ │ │ └── ...
├── .eslintignore
├── .eslintrc
├── .gitignore
└── .stylelintrc
總結(jié)來說 Plugin 的作用總結(jié)如下:
提供了 Loader 無法解決的一些其他事情 提供強大的擴展方法,能執(zhí)行更廣的任務(wù)
了解完 Plugin 的大致作用之后,我們來聊一聊如何創(chuàng)建一個 Plugin。
創(chuàng)建一個 Plugin
Hook
在聊創(chuàng)建 Plugin 之前,我們先來聊一下什么是 Hook。
Webpack 在編譯的過程中會觸發(fā)一系列流程,而在這樣一連串的流程中,Webpack 把一些關(guān)鍵的流程節(jié)點暴露出來供開發(fā)者使用,這就是 Hook,可以類比 React 的生命周期鉤子。
Plugin 就是在這些 Hook 上暴露出方法供開發(fā)者做一些額外操作,在寫 Plugin 的時候,也需要先了解我們應該在哪個 Hook 上做操作。
如何創(chuàng)建 Plugin
我們先來看一下 Webpack 官方給的案例:
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
// 代表開始讀取 records 之前執(zhí)行
compiler.hooks.run.tap(pluginName, compilation => {
console.log("webpack 構(gòu)建過程開始!");
});
}
}
從上面的代碼我們可以總結(jié)如下內(nèi)容:
Plugin 其實就是一個類。 類需要一個 apply 方法,執(zhí)行具體的插件方法。 插件方法做了一件事情就是在 run 這個 Hook 上注冊了一個同步的打印日志的方法。 apply 方法的入?yún)⒆⑷肓艘粋€ compiler 實例,compiler 實例是 Webpack 的支柱引擎,代表了 CLI 和 Node API 傳遞的所有配置項。 Hook 回調(diào)方法注入了 compilation 實例,compilation 能夠訪問當前構(gòu)建時的模塊和相應的依賴。
Compiler 對象包含了 Webpack 環(huán)境所有的的配置信息,包含 options,loaders,plugins 這些信息,這個對象在 Webpack 啟動時候被實例化,它是全局唯一的,可以簡單地把它理解為 Webpack 實例;
Compilation 對象包含了當前的模塊資源、編譯生成資源、變化的文件等。當 Webpack 以開發(fā)模式運行時,每當檢測到一個文件變化,一次新的 Compilation 將被創(chuàng)建。Compilation 對象也提供了很多事件回調(diào)供插件做擴展。通過 Compilation 也能讀取到 Compiler 對象。
—— 摘自「深入淺出 Webpack」
compiler 實例和 compilation 實例上分別定義了許多 Hooks,可以通過 實例.hooks.具體Hook訪問,Hook 上還暴露了 3 個方法供使用,分別是 tap、tapAsync 和 tapPromise。這三個方法用于定義如何執(zhí)行 Hook,比如 tap 表示注冊同步 Hook,tapAsync 代表 callback 方式注冊異步 hook,而 tapPromise 代表 Promise 方式注冊異步 Hook,可以看下 Webpack 中關(guān)于這三種類型實現(xiàn)的源碼,為方便閱讀,我加了些注釋。
// tap方法的type是sync,tapAsync方法的type是async,tapPromise方法的type是promise
// 源碼取自Hook工廠方法:lib/HookCodeFactory.js
create(options) {
this.init(options);
let fn;
// Webpack 通過new Function 生成函數(shù)
switch (this.options.type) {
case "sync":
fn = new Function(
this.args(), // 生成函數(shù)入?yún)?/span>
'"use strict";\n' +
this.header() + // 公共方法,生成一些需要定義的變量
this.contentWithInterceptors({ // 生成實際執(zhí)行的代碼的方法
onError: err => `throw ${err};\n`, // 錯誤回調(diào)
onResult: result => `return ${result};\n`, // 得到值的時候的回調(diào)
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() + // 公共方法,生成一些需要定義的變量
this.contentWithInterceptors({
onError: err => `_callback(${err});\n`, // 錯誤時執(zhí)行回調(diào)方法
onResult: result => `_callback(null, ${result});\n`, // 得到結(jié)果時執(zhí)行回調(diào)方法
onDone: () => "_callback();\n" // 無結(jié)果,執(zhí)行完成時
})
);
break;
case "promise":
let errorHelperUsed = false;
const content = this.contentWithInterceptors({
onError: err => {
errorHelperUsed = true;
return `_error(${err});\n`;
},
onResult: result => `_resolve(${result});\n`,
onDone: () => "_resolve();\n"
});
let code = "";
code += '"use strict";\n';
code += this.header(); // 公共方法,生成一些需要定義的變量
code += "return new Promise((function(_resolve, _reject) {\n"; // 返回的是 Promise
if (errorHelperUsed) {
code += "var _sync = true;\n";
code += "function _error(_err) {\n";
code += "if(_sync)\n";
code +=
"_resolve(Promise.resolve().then((function() { throw _err; })));\n";
code += "else\n";
code += "_reject(_err);\n";
code += "};\n";
}
code += content; // 判斷具體執(zhí)行_resolve方法還是執(zhí)行_error方法
if (errorHelperUsed) {
code += "_sync = false;\n";
}
code += "}));\n";
fn = new Function(this.args(), code);
break;
}
this.deinit(); // 清空 options 和 _args
return fn;
}
Webpack 共提供了以下十種 Hooks,代碼中所有具體的 Hook 都是以下這 10 種中的一種。
// 源碼取自:lib/index.js
"use strict";
exports.__esModule = true;
// 同步執(zhí)行的鉤子,不能處理異步任務(wù)
exports.SyncHook = require("./SyncHook");
// 同步執(zhí)行的鉤子,返回非空時,阻止向下執(zhí)行
exports.SyncBailHook = require("./SyncBailHook");
// 同步執(zhí)行的鉤子,支持將返回值透傳到下一個鉤子中
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
// 同步執(zhí)行的鉤子,支持將返回值透傳到下一個鉤子中,返回非空時,重復執(zhí)行
exports.SyncLoopHook = require("./SyncLoopHook");
// 異步并行的鉤子
exports.AsyncParallelHook = require("./AsyncParallelHook");
// 異步并行的鉤子,返回非空時,阻止向下執(zhí)行,直接執(zhí)行回調(diào)
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
// 異步串行的鉤子
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
// 異步串行的鉤子,返回非空時,阻止向下執(zhí)行,直接執(zhí)行回調(diào)
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
// 支持異步串行 && 并行的鉤子,返回非空時,重復執(zhí)行
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
// 異步串行的鉤子,下一步依賴上一步返回的值
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
// 以下 2 個是 hook 工具類,分別用于 hooks 映射以及 hooks 重定向
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook");
舉幾個簡單的例子:
上面官方案例中的 run 這個 Hook,會在開始讀取 records 之前執(zhí)行,它的類型是 AsyncSeriesHook,查看源碼可以發(fā)現(xiàn),run Hook 既可以執(zhí)行同步的 tap 方法,也可以執(zhí)行異步的 tapAsync 和 tapPromise 方法,所以以下寫法也是可以的:
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tapAsync(pluginName, (compilation, callback) => {
setTimeout(() => {
console.log("webpack 構(gòu)建過程開始!");
callback(); // callback 方法為了讓構(gòu)建繼續(xù)執(zhí)行下去,必須要調(diào)用
}, 1000);
});
}
}
再舉一個例子,比如 failed 這個 Hook,會在編譯失敗之后執(zhí)行,它的類型是 SyncHook,查看源碼可以發(fā)現(xiàn),調(diào)用 tapAsync 和 tapPromise 方法時,會直接拋錯。
對于一些同步的方法,推薦直接使用 tap 進行注冊方法,對于異步的方案,tapAsync 通過執(zhí)行 callback 方法實現(xiàn)回調(diào),如果執(zhí)行的方法返回的是一個 Promise,推薦使用 tapPromise 進行方法的注冊。
Hook 的類型可以通過官方 API 查詢,地址傳送門:https://www.webpackjs.com/api/compiler-hooks/?fileGuid=3tGHdrykRgwCyTP8
// 源碼取自:lib/SyncHook.js
const TAP_ASYNC = () => {
throw new Error("tapAsync is not supported on a SyncHook");
};
const TAP_PROMISE = () => {
throw new Error("tapPromise is not supported on a SyncHook");
};
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name);
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
hook.compile = COMPILE;
return hook;
}
講解完具體的執(zhí)行方法之后,我們再聊一下 Webpack 流程以及 Tapable 是什么。
Webpack && Tapable
Webpack 運行機制
要理解 Plugin,我們先大致了解 Webpack 打包的流程
我們打包的時候,會先合并 Webpack config 文件和命令行參數(shù),合并為 options。 將 options 傳入 Compiler 構(gòu)造方法,生成 compiler 實例,并實例化了 Compiler 上的 Hooks。 compiler 對象執(zhí)行 run 方法,并自動觸發(fā) beforeRun、run、beforeCompile、compile 等關(guān)鍵 Hooks。 調(diào)用 Compilation 構(gòu)造方法創(chuàng)建 compilation 對象,compilation 負責管理所有模塊和對應的依賴,創(chuàng)建完成后觸發(fā) make Hook。 執(zhí)行 compilation.addEntry() 方法,addEntry 用于分析所有入口文件,逐級遞歸解析,調(diào)用 NormalModuleFactory 方法,為每個依賴生成一個 Module 實例,并在執(zhí)行過程中觸發(fā) beforeResolve、resolver、afterResolve、module 等關(guān)鍵 Hooks。 將第 5 步中生成的 Module 實例作為入?yún)ⅲ瑘?zhí)行 Compilation.addModule() 和 Compilation.buildModule() 方法遞歸創(chuàng)建模塊對象和依賴模塊對象。 調(diào)用 seal 方法生成代碼,整理輸出主文件和 chunk,并最終輸出。

Tapable
Tapable 是 Webpack 核心工具庫,它提供了所有 Hook 的抽象類定義,Webpack 許多對象都是繼承自 Tapable 類。比如上面說的 tap、tapAsync 和 tapPromise 都是通過 Tapable 進行暴露的。源碼如下(截取了部分代碼):
// 第二節(jié) “創(chuàng)建一個 Plugin” 中說的 10 種 Hooks 都是繼承了這兩個類
// 源碼取自:tapable.d.ts
declare class Hook<T, R, AdditionalOptions = UnsetAdditionalOptions> {
tap(options: string | Tap & IfSet<AdditionalOptions>, fn: (...args: AsArray<T>) => R): void;
}
declare class AsyncHook<T, R, AdditionalOptions = UnsetAdditionalOptions> extends Hook<T, R, AdditionalOptions> {
tapAsync(
options: string | Tap & IfSet<AdditionalOptions>,
fn: (...args: Append<AsArray<T>, InnerCallback<Error, R>>) => void
): void;
tapPromise(
options: string | Tap & IfSet<AdditionalOptions>,
fn: (...args: AsArray<T>) => Promise<R>
): void;
}
常見 Hooks API
可以參考 Webpack:https://www.webpackjs.com/api/compiler-hooks/?fileGuid=3tGHdrykRgwCyTP8
本文列舉一些常用 Hooks 和其對應的類型
Compiler Hooks
| Hook | type | 調(diào)用 |
|---|---|---|
| run | AsyncSeriesHook | 開始讀取 records 之前 |
| compile | SyncHook | 一個新的編譯(compilation)創(chuàng)建之后 |
| emit | AsyncSeriesHook | 生成資源到 output 目錄之前 |
| done | SyncHook | 編譯(compilation)完成 |
Compilation Hooks
| Hook | type | 調(diào)用 |
|---|---|---|
| buildModule | SyncHook | 在模塊構(gòu)建開始之前觸發(fā)。 |
| finishModules | SyncHook | 所有模塊都完成構(gòu)建。 |
| optimize | SyncHook | 優(yōu)化階段開始時觸發(fā)。 |
Plugin 在項目中的應用
講完這么多理論知識,接下來我們來看一下 Plugin 在項目中的實戰(zhàn):如何將各個子模塊中的 router 文件合并到 router-config.js 中。
背景:
在 React 項目中,一般我們的 Router 文件是寫在一個項目中的,如果項目中包含了許多頁面,不免會出現(xiàn)所有業(yè)務(wù)模塊 Router 耦合的情況,所以我們開發(fā)了一個 Plugin,在構(gòu)建打包時,該 Plugin 會讀取所有文件夾下的 Router 文件,再合并到一起形成一個統(tǒng)一的 Router Config 文件,輕松解決業(yè)務(wù)耦合問題。這就是 Plugin 的應用。
實現(xiàn):
const fs = require('fs');
const path = require('path');
const _ = require('lodash');
function resolve(dir) {
return path.join(__dirname, '..', dir);
}
function MegerRouterPlugin(options) {
// options是配置文件,你可以在這里進行一些與options相關(guān)的工作
}
MegerRouterPlugin.prototype.apply = function (compiler) {
// 注冊 before-compile 鉤子,觸發(fā)文件合并
compiler.plugin('before-compile', (compilation, callback) => {
// 最終生成的文件數(shù)據(jù)
const data = {};
const routesPath = resolve('src/routes');
const targetFile = resolve('src/router-config.js');
// 獲取路徑下所有的文件和文件夾
const dirs = fs.readdirSync(routesPath);
try {
dirs.forEach((dir) => {
const routePath = resolve(`src/routes/${dir}`);
// 判斷是否是文件夾
if (!fs.statSync(routePath).isDirectory()) {
return true;
}
delete require.cache[`${routePath}/index.js`];
const routeInfo = require(routePath);
// 多個 view 的情況下,遍歷生成router信息
if (!_.isArray(routeInfo)) {
generate(routeInfo, dir, data);
// 單個 view 的情況下,直接生成
} else {
routeInfo.map((config) => {
generate(config, dir, data);
});
}
});
} catch (e) {
console.log(e);
}
// 如果 router-config.js 存在,判斷文件數(shù)據(jù)是否相同,不同刪除文件后再生成
if (fs.existsSync(targetFile)) {
delete require.cache[targetFile];
const targetData = require(targetFile);
if (!_.isEqual(targetData, data)) {
writeFile(targetFile, data);
}
// 如果 router-config.js 不存在,直接生成文件
} else {
writeFile(targetFile, data);
}
// 最后調(diào)用callback,繼續(xù)執(zhí)行 webpack 打包
callback();
});
};
// 合并當前文件夾下的router數(shù)據(jù),并輸出到 data 對象中
function generate(config, dir, data) {
// 合并 router
mergeConfig(config, dir, data);
// 合并子 router
getChildRoutes(config.childRoutes, dir, data, config.url);
}
// 合并 router 數(shù)據(jù)到 targetData 中
function mergeConfig(config, dir, targetData) {
const { view, models, extraModels, url, childRoutes, ...rest } = config;
// 獲取 models,并去除 src 字段
const dirModels = getModels(`src/routes/${dir}/models`, models);
const data = {
...rest,
};
// view 拼接到 path 字段
data.path = `${dir}/views${view ? `/${view}` : ''}`;
// 如果有 extraModels,就拼接到 models 對象上
if (dirModels.length || (extraModels && extraModels.length)) {
data.models = mergerExtraModels(config, dirModels);
}
Object.assign(targetData, {
[url]: data,
});
}
// 拼接 dva models
function getModels(modelsDir, models) {
if (!fs.existsSync(modelsDir)) {
return [];
}
let files = fs.readdirSync(modelsDir);
// 必須要以 js 或者 jsx 結(jié)尾
files = files.filter((item) => {
return /\.jsx?$/.test(item);
});
// 如果沒有定義 models ,默認取 index.js
if (!models || !models.length) {
if (files.indexOf('index.js') > -1) {
// 去除 src
return [`${modelsDir.replace('src/', '')}/index.js`];
}
return [];
}
return models.map((item) => {
if (files.indexOf(`${item}.js`) > -1) {
// 去除 src
return `${modelsDir.replace('src/', '')}/${item}.js`;
}
});
}
// 合并 extra models
function mergerExtraModels(config, models) {
return models.concat(config.extraModels ? config.extraModels : []);
}
// 合并子 router
function getChildRoutes(childRoutes, dir, targetData, oUrl) {
if (!childRoutes) {
return;
}
childRoutes.map((option) => {
option.url = oUrl + option.url;
if (option.childRoutes) {
// 遞歸合并子 router
getChildRoutes(option.childRoutes, dir, targetData, option.url);
}
mergeConfig(option, dir, targetData);
});
}
// 寫文件
function writeFile(targetFile, data) {
fs.writeFileSync(targetFile, `module.exports = ${JSON.stringify(data, null, 2)}`, 'utf-8');
}
module.exports = MegerRouterPlugin;
結(jié)果:
合并前的文件:
module.exports = [
{
url: '/category/protocol',
view: 'protocol',
},
{
url: '/category/sync',
models: ['sync'],
view: 'sync',
},
{
url: '/category/list',
models: ['category', 'config', 'attributes', 'group', 'otherSet', 'collaboration'],
view: 'categoryRefactor',
},
{
url: '/category/conversion',
models: ['conversion'],
view: 'conversion',
},
];
合并后的文件:
module.exports = {
"/category/protocol": {
"path": "Category/views/protocol"
},
"/category/sync": {
"path": "Category/views/sync",
"models": [
"routes/Category/models/sync.js"
]
},
"/category/list": {
"path": "Category/views/categoryRefactor",
"models": [
"routes/Category/models/category.js",
"routes/Category/models/config.js",
"routes/Category/models/attributes.js",
"routes/Category/models/group.js",
"routes/Category/models/otherSet.js",
"routes/Category/models/collaboration.js"
]
},
"/category/conversion": {
"path": "Category/views/conversion",
"models": [
"routes/Category/models/conversion.js"
]
},
}
最終項目就會生成 router-config.js文件

結(jié)尾
希望大家看完本章之后,對 Webpack Plugin 有一個初步的認識,能夠上手寫一個自己的 Plugin 來應用到自己的項目中。
文章中如有不對的地方,歡迎指正。

回復“加群”與大佬們一起交流學習~
點擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
