初探webpack之編寫loader

初探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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章