初探webpack之編寫plugin
webpack通過plugin機制讓其使用更加靈活,以適應各種應用場景,當然也大大增加了webpack的復雜性,在webpack運行的生命周期中會廣播出許多事件,plugin可以hook這些事件,在合適的時機通過webpack提供的API改變其在處理過程中的輸出結果。
描述
webpack是一個現代JavaScript應用程序的靜態模塊打包器module bundler,當webpack處理應用程序時,它會遞歸地構建一個依賴關系圖dependency graph,其中包含應用程序需要的每個模塊,然后將所有這些模塊打包成一個或多個bundle。
使用webpack作為前端構建工具通常可以做到以下幾個方面的事情:
- 代碼轉換:
TypeScript編譯成JavaScript、SCSS編譯成CSS等。 - 文件優化: 壓縮
JavaScript、CSS、HTML代碼,壓縮合並圖片等。 - 代碼分割: 提取多個頁面的公共代碼、提取首屏不需要執行部分的代碼讓其異步加載。
- 模塊合並: 在采用模塊化的項目里會有很多個模塊和文件,需要構建功能把模塊分類合並成一個文件。
- 自動刷新: 監聽本地源代碼的變化,自動重新構建、刷新瀏覽器頁面,通常叫做模塊熱替換
HMR。 - 代碼校驗: 在代碼被提交到倉庫前需要校驗代碼是否符合規范,以及單元測試是否通過。
- 自動發布: 更新完代碼后,自動構建出線上發布代碼並傳輸給發布系統。
在webpack應用中有兩個核心:
- 模塊轉換器,用於把模塊原內容按照需求轉換成新內容,可以加載非
js模塊; - 擴展插件,在
webpack構建流程中的特定時機注入擴展邏輯來改變構建結果或做你想要的事情。
本文編寫的就是編寫一個簡單的webpack插件,設想一個簡單的場景,假如我們實現了一個多頁的Vue應用,每個打包的頁面都會共享一個相同的頭部和底部,也就是頂部navigation bar和底部的footer。因為類似於Vue這種框架都是在運行時才會加載出來頭部與底部,而這部分代碼實際上完全可以作為一個獨立的公用子項目去開發,沒必要在多頁應用的每個頁面都引用一次組件再讓框架去解析組件。另外在多頁應用頁面之間跳轉時,如果編寫一個頭部組件在每個頁面組件內部去引用的話,很容易因為需要加載解析JS的時間比較長從而出現導航欄閃爍的問題。
如果要解決上邊提到的問題的話,可以采用的一個方案就是使用靜態頁面片,我們可以將頭部和底部的頁面片在webpack打包的時候將其注入到要打包完成的html頁面中,這樣的話不但可以節省一些框架解析組件的JS消耗,而且還可以有更好的SEO表現。雖然只是一個頭部與底部並未承載多少信息,但是如果是在SSR場景下大量的重復CPU任務,提升一點對於整體來說還是有一個比較大的提高的,就像圖形學中畫線的算法一樣,架不住運算次數太多。此外這樣可以比較好的解決組件頭部閃爍的問題,因為其是隨着HTML一並返回的,所以能立即渲染在頁面上不需要JS的加載解析,同樣對於骨架屏而言也是可以采用webpack注入頁面片的這種方案加載,文中涉及到的所有代碼都在https://github.com/WindrunnerMax/webpack-simple-environment。
實現
搭建環境
初探webpack,那么便從搭建簡單的webpack環境開始,首先是初始化並安裝依賴。
$ yarn init -y
$ yarn add -D webpack webpack-cli cross-env
首先可以嘗試一下webpack打包程序,webpack可以零配置進行打包,目錄結構如下:
webpack-simple
├── package.json
├── src
│ ├── index.js
│ └── sum.js
└── yarn.lock
// src/sum.js
export const add = (a, b) => a + b;
// src/index.js
import { add } from "./sum";
console.log(add(1, 1));
之后寫入一個打包的命令。
// package.json
{
// ...
"scripts": {
"build": "webpack"
},
// ...
}
執行npm run build,默認會調用node_modules/.bin下的webpack命令,內部會調用webpack-cli解析用戶參數進行打包,默認會以src/index.js作為入口文件。
$ npm run build
執行完成后,會出現警告,這里還提示我們默認mode為production,此時可以看到出現了dist文件夾,此目錄為最終打包出的結果,並且內部存在一個main.js,其中webpack會進行一些語法分析與優化,可以看到打包完成的結構是。
// src/main.js
(()=>{"use strict";console.log(2)})();
配置webpack
當然我們打包時一般不會采用零配置,此時我們就首先新建一個文件webpack.config.js。既然webpack說默認mode是production,那就先進行一下配置解決這個問題,因為只是一個簡單的webpack環境我們就不區分webpack.dev.js和webpack.prod.js進行配置了,簡單的使用process.env.NODE_ENV在webpack.config.js中區分一下即可。在這里我們主要關心dist打包過后的文件,在這里就不進行dev環境的處理以及webpack-dev-server的搭建了,cross-env是用以配置環境變量的插件。
// package.json
{
// ...
"scripts": {
"build": "cross-env NODE_ENV=production webpack --config webpack.config.js"
},
// ...
}
const path = require("path");
module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path:path.resolve(__dirname, "dist")
}
}
不過按照上邊的需求來說,我們不光是需要處理js文件的,還需要處理html文件,這里就需要使用html-webpack-plugin插件。
$ yarn add -D html-webpack-plugin
之后在webpack.config.js中進行配置,簡單配置一下相關的輸入輸出和壓縮信息,另外如果要是想每次打包刪除dist文件夾的話可以考慮使用clean-webpack-plugin插件。
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path:path.resolve(__dirname, "dist")
},
plugins:[
new HtmlWebpackPlugin({
title: "Webpack Template",
filename: "index.html", // 打包出來的文件名 根路徑是`module.exports.output.path`
template: path.resolve("./public/index.html"),
hash: true, // 在引用資源的后面增加`hash`戳
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
minifyCSS: true,
minifyJS: true,
},
inject: "body", // `head`、`body`、`true`、`false`
scriptLoading: "blocking" // `blocking`、`defer`
})
]
}
編寫插件
之后到了正文環節,此時我們要編寫一個插件去處理上邊提到的需求,具體實現來看,我們需要的是首先在html中留下一個類似於<!-- inject:name="head" -->的標記注釋,之后在webpack打包時對於html文件進行一次正則匹配,將注釋相關的信息替換成頁面片,通過name進行區分到底要加載哪一個頁面片。另外個人感覺實際上編寫webpack插件的時候還是首先參考其他人編寫的webpack插件的實現,自己去翻閱文檔成本查閱各種hook的成本有點高。
對於這個插件我們直接在根目錄建立一個static-page-slice.js,插件由一個構造函數實例化出來,構造函數定義apply方法,在webpack處理插件的時候,apply方法會被webpack compiler調用一次。apply方法可以接收一個webpack compiler對象的引用,從而可以在回調函數中訪問到compiler對象。一個最基礎的Plugin的結構是類似於這樣的:
class BasicPlugin{
// 在構造函數中獲取用戶給該插件傳入的配置
constructor(options){
this.options = options || {};
}
// `Webpack`會調用`BasicPlugin`實例的`apply`方法給插件實例傳入`compiler`對象
apply(compiler){
compiler.hooks.someHook.tap("BasicPlugin", (params) => {
/* ... */
});
}
}
// 導出 Plugin
module.exports = BasicPlugin;
在開發plugin時最常用的兩個對象就是compiler和compilation,它們是plugin和webpack之間的橋梁,compiler和compilation的含義如下:
compiler對象包含了webpack環境所有的的配置信息,包含options、loaders、plugins這些信息,這個對象在webpack啟動時候被實例化,它是全局唯一的,可以簡單地把它理解為webpack實例。compilation對象包含了當前的模塊資源、編譯生成資源、變化的文件等,當webpack以開發模式運行時,每當檢測到一個文件變化,一次新的compilation將被創建,compilation對象也提供了很多事件回調供插件做擴展,通過compilation也能讀取到compiler對象。
compiler和compilation的區別在於: compiler代表了整個webpack從啟動到關閉的生命周期,而compilation只是代表了一次新的編譯,與之相關的信息可以參考https://webpack.docschina.org/api/compiler-hooks/。
webpack就像一條生產線,要經過一系列處理流程后才能將源文件轉換成輸出結果,這條生產線上的每個處理流程的職責都是單一的,多個流程之間有存在依賴關系,只有完成當前處理后才能交給下一個流程去處理,插件就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源做處理,webpack通過tapable來組織這條復雜的生產線https://github.com/webpack/tapable。
在這里我們選擇在compiler鈎子的emit時期處理資源文件,即是在輸出asset到output目錄之前執行,在此時要注意emit是一個AsyncSeriesHook也就是異步的hook,所以我們需要使用Tapable的tapAsync或者tapPromise,如果選取的是同步的hook,則可以使用tap。
class StaticPageSlice {
constructor(options) {
this.options = options || {};
}
apply(compiler) {
compiler.hooks.emit.tapPromise("StaticPageSlice", compilation => {
return new Promise(resolve => {
console.log("StaticPageSlice is being called")
resolve();
})
});
}
}
module.exports = StaticPageSlice;
接下來我們正式開始處理邏輯,首先此處我們需要先判斷這個文件的類型,我們只需要處理html文件,所以我們需要先一下是否為html文件,之后就是一個正則匹配的過程,匹配到注釋信息以后,將其替換為頁面片,這里的頁面片我們就直接在此處使用Promise模擬一下異步過程就好,之后便可以在webpack中引用並成功打包了。
// static-page-slice.js
const simulateRemoteData = key => {
const data = {
header: "<div>HEADER</div>",
footer: "<div>FOOTER</div>",
}
return Promise.resolve(data[key]);
}
class StaticPageSlice {
constructor(options) {
this.options = options || {}; // 傳遞參數
}
apply(compiler) {
compiler.hooks.emit.tapPromise("StaticPageSlice", compilation => {
return new Promise(resolve => {
const cache = {};
const assetKeys = Object.keys(compilation.assets);
for (const key of assetKeys) {
const isLastAsset = key === assetKeys[assetKeys.length - 1];
if (!/.*\.html$/.test(key)) {
if (isLastAsset) resolve();
continue;
}
let target = compilation.assets[key].source();
const matchedValues = target.matchAll(/<!-- inject:name="(\S*?)" -->/g); // `matchAll`函數需要`Node v12.0.0`以上
const tags = [];
for (const item of matchedValues) {
const [tag, name] = item;
tags.push({
tag,
name,
data: cache[name] ? cache[name] : simulateRemoteData(name),
});
}
Promise.all(tags.map(item => item.data))
.then(res => {
res.forEach((data, index) => {
const tag = tags[index].tag;
const name = tags[index].name;
if (!cache[name]) cache[name] = data;
target = target.replace(tag, data);
});
})
.then(() => {
compilation.assets[key] = {
source() {
return target;
},
size() {
return this.source().length;
},
};
})
.then(() => {
if (isLastAsset) resolve();
});
}
});
});
}
}
module.exports = StaticPageSlice;
// webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const StaticPageSlice = require("./static-page-slice");
module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path:path.resolve(__dirname, "dist")
},
plugins:[
new HtmlWebpackPlugin({
title: "Webpack Template",
filename: "index.html", // 打包出來的文件名 根路徑是`module.exports.output.path`
template: path.resolve("./public/index.html"),
hash: true, // 在引用資源的后面增加`hash`戳
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
minifyCSS: true,
minifyJS: true,
},
inject: "body", // `head`、`body`、`true`、`false`
scriptLoading: "blocking" // `blocking`、`defer`
}),
new StaticPageSlice({
url: "https://www.example.com/"
})
]
}
之后便可以看到打包前后的html文件的差別了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<!-- inject:name="header" -->
<div id="app"></div>
<!-- inject:name="footer" -->
<!-- built files will be auto injected -->
</body>
</html>
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><title>Webpack Template</title></head><body><div>HEADER</div><div id=app></div><div>FOOTER</div><!-- built files will be auto injected --><script src=index.js?7e2c7994f2e0891ec351></script></body></html>
webpack5對於hooks有一次更新,使用上邊的插件會提示:
(node:5760) [DEP_WEBPACK_COMPILATION_ASSETS] DeprecationWarning: Compilation.assets will be frozen in future, all modifications are deprecated.
BREAKING CHANGE: No more changes should happen to Compilation.assets after sealing the Compilation.
Do changes to assets earlier, e. g. in Compilation.hooks.processAssets.
Make sure to select an appropriate stage from Compilation.PROCESS_ASSETS_STAGE_*.
所以我們可以根據其提示提前將資源進行處理,可以實現同樣的效果。
// static-page-slice.js
const simulateRemoteData = key => {
const data = {
header: "<div>HEADER</div>",
footer: "<div>FOOTER</div>",
};
return Promise.resolve(data[key]);
};
class StaticPageSlice {
constructor(options) {
this.options = options || {}; // 傳遞參數
}
apply(compiler) {
compiler.hooks.thisCompilation.tap("StaticPageSlice", compilation => {
compilation.hooks.processAssets.tapPromise(
{
name: "StaticPageSlice",
stage: compilation.constructor.PROCESS_ASSETS_STAGE_ADDITIONS,
additionalAssets: true,
},
assets => this.replaceAssets(assets, compilation)
);
});
}
replaceAssets(assets, compilation) {
return new Promise(resolve => {
const cache = {};
const assetKeys = Object.keys(assets);
for (const key of assetKeys) {
const isLastAsset = key === assetKeys[assetKeys.length - 1];
if (!/.*\.html$/.test(key)) {
if (isLastAsset) resolve();
continue;
}
let target = assets[key].source();
const matchedValues = target.matchAll(/<!-- inject:name="(\S*?)" -->/g); // `matchAll`函數需要`Node v12.0.0`以上
const tags = [];
for (const item of matchedValues) {
const [tag, name] = item;
tags.push({
tag,
name,
data: cache[name] ? cache[name] : simulateRemoteData(name),
});
}
Promise.all(tags.map(item => item.data))
.then(res => {
res.forEach((data, index) => {
const tag = tags[index].tag;
const name = tags[index].name;
if (!cache[name]) cache[name] = data;
target = target.replace(tag, data);
});
})
.then(() => {
compilation.assets[key] = {
source() {
return target;
},
size() {
return this.source().length;
},
};
})
.then(() => {
if (isLastAsset) resolve();
});
}
});
}
}
module.exports = StaticPageSlice;
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://webpack.docschina.org/concepts/
https://juejin.cn/post/6854573216108085261
https://webpack.docschina.org/api/plugins/
https://juejin.cn/post/6844903942736838670
https://segmentfault.com/a/1190000012840742
https://segmentfault.com/a/1190000021821557
https://webpack.docschina.org/api/compilation-hooks/
https://webpack.docschina.org/api/normalmodulefactory-hooks/
