初探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