Webpack - 手把手教你寫一個 loader / plugin
一、Loader
1.1 loader 干啥的?
webpack 只能理解 JavaScript 和 JSON 文件,這是 webpack 開箱可用的自帶能力。**loader **讓 webpack 能夠去處理其他類型的文件,并將它們轉換為有效模塊,以供應用程序使用,以及被添加到依賴圖中。
也就是說,webpack 把任何文件都看做模塊,loader 能 import 任何類型的模塊,但是 webpack 原生不支持譬如 css 文件等的解析,這時候就需要用到我們的 loader 機制了。 我們的 loader 主要通過兩個屬性來讓我們的 webpack 進行聯動識別:
- test 屬性,識別出哪些文件會被轉換。
- use 屬性,定義出在進行轉換時,應該使用哪個 loader。
那么問題來了,大家一定想知道自己要定制一個 loader 的話需要怎么做呢?
1.2 開發準則
俗話說的好,沒有規矩不成方圓,編寫我們的 loader 時,官方也給了我們一套用法準則(Guidelines),在編寫的時候應該按照這套準則來使我們的 loader 標準化:
- 簡單易用。
- 使用鏈式傳遞。(由于 loader 是可以被鏈式調用的,所以請保證每一個 loader 的單一職責)
- 模塊化的輸出。
- 確保無狀態。(不要讓 loader 的轉化中保留之前的狀態,每次運行都應該獨立于其他編譯模塊以及相同模塊之前的編譯結果)
- 充分使用官方提供的 loader utilities。
- 記錄 loader 的依賴。
- 解析模塊依賴關系。
根據模塊類型,可能會有不同的模式指定依賴關系。例如在 CSS 中,使用@import 和 url(...)語句來聲明依賴。這些依賴關系應該由模塊系統解析。 可以通過以下兩種方式中的一種來實現:
- 通過把它們轉化成 require 語句。
- 使用 this.resolve 函數解析路徑。
- 提取通用代碼。
- 避免絕對路徑。
- 使用 peer dependencies。如果你的 loader 簡單包裹另外一個包,你應該把這個包作為一個 peerDependency 引入。
1.3 上手
一個 loader 就是一個 nodejs 模塊,他導出的是一個函數,這個函數只有一個入參,這個參數就是一個包含資源文件內容的字符串,而函數的返回值就是處理后的內容。也就是說,一個最簡單的 loader 長這樣:
- module.exports = function (content) {
- // content 就是傳入的源內容字符串
- return content
- }
當一個 loader 被使用的時候,他只可以接收一個入參,這個參數是一個包含包含資源文件內容的字符串。 是的,到這里為止,一個最簡單 loader 就已經完成了!接下來我們來看看怎么給他加上豐富的功能。
1.4 四種 loader
我們基本可以把常見的 loader 分為四種:
- 同步 loader
- 異步 loader
- "Raw" Loader
- Pitching loader
① 同步 loader 與 異步 loader
一般的 loader 轉換都是同步的,我們可以采用上面說的直接 return 結果的方式,返回我們的處理結果:
- module.exports = function (content) {
- // 對 content 進行一些處理
- const res = dosth(content)
- return res
- }
也可以直接使用 this.callback() 這個 api,然后在最后直接 **return undefined **的方式告訴 webpack 去 this.callback() 尋找他要的結果,這個 api 接受這些參數:
- this.callback(
- err: Error | null, // 一個無法正常編譯時的 Error 或者 直接給個 null
- content: string | Buffer,// 我們處理后返回的內容 可以是 string 或者 Buffer()
- sourceMap?: SourceMap, // 可選 可以是一個被正常解析的 source map
- meta?: any // 可選 可以是任何東西,比如一個公用的 AST 語法樹
- );
接下來舉個例子:
這里注意[this.getOptions()](https://webpack.docschina.org/api/loaders/#thisgetoptionsschema) 可以用來獲取配置的參數
從 webpack 5 開始,this.getOptions 可以獲取到 loader 上下文對象。它用來替代來自loader-utils中的 getOptions 方法。
- module.exports = function (content) {
- // 獲取到用戶傳給當前 loader 的參數
- const options = this.getOptions()
- const res = someSyncOperation(content, options)
- this.callback(null, res, sourceMaps);
- // 注意這里由于使用了 this.callback 直接 return 就行
- return
- }
這樣一個同步的 loader 就完成了!
再來說說異步: 同步與異步的區別很好理解,一般我們的轉換流程都是同步的,但是當我們遇到譬如需要網絡請求等場景,那么為了避免阻塞構建步驟,我們會采取異步構建的方式,對于異步 loader 我們主要需要使用 this.async() 來告知 webpack 這次構建操作是異步的,不多廢話,看代碼就懂了:
- module.exports = function (content) {
- var callback = this.async()
- someAsyncOperation(content, function (err, result) {
- if (err) return callback(err)
- callback(null, result, sourceMaps, meta)
- })
- }
② "Raw" loader
默認情況下,資源文件會被轉化為 UTF-8 字符串,然后傳給 loader。通過設置 raw 為 true,loader 可以接收原始的 Buffer。每一個 loader 都可以用 String 或者 Buffer 的形式傳遞它的處理結果。complier 將會把它們在 loader 之間相互轉換。大家熟悉的 file-loader 就是用了這個。簡而言之:你加上 module.exports.raw = true; 傳給你的就是 Buffer 了,處理返回的類型也并非一定要是 Buffer,webpack 并沒有限制。
- module.exports = function (content) {
- console.log(content instanceof Buffer); // true
- return doSomeOperation(content)
- }
- // 劃重點↓
- module.exports.raw = true;
③ Pitching loader
我們每一個 loader 都可以有一個 pitch 方法,大家都知道,loader 是按照從右往左的順序被調用的,但是實際上,在此之前會有一個按照從左往右執行每一個 loader 的 pitch 方法的過程。pitch 方法共有三個參數:
- remainingRequest:loader 鏈中排在自己后面的 loader 以及資源文件的絕對路徑以!作為連接符組成的字符串。
- precedingRequest:loader 鏈中排在自己前面的 loader 的絕對路徑以!作為連接符組成的字符串。
- data:每個 loader 中存放在上下文中的固定字段,可用于 pitch 給 loader 傳遞數據。
在 pitch 中傳給 data 的數據,在后續的調用執行階段,是可以在 this.data 中獲取到的:
- module.exports = function (content) {
- return someSyncOperation(content, this.data.value);// 這里的 this.data.value === 42
- };
- module.exports.pitch = function (remainingRequest, precedingRequest, data) {
- data.value = 42;
- };
注意! 如果某一個 loader 的 pitch 方法中返回了值,那么他會直接“往回走”,跳過后續的步驟,來舉個例子:
假設我們現在是這樣:use: ['a-loader', 'b-loader', 'c-loader'],那么正常的調用順序是這樣:
現在 b-loader 的 pitch 改為了有返回值:
- // b-loader.js
- module.exports = function (content) {
- return someSyncOperation(content);
- };
- module.exports.pitch = function (remainingRequest, precedingRequest, data) {
- return "誒,我直接返回,就是玩兒~"
- };
那么現在的調用就會變成這樣,直接“回頭”,跳過了原來的其他三個步驟:
1.5 其他 API
- this.addDependency:加入一個文件進行監聽,一旦文件產生變化就會重新調用這個 loader 進行處理
- this.cacheable:默認情況下 loader 的處理結果會有緩存效果,給這個方法傳入 false 可以關閉這個效果
- this.clearDependencies:清除 loader 的所有依賴
- this.context:文件所在的目錄(不包含文件名)
- this.data:pitch 階段和正常調用階段共享的對象
- this.getOptions(schema):用來獲取配置的 loader 參數選項
- this.resolve:像 require 表達式一樣解析一個 request。resolve(context: string, request: string, callback: function(err, result: string))
- this.loaders:所有 loader 組成的數組。它在 pitch 階段的時候是可以寫入的。
- this.resource:獲取當前請求路徑,包含參數:'/abc/resource.js?rrr'
- this.resourcePath:不包含參數的路徑:'/abc/resource.js'
- this.sourceMap:bool 類型,是否應該生成一個 sourceMap
官方還提供了很多實用 Api ,這邊只列舉一些可能常用的,更多可以戳鏈接👇更多詳見官方鏈接
1.6 來個簡單實踐
功能實現
接下來我們簡單實踐制作兩個 loader ,功能分別是在編譯出的代碼中加上 /** 公司@年份 */ 格式的注釋和簡單做一下去除代碼中的 console.log ,并且我們鏈式調用他們:
company-loader.js
- module.exports = function (source) {
- const options = this.getOptions() // 獲取 webpack 配置中傳來的 option
- this.callback(null, addSign(source, options.sign))
- return
- }
- function addSign(content, sign) {
- return `/** ${sign} */\n${content}`
- }
console-loader.js
- module.exports = function (content) {
- return handleConsole(content)
- }
- function handleConsole(content) {
- return content.replace(/console.log\(['|"](.*?)['|"]\)/, '')
- }
調用測試方式
功能就簡單的進行了一下實現,這里我們主要說一下如何測試調用我們的本地的 loader,方式有兩種,一種是通過 Npm link 的方式進行測試,這個方式的具體使用就不細說了,大家可以簡單查閱一下。 另外一種就是直接在項目中通過路徑配置的方式,有兩種情況:
1.匹配(test)單個 loader,你可以簡單通過在 rule 對象設置 path.resolve 指向這個本地文件
webpack.config.js
- {
- test: /\.js$/
- use: [
- {
- loader: path.resolve('path/to/loader.js'),
- options: {/* ... */}
- }
- ]
- }
2.匹配(test)多個 loaders,你可以使用 resolveLoader.modules 配置,webpack 將會從這些目錄中搜索這些 loaders。例如,如果你的項目中有一個 /loaders 本地目錄:
webpack.config.js
- resolveLoader: {
- // 這里就是說先去找 node_modules 目錄中,如果沒有的話再去 loaders 目錄查找
- modules: [
- 'node_modules',
- path.resolve(__dirname, 'loaders')
- ]
- }
配置使用
我們這里的 webpack 配置如下所示:
- module: {
- rules: [
- {
- test: /\.js$/,
- use: [
- 'console-loader',
- {
- loader: 'company-loader',
- options: {
- sign: 'we-doctor@2021',
- },
- },
- ],
- },
- ],
- },
項目中的 index.js:
- function fn() {
- console.log("this is a message")
- return "1234"
- }
執行編譯后的 bundle.js: 可以看到,兩個 loader 的功能都體現到了編譯后的文件內。
- /******/ (() => { // webpackBootstrap
- var __webpack_exports__ = {};
- /*!**********************!*\
- !*** ./src/index.js ***!
- \**********************/
- /** we-doctor@2021 */
- function fn() {
- return "1234"
- }
- /******/ })()
- ;
二、Plugin
為什么要有 plugin
plugin 提供了很多比 loader 中更完備的功能,他使用階段式的構建回調,webpack 給我們提供了非常多的 hooks 用來在構建的階段讓開發者自由的去引入自己的行為。
基本結構
- 一個最基本的 plugin 需要包含這些部分:
- 一個 JavaScript 類
- 一個 apply 方法,apply 方法在 webpack 裝載這個插件的時候被調用,并且會傳入 compiler 對象。
- 使用不同的 hooks 來指定自己需要發生的處理行為
- 在異步調用時最后需要調用 webpack 提供給我們的 callback 或者通過 Promise 的方式(后續異步編譯部分會詳細說)
- class HelloPlugin{
- apply(compiler){
- compiler.hooks.<hookName>.tap(PluginName,(params)=>{
- /** do some thing */
- })
- }
- }
- module.exports = HelloPlugin
Compiler andCompilation
Compiler 和 Compilation 是整個編寫插件的過程中的**重!中!之!重!**因為我們幾乎所有的操作都會圍繞他們。
compiler 對象可以理解為一個和 webpack 環境整體綁定的一個對象,它包含了所有的環境配置,包括 options,loader 和 plugin,當 webpack 啟動時,這個對象會被實例化,并且他是全局唯一的,上面我們說到的 apply 方法傳入的參數就是它。
compilation 在每次構建資源的過程中都會被創建出來,一個 compilation 對象表現了當前的模塊資源、編譯生成資源、變化的文件、以及被跟蹤依賴的狀態信息。它同樣也提供了很多的 hook 。
Compiler 和 Compilation 提供了非常多的鉤子供我們使用,這些方法的組合可以讓我們在構建過程的不同時間獲取不同的內容,具體詳情可參見官網直達。
上面的鏈接中我們會發現鉤子會有不同的類型,比如 SyncHook、SyncBailHook、AsyncParallelHook、AsyncSeriesHook ,這些不同的鉤子類型都是由 tapable 提供給我們的,關于 tapable 的詳細用法與解析可以參考我們前端構建工具系列專欄中的 tapable 專題講解。
基本的使用方式是:
- compiler/compilation.hooks.<hookName>.tap/tapAsync/tapPromise(pluginName,(xxx)=>{/**dosth*/})
- Tip: 以前的寫法是 compiler.plugin ,但是在最新的 webpack@5 可能會引起問題,參見 webpack-4-migration-notes
同步與異步
plugin 的 hooks 是有同步和異步區分的,在同步的情況下,我們使用
tapAsync
使用 tapAsync 的時候,我們需要多傳入一個 callback 回調,并且在結束的時候一定要調用這個回調告知 webpack 這段異步操作結束了。👇 比如:
- class HelloPlugin {
- apply(compiler) {
- compiler.hooks.emit.tapAsync(HelloPlugin, (compilation, callback) => {
- setTimeout(() => {
- console.log('async')
- callback()
- }, 1000)
- })
- }
- }
- module.exports = HelloPlugin
tapPromise
當使用 tapPromise 來處理異步的時候,我們需要返回一個 Promise 對象并且讓它在結束的時候 resolve 👇
- class HelloPlugin {
- apply(compiler) {
- compiler.hooks.emit.tapPromise(HelloPlugin, (compilation) => {
- return new Promise((resolve) => {
- setTimeout(() => {
- console.log('async')
- resolve()
- }, 1000)
- })
- })
- }
- }
- module.exports = HelloPlugin
做個實踐
接下來我們通過實際來做一個插件梳理一遍整體的流程和零散的功能點,這個插件實現的功能是在打包后輸出的文件夾內多增加一個 markdown 文件,文件內記錄打包的時間點、文件以及文件大小的輸出。
首先我們根據需求確定我們需要的 hook ,由于需要輸出文件,我們需要使用 compilation 的 emitAsset 方法。 其次由于需要對 assets 進行處理,所以我們使用 compilation.hooks.processAssets ,因為 processAssets 是負責 asset 處理的鉤子。
這樣我們插件結構就出來了👇OutLogPlugin.js
- class OutLogPlugin {
- constructor(options) {
- this.outFileName = options.outFileName
- }
- apply(compiler) {
- // 可以從編譯器對象訪問 webpack 模塊實例
- // 并且可以保證 webpack 版本正確
- const { webpack } = compiler
- // 獲取 Compilation 后續會用到 Compilation 提供的 stage
- const { Compilation } = webpack
- const { RawSource } = webpack.sources
- /** compiler.hooks.<hoonkName>.tap/tapAsync/tapPromise */
- compiler.hooks.compilation.tap('OutLogPlugin', (compilation) => {
- compilation.hooks.processAssets.tap(
- {
- name: 'OutLogPlugin',
- // 選擇適當的 stage,具體參見:
- // https://webpack.js.org/api/compilation-hooks/#list-of-asset-processing-stages
- stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
- },
- (assets) => {
- let resOutput = `buildTime: ${new Date().toLocaleString()}\n\n`
- resOutput += `| fileName | fileSize |\n| --------- | --------- |\n`
- Object.entries(assets).forEach(([pathname, source]) => {
- resOutput += `| ${pathname} | ${source.size()} bytes |\n`
- })
- compilation.emitAsset(
- `${this.outFileName}.md`,
- new RawSource(resOutput),
- )
- },
- )
- })
- }
- }
- module.exports = OutLogPlugin
對插件進行配置:webpack.config.js
- const OutLogPlugin = require('./plugins/OutLogPlugin')
- module.exports = {
- plugins: [
- new OutLogPlugin({outFileName:"buildInfo"})
- ],
- }
打包后的目錄結構:
- dist
- ├─ buildInfo.md
- ├─ bundle.js
- └─ bundle.js.map
buildInfo.md
可以看到按照我們希望的格式準確輸出了內容,這樣一個簡單的功能插件就完成了!