初探webpack之編寫loader

WindrunnerMax發表於2022-05-05

初探webpack之編寫loader

loader載入器是webpack的核心之一,其用於將不同型別的檔案轉換為webpack可識別的模組,即用於把模組原內容按照需求轉換成新內容,用以載入非js模組,通過配合擴充套件外掛,在webpack構建流程中的特定時機注入擴充套件邏輯來改變構建結果,從而完成一次完整的構建。

描述

webpack是一個現代JavaScript應用程式的靜態模組打包器module bundler,當webpack處理應用程式時,它會遞迴地構建一個依賴關係圖dependency graph,其中包含應用程式需要的每個模組,然後將所有這些模組打包成一個或多個bundle
使用webpack作為前端構建工具通常可以做到以下幾個方面的事情:

  • 程式碼轉換: TypeScript編譯成JavaScriptSCSS編譯成CSS等。
  • 檔案優化: 壓縮JavaScriptCSSHTML程式碼,壓縮合並圖片等。
  • 程式碼分割: 提取多個頁面的公共程式碼、提取首屏不需要執行部分的程式碼讓其非同步載入。
  • 模組合併: 在採用模組化的專案裡會有很多個模組和檔案,需要構建功能把模組分類合併成一個檔案。
  • 自動重新整理: 監聽本地原始碼的變化,自動重新構建、重新整理瀏覽器頁面,通常叫做模組熱替換HMR
  • 程式碼校驗: 在程式碼被提交到倉庫前需要校驗程式碼是否符合規範,以及單元測試是否通過。
  • 自動釋出: 更新完程式碼後,自動構建出線上釋出程式碼並傳輸給釋出系統。

對於webpack來說,一切皆模組,而webpack僅能處理出js以及json檔案,因此如果要使用其他型別的檔案,都需要轉換成webpack可識別的模組,即jsjson模組。也就是說無論什麼字尾的檔案例如pngtxtvue檔案等等,都需要當作js來使用,但是直接當作js來使用肯定是不行的,因為這些檔案並不符合js的語法結構,所以就需需要webpack loader來處理,幫助我們將一個非js檔案轉換為js檔案,例如css-loaderts-loaderfile-loader等等。

在這裡編寫一個簡單的webpack loader,設想一個簡單的場景,在這裡我們關注vue2,從例項出發,在平時我們構建vue專案時都是通過編寫.vue檔案來作為模組的,這種單檔案元件的方式雖然比較清晰,但是如果一個元件比較複雜的話,就會導致整個檔案相當大。當然vue中給我們提供了在.vue檔案中引用jscss的方式,但是這樣用起來畢竟還是稍顯麻煩,所以我們可以通過編寫一個webpack loader,在編寫程式碼時將三部分即htmljscss進行分離,之後在loader中將其合併,再我們編寫的loader完成處理之後再交與vue-loader去處理之後的事情。當然,關注點分離不等於檔案型別分離,將一個單檔案分成多個檔案也只是對於程式碼編寫過程中可讀性的傾向問題,在這裡我們重點關注的是編寫一個簡單的loader而不在於對於檔案是否應該分離的探討。文中涉及到的所有程式碼都在https://github.com/WindrunnerMax/webpack-simple-environment

實現

搭建環境

在這裡直接使用我之前的 初探webpack之從零搭建Vue開發環境 中搭建的簡單vue + ts開發環境,環境的相關的程式碼都在https://github.com/WindrunnerMax/webpack-simple-environment中的webpack--vue-cli分支中,我們直接將其clone並安裝。

git clone https://github.com/WindrunnerMax/webpack-simple-environment.git
git checkout webpack--vue-cli
yarn install --registry https://registry.npm.taobao.org/

之後便可以通過執行yarn dev來檢視效果,在這裡我們先列印一下此時的目錄結構。

webpack--vue-cli
├── dist
│   ├── static
│   │   └── vue-large.b022422b.png
│   ├── index.html
│   ├── index.js
│   └── index.js.LICENSE.txt
├── public
│   └── index.html
├── src
│   ├── common
│   │   └── styles.scss
│   ├── components
│   │   ├── tab-a.vue
│   │   └── tab-b.vue
│   ├── router
│   │   └── index.ts
│   ├── static
│   │   ├── vue-large.png
│   │   └── vue.jpg
│   ├── store
│   │   └── index.ts
│   ├── views
│   │   └── framework.vue
│   ├── App.vue
│   ├── index.ts
│   ├── main.ts
│   ├── sfc.d.ts
│   └── sum.ts
├── LICENSE
├── README.md
├── babel.config.js
├── package.json
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

編寫loader

在編寫loader之前,我們先關注一下上邊目錄結構中的.vue檔案,因為此時我們需要將其拆分,但是如何將其拆分是需要考慮一下的,為了儘量不影響正常的使用,在這裡採用瞭如下的方案。

  • template部分留在了.vue檔案中,因為一些外掛例如Vetur是會檢查template中的一些語法,例如將其抽離出作為html檔案,對於@click等語法prettier是會有error提醒的,而且如果不存在.vue檔案的話,對於在TS中使用declare module "*.vue"也需要修改,所以本著最小影響的原則我們將template部分留在了.vue檔案中,儲存了.vue這個宣告的檔案。
  • 對於script部分,我們將其抽出,如果是使用js編寫的,那麼就將其命名為.vue.js,同樣ts編寫的就命名為.vue.ts
  • 對於style部分,我們將其抽出,與script部分採用同樣的方案,使用cssscssless也分別命名為.vue.css.vue.scss.vue.less,而對於scoped我們通過註釋的方式來實現。

通過以上的修改,我們將檔案目錄再次列印出來,重點關注於.vue檔案的分離。

webpack--loader
├── dist
│   ├── static
│   │   └── vue-large.b022422b.png
│   ├── index.html
│   ├── index.js
│   └── index.js.LICENSE.txt
├── public
│   └── index.html
├── src
│   ├── common
│   │   └── styles.scss
│   ├── components
│   │   ├── tab-a
│   │   │   ├── tab-a.vue
│   │   │   └── tab-a.vue.ts
│   │   └── tab-b
│   │       ├── tab-b.vue
│   │       └── tab-b.vue.ts
│   ├── router
│   │   └── index.ts
│   ├── static
│   │   ├── vue-large.png
│   │   └── vue.jpg
│   ├── store
│   │   └── index.ts
│   ├── views
│   │   └── framework
│   │       ├── framework.vue
│   │       ├── framework.vue.scss
│   │       └── framework.vue.ts
│   ├── App.vue
│   ├── index.ts
│   ├── main.ts
│   ├── sfc.d.ts
│   └── sum.ts
├── LICENSE
├── README.md
├── babel.config.js
├── package.json
├── tsconfig.json
├── vue-multiple-files-loader.js
├── webpack.config.js
└── yarn.lock

現在我們開始正式編寫這個loader了,首先需要簡單說明一下loader的輸入與輸出以及常用的模組。

  • 簡單來說webpack loader是一個從stringstring的函式,輸入的是字串的程式碼,輸出也是字串的程式碼。
  • 通常來說對於各種檔案的處理loader已經都有很好的輪子了,我們自己來編寫的loader通常是用來做程式碼處理的,也就是說在loader中拿到source之後,我們將其轉換為AST樹,然後在這個AST上進行一些修改,之後再將其轉換為字串程式碼之後進行返回。
  • 從字串到AST語法分析樹是為了得到計算機容易識別的資料結構,在webpack中自帶了一些工具,acorn是程式碼轉AST的工具,estraverseAST遍歷工具,escodegen是轉換AST到字串程式碼的工具。
  • 既然loader是字串到字串的,那麼在程式碼轉換為AST處理之後需要轉為字串,然後再傳遞到下一個loader,下一個loader可能又要進行相同的轉換,這樣還是比較耗費時間的,所以可以通過speed-measure-webpack-plugin進行速率打點,以及cache-loader來儲存AST
  • loader-utils是在loader中常用的輔助類,常用的有urlToRequest絕對路徑轉webpack請求的相對路徑,urlToRequest來獲取配置loader時傳遞的引數。

由於我們在這裡這個需求是用不到AST相關的處理的,所以還是比較簡單的一個例項,首先我們需要寫一個loader檔案,然後配置在webpack.config.js中,在根目錄我們建立一個vue-multiple-files-loader.js,然後在webpack.config.jsmodule.rule部分找到test: /\.vue$/,將這部分修改為如下配置。

// ...
{
    test: /\.vue$/,
    use: [
        "vue-loader",
        {
            loader: "./vue-multiple-files-loader",
            options: {
                // 匹配的檔案擴充名
                style: ["scss", "css"],
                script: ["ts"],
            },
        },
    ],
}
// ...

首先可以看到在"vue-loader"之後我們編寫了一個物件,這個物件的loader引數是一個字串,這個字串是將來要被傳遞到require當中的,也就是說在webpack中他會自動幫我們把這個模組requirerequire("./vue-multiple-files-loader")webpack loader是有優先順序的,在這裡我們的目標是首先經由vue-multiple-files-loader這個loader將程式碼處理之後再交與vue-loader進行處理,所以我們要將vue-multiple-files-loader寫在vue-loader後邊,這樣就會首先使用vue-multiple-files-loader程式碼了。我們通過options這個物件傳遞引數,這個引數可以在loader中拿到。
關於webpack loader的優先順序,首先定義loader配置的時候,除了loaderoptions選項,還有一個enforce選項,其可接受的引數分別是pre: 前置loadernormal: 普通loaderinline: 內聯loaderpost: 後置loader,其優先順序也是pre > normal > inline > post,那麼相同優先順序的loader就是從右到左、從下到上,從上到下很好理解,至於從右到左,只是webpack選擇了compose方式,而不是pipe的方式而已,在技術上實現從左往右也不會有難度,就是函數語言程式設計中的兩種組合方式而已。此外,我們在require的時候還可以跳過某些loader!跳過normal loader-!跳過prenormal loader!!跳過pre normalpost loader,比如require("!!raw!./script.coffee"),關於loader的跳過,webpack官方的建議是,除非從另一個loader處理生成的,一般不建議主動使用。

現在我們已經處理好vue-multiple-files-loader.js這個檔案的建立以及loader的引用了,那麼我們可以通過他來編寫程式碼了,通常來說,loader一般是比較耗時的應用,所以我們通過非同步來處理這個loader,通過this.async告訴loader-runner這個loader將會非同步地回撥,當我們處理完成之後,使用其返回值將處理後的字串程式碼作為引數執行即可。

module.exports = async function (source) {
    const done = this.async();
    // do something
    done(null, source);
}

對於檔案的操作,我們使用promisify來處理,以便我們能夠更好地使用async/await

const fs = require("fs");
const { promisify } = require("util");

const readDir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);

下面我們回到上邊的需求上來,思路很簡單,首先我們在這個loader中僅會收到以.vue結尾的檔案,這是在webpack.config.js中配置的,所以我們在這裡僅關注.vue檔案,那麼在這個檔案下,我們需要獲取這個檔案所在的目錄,然後將其遍歷,通過webpack.config.js中配置的options來構建正規表示式去匹配同級目錄下的scriptstyle的相關檔案,對於匹配成功的檔案我們將其讀取然後按照.vue檔案的規則拼接到source中,然後將其返回之後將程式碼交與vue-loader處理即可。
那麼我們首先處理一下當前目錄,以及當前處理的檔名,還有正規表示式的構建,在這裡我們傳遞了scsscssts,那麼對於App.vue這個檔案來說,將會構建/App\.vue\.css$|App\.vue\.scss$/App\.vue\.ts$這兩個正規表示式。

const filePath = this.context;
const fileName = this.resourcePath.replace(filePath + "/", "");

const options = loaderUtils.getOptions(this) || {};
const styleRegExp = new RegExp(options.style.map(it => `${fileName}\\.${it}$`).join("|"));
const scriptRegExp = new RegExp(options.script.map(it => `${fileName}\\.${it}$`).join("|"));

之後我們通過遍歷目錄的方式,來匹配符合要求的scriptstyle的檔案路徑。

let stylePath = null;
let scriptPath = null;

const files = await readDir(filePath);
files.forEach(file => {
    if (styleRegExp.test(file)) stylePath = path.join(filePath, file);
    if (scriptRegExp.test(file)) scriptPath = path.join(filePath, file);
});

之後對於script部分,存在匹配節點且原.vue檔案不存在script標籤,則非同步讀取檔案之後將程式碼進行拼接,如果擴充名不為js的話,例如是ts編寫的那麼就會將其作為lang="ts"去處理,之後將其拼接到source這個字串中。

if (scriptPath && !/<script[\s\S]*?>/.test(source)) {
    const extName = scriptPath.split(".").pop();
    if (extName) {
        const content = await readFile(scriptPath, "utf8");
        const scriptTagContent = [
            "<script ",
            extName === "js" ? "" : `lang="${extName}" `,
            ">\n",
            content,
            "</script>",
        ].join("");
        source = source + "\n" + scriptTagContent;
    }
}

之後對於style部分,存在匹配節點且原.vue檔案不存在style標籤,則非同步讀取檔案之後將程式碼進行拼接,如果擴充名不為css的話,例如是scss編寫的那麼就會將其作為lang="scss"去處理,如果程式碼中存在單行的// scoped字樣的話,就會將這個style部分作scoped處理,之後將其拼接到source這個字串中。

if (stylePath && !/<style[\s\S]*?>/.test(source)) {
    const extName = stylePath.split(".").pop();
    if (extName) {
        const content = await readFile(stylePath, "utf8");
        const scoped = /\/\/[\s]scoped[\n]/.test(content) ? true : false;
        const styleTagContent = [
            "<style ",
            extName === "css" ? "" : `lang="${extName}" `,
            scoped ? "scoped " : " ",
            ">\n",
            content,
            "</style>",
        ].join("");
        source = source + "\n" + styleTagContent;
    }
}

在之後使用done(null, source)觸發回撥完成loader的流程,相關程式碼如下所示,完整程式碼在https://github.com/WindrunnerMax/webpack-simple-environment中的webpack--loader分支當中。

const fs = require("fs");
const path = require("path");
const { promisify } = require("util");
const loaderUtils = require("loader-utils");

const readDir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);

module.exports = async function (source) {
    const done = this.async();
    const filePath = this.context;
    const fileName = this.resourcePath.replace(filePath + "/", "");

    const options = loaderUtils.getOptions(this) || {};
    const styleRegExp = new RegExp(options.style.map(it => `${fileName}\\.${it}$`).join("|"));
    const scriptRegExp = new RegExp(options.script.map(it => `${fileName}\\.${it}$`).join("|"));

    let stylePath = null;
    let scriptPath = null;

    const files = await readDir(filePath);
    files.forEach(file => {
        if (styleRegExp.test(file)) stylePath = path.join(filePath, file);
        if (scriptRegExp.test(file)) scriptPath = path.join(filePath, file);
    });

    // 存在匹配節點且原`.vue`檔案不存在`script`標籤
    if (scriptPath && !/<script[\s\S]*?>/.test(source)) {
        const extName = scriptPath.split(".").pop();
        if (extName) {
            const content = await readFile(scriptPath, "utf8");
            const scriptTagContent = [
                "<script ",
                extName === "js" ? "" : `lang="${extName}" `,
                ">\n",
                content,
                "</script>",
            ].join("");
            source = source + "\n" + scriptTagContent;
        }
    }

    // 存在匹配節點且原`.vue`檔案不存在`style`標籤
    if (stylePath && !/<style[\s\S]*?>/.test(source)) {
        const extName = stylePath.split(".").pop();
        if (extName) {
            const content = await readFile(stylePath, "utf8");
            const scoped = /\/\/[\s]scoped[\n]/.test(content) ? true : false;
            const styleTagContent = [
                "<style ",
                extName === "css" ? "" : `lang="${extName}" `,
                scoped ? "scoped " : " ",
                ">\n",
                content,
                "</style>",
            ].join("");
            source = source + "\n" + styleTagContent;
        }
    }

    // console.log(stylePath, scriptPath, source);
    done(null, source);
};

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://webpack.js.org/api/loaders/
https://juejin.cn/post/6844904054393405453
https://segmentfault.com/a/1190000014685887
https://segmentfault.com/a/1190000021657031
https://webpack.js.org/concepts/loaders/#inline
http://t.zoukankan.com/hanshuai-p-11287231.html
https://v2.vuejs.org/v2/guide/single-file-components.html

相關文章