初探webpack之編寫plugin

初探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程式碼,壓縮合併圖片等。
  • 程式碼分割: 提取多個頁面的公共程式碼、提取首屏不需要執行部分的程式碼讓其非同步載入。
  • 模組合併: 在採用模組化的項目里會有很多個模組和文件,需要構建功能把模組分類合併成一個文件。
  • 自動刷新: 監聽本地源程式碼的變化,自動重新構建、刷新瀏覽器頁面,通常叫做熱重載Hot Reload;
  • 程式碼校驗: 在程式碼被提交到倉庫前需要校驗程式碼是否符合規範,以及單元測試是否通過。
  • 自動發布: 更新完程式碼後,自動構建出線上發布程式碼並傳輸給發布系統。

在webpack應用中有兩個核心:

  • 模組轉換器,用於把模組原內容按照需求轉換成新內容,可以載入非js模組;
  • 擴展插件,在webpack構建流程中的特定時機注入擴展邏輯來改變構建結果或做你想要的事情。

本文編寫的就是編寫一個簡單的webpack插件,設想一個簡單的場景,假如我們實現了一個多頁的Vue應用,每個打包的頁面都會共享一個相同的頭部和底部,也就是頂部navigation bar和底部的footer。因為類似於Vue這種框架都是在運行時才會載入出來頭部與底部,而這部分程式碼實際上完全可以作為一個獨立的公用子項目去開發,沒必要在多頁應用的每個頁面都引用一次組件再讓框架去解析組件。另外在多頁應用頁面之間跳轉時,如果編寫一個頭部組件在每個頁面組件內部去引用的話,很容易因為需要載入解析JS的時間比較長從而出現導航欄閃爍的問題。
如果要解決上邊提到的問題的話,可以採用的一個方案就是使用靜態頁面片,我們可以將頭部和底部的頁面片在webpack打包的時候將其注入到要打包完成的html頁面中,這樣的話不但可以節省一些框架解析組件的JS消耗,而且還可以有更好的SEO表現。雖然只是一個頭部與底部並未承載多少資訊,但是如果是在SSR場景下大量的重複CPU任務,提升一點對於整體來說還是有一個比較大的提高的,就像圖形學中畫線的演算法一樣,架不住運算次數太多。此外這樣可以比較好的解決組件頭部閃爍的問題,因為其是隨著HTML一併返回的,所以能立即渲染在頁面上不需要JS的載入解析,同樣對於骨架屏而言也是可以採用webpack注入頁面片的這種方案載入,文中涉及到的所有程式碼都在//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只是代表了一次新的編譯,與之相關的資訊可以參考//webpack.docschina.org/api/compiler-hooks/。
webpack就像一條生產線,要經過一系列處理流程後才能將源文件轉換成輸出結果,這條生產線上的每個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理,插件就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源做處理,webpack通過tapable來組織這條複雜的生產線//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: "//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;

每日一題

//github.com/WindrunnerMax/EveryDay

參考

//webpack.docschina.org/concepts/
//juejin.cn/post/6854573216108085261
//webpack.docschina.org/api/plugins/
//juejin.cn/post/6844903942736838670
//segmentfault.com/a/1190000012840742
//segmentfault.com/a/1190000021821557
//webpack.docschina.org/api/compilation-hooks/
//webpack.docschina.org/api/normalmodulefactory-hooks/
Tags: