初探webpack之編寫loader
loader
加載器是webpack
的核心之一,其用於將不同類型的文件轉換爲webpack
可識別的模塊,即用於把模塊原內容按照需求轉換成新內容,用以加載非js
模塊,通過配合擴展插件,在webpack
構建流程中的特定時機注入擴展邏輯來改變構建結果,從而完成一次完整的構建。
描述
webpack
是一個現代JavaScript
應用程序的靜態模塊打包器module bundler
,當webpack
處理應用程序時,它會遞歸地構建一個依賴關係圖dependency graph
,其中包含應用程序需要的每個模塊,然後將所有這些模塊打包成一個或多個bundle
。
使用webpack
作爲前端構建工具通常可以做到以下幾個方面的事情:
- 代碼轉換:
TypeScript
編譯成JavaScript
、SCSS
編譯成CSS
等。 - 文件優化: 壓縮
JavaScript
、CSS
、HTML
代碼,壓縮合並圖片等。 - 代碼分割: 提取多個頁面的公共代碼、提取首屏不需要執行部分的代碼讓其異步加載。
- 模塊合併: 在採用模塊化的項目裏會有很多個模塊和文件,需要構建功能把模塊分類合併成一個文件。
- 自動刷新: 監聽本地源代碼的變化,自動重新構建、刷新瀏覽器頁面,通常叫做模塊熱替換
HMR
。 - 代碼校驗: 在代碼被提交到倉庫前需要校驗代碼是否符合規範,以及單元測試是否通過。
- 自動發佈: 更新完代碼後,自動構建出線上發佈代碼並傳輸給發佈系統。
對於webpack
來說,一切皆模塊,而webpack
僅能處理出js
以及json
文件,因此如果要使用其他類型的文件,都需要轉換成webpack
可識別的模塊,即js
或json
模塊。也就是說無論什麼後綴的文件例如png
、txt
、vue
文件等等,都需要當作js
來使用,但是直接當作js
來使用肯定是不行的,因爲這些文件並不符合js
的語法結構,所以就需需要webpack loader
來處理,幫助我們將一個非js
文件轉換爲js
文件,例如css-loader
、ts-loader
、file-loader
等等。
在這裏編寫一個簡單的webpack loader
,設想一個簡單的場景,在這裏我們關注vue2
,從實例出發,在平時我們構建vue
項目時都是通過編寫.vue
文件來作爲模塊的,這種單文件組件的方式雖然比較清晰,但是如果一個組件比較複雜的話,就會導致整個文件相當大。當然vue
中給我們提供了在.vue
文件中引用js
、css
的方式,但是這樣用起來畢竟還是稍顯麻煩,所以我們可以通過編寫一個webpack loader
,在編寫代碼時將三部分即html
、js
、css
進行分離,之後在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
部分採用同樣的方案,使用css
、scss
、less
也分別命名爲.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
是一個從string
到string
的函數,輸入的是字符串的代碼,輸出也是字符串的代碼。 - 通常來說對於各種文件的處理
loader
已經都有很好的輪子了,我們自己來編寫的loader
通常是用來做代碼處理的,也就是說在loader
中拿到source
之後,我們將其轉換爲AST
樹,然後在這個AST
上進行一些修改,之後再將其轉換爲字符串代碼之後進行返回。 - 從字符串到
AST
語法分析樹是爲了得到計算機容易識別的數據結構,在webpack
中自帶了一些工具,acorn
是代碼轉AST
的工具,estraverse
是AST
遍歷工具,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.js
的module.rule
部分找到test: /\.vue$/
,將這部分修改爲如下配置。
// ...
{
test: /\.vue$/,
use: [
"vue-loader",
{
loader: "./vue-multiple-files-loader",
options: {
// 匹配的文件拓展名
style: ["scss", "css"],
script: ["ts"],
},
},
],
}
// ...
首先可以看到在"vue-loader"
之後我們編寫了一個對象,這個對象的loader
參數是一個字符串,這個字符串是將來要被傳遞到require
當中的,也就是說在webpack
中他會自動幫我們把這個模塊require
即require("./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
配置的時候,除了loader
與options
選項,還有一個enforce
選項,其可接受的參數分別是pre:
前置loader
、normal:
普通loader
、inline:
內聯loader
、post:
後置loader
,其優先級也是pre > normal > inline > post
,那麼相同優先級的loader
就是從右到左、從下到上,從上到下很好理解,至於從右到左,只是webpack
選擇了compose
方式,而不是pipe
的方式而已,在技術上實現從左往右也不會有難度,就是函數式編程中的兩種組合方式而已。此外,我們在require
的時候還可以跳過某些loader
,!
跳過normal loader
、-!
跳過pre
和normal loader
、!!
跳過pre normal
和post 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
來構建正則表達式去匹配同級目錄下的script
與style
的相關文件,對於匹配成功的文件我們將其讀取然後按照.vue
文件的規則拼接到source
中,然後將其返回之後將代碼交與vue-loader
處理即可。
那麼我們首先處理一下當前目錄,以及當前處理的文件名,還有正則表達式的構建,在這裏我們傳遞了scss
、css
和ts
,那麼對於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("|"));
之後我們通過遍歷目錄的方式,來匹配符合要求的script
和style
的文件路徑。
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