webpack 快速入門 系列 - 自定義 wepack 上

彭加李發表於2021-06-21

其他章節請看:

webpack 快速入門 系列

自定義 wepack 上

通過“初步認識webpack”和“實戰一”這 2 篇文章,我們已經學習了 webpack 最基礎的知識。在繼續學習 webpack 更多用法之前,我們先從更底層的角度來認識 webpack。

自定義 webpack 分上下兩篇,上篇介紹 webpack 的兩個核心,loader和plugin;下篇我們自己實現一個簡單的 webpack。

初始化專案

loader 和 plugin 將使用此環境進行。

輸入以下命名初始專案:

> mkdir webpack-demo
> cd webpack-demo
> npm init -y
> npm i -D webpack@5 webpack-cli@4

Tip: 如果出現如下錯誤,可以嘗試執行 npm cache clean --force 來解決。

> npm i -D webpack       
npm ERR! code FETCH_ERROR
npm ERR! errno FETCH_ERROR
npm ERR! invalid json response body at http://registry.npmjs.org/webpack reason: Unexpected end of JSON input

npm ERR!     ......\npm-cache\_logs\2021-06-21T07_25_58_995Z-debug.log

> npm cache clean --force

新建兩個空檔案:配置檔案(webpack.config.js)和入口檔案(src/index.js)。

目錄結構如下:

webpack-demo
  - myLoaders
  - myPlugins
  - src
    - index.js
  - webpack.config.js
  - package.json

loader

loader 的本質

loader 本質上是匯出為函式的 JavaScript 模組。

我們新建一個loader,然後在配置檔案中引入該loader,最後打包。示例如下:

新建loader(myLoaders/loader1.js):

/**
 *
 * @param {string|Buffer} content 原始檔的內容
 * @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 資料
 * @param {any} [meta] meta 資料,可以是任何內容
 */
 module.exports = function webpackLoader(content, map, meta) {
     console.log(`content=${content}`)
     // 必須有返回值
     return content
}
  

配置檔案(webpack.config.js):

const path = require('path');
module.exports = {
    module: {
        rules: [
            {loader: path.join(path.resolve(__dirname, 'myLoaders'), 'loader1.js')}
        ]
    }
};

:筆者採用 webpack v5,無需配置 entry 和 output。

入口檔案(src/index.js):

console.log('hello')

打包:

// 執行 npx webpack,會將我們的指令碼 src/index.js 作為 入口起點,也會生成 dist/main.js 作為 輸出
> npx webpack
// loader1中的輸出:
content=console.log('hello')
asset main.js 21 bytes [compared for emit] [minimized] (name: main)
./src/index.js 20 bytes [built] [code generated]

自定義 loader 確實執行了,驗證通過。

我們可以使用 resolveLoader 簡化 loader 的路徑。請看示例:

// 配置檔案
const path = require('path');
module.exports = {
    module: {
        rules: [
            {loader:'loader1'}
        ]
    },
    resolveLoader: {
        modules: ['node_modules', path.resolve(__dirname, 'myLoaders')]
    }
};

執行順序

前面我們說到 loader 從右往左執行。例如 use: ["style-loader", "css-loader"] 會先執行 css-loader,然後再執行 style-loader,我們驗證一下。

核心程式碼如下:

// myLoaders/loader1.js
module.exports = function webpackLoader(content, map, meta) {
     console.log(`loader1`)
     return content
}

// myLoaders/loader2.js
module.exports = function webpackLoader(content, map, meta) {
     console.log(`loader2`)
     return content
}

// 修改配置檔案
module: {
    rules: [
        {use:['loader1', 'loader2']}
    ]
}

打包:

> npx webpack
loader2
loader1

驗證通過。雖然 loader 總是從右到左被呼叫。在實際(從右到左)執行 loader 之前,會先從左到右呼叫 loader 上的 pitch 方法。請看示例:

核心程式碼如下:

// loader1.js
// + 
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
     console.log(`pitch1`)
};

// loader2.js
// + 
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
     console.log(`pitch2`)
};

打包:

> npx webpack
pitch1
pitch2
loader2
loader1

同步 Loaders

無論是 return 還是 this.callback() 都可以同步地返回轉換後的 content 值。

this.callback() 方法則更靈活,因為它允許傳遞多個引數,而不僅僅是 content。請看示例:

核心程式碼如下:

// loader1.js
module.exports = function webpackLoader(content, map, meta) {
    console.log(`${content}`)
    return content
}

// loader2.js
module.exports = function webpackLoader(content, map, meta) {
     // node 的錯誤優先風格
     this.callback(null, someSyncOperation(content), map, meta);
}

function someSyncOperation(content){
     return `${content};console.log('i am loader2')`
}

// src/index.js
console.log('hello')

// 配置檔案
rules: [
    {use:['loader1', 'loader2']}
]

打包:

> npx webpack
// 輸出
console.log('hello');console.log('i am loader2')

先執行 loader2,然後在 loader1 中輸出。

非同步 Loaders

對於非同步 loader,使用 this.async() 來獲取 callback 函式。

將同步 loaders 的例子改為非同步,核心程式碼如下:

// loader1.js
module.exports = function webpackLoader(content, map, meta) {
    var callback = this.async();
    console.log(`${content}`)
    callback(null, content, map, meta);
}

// loader2.js
module.exports = function webpackLoader(content, map, meta) {
     const callback = this.async();
     console.log('i am loader2,非同步處理需要3秒鐘')
     someAsyncOperation(content, function (err, result) {
          if (err) return callback(err);
          callback(null, result, map, meta);
     });
}

function someAsyncOperation(content, callback){
     setTimeout(function(){
          callback(null, `${content};console.log('i am loader2')`)
     }, 3000)
}

打包:

> npx webpack
i am loader2,非同步處理需要3秒鐘
// 需要等3秒才會列印下面資訊
console.log('hello');console.log('i am loader2')

Tip: 由於同步計算過於耗時,在Node.js這樣的單執行緒環境下,建議使用非同步 loader。

獲取和校驗引數

我們使用 loader 時,通常也會配置引數,就像這樣:

{
  loader: 'url-loader',
  // 配置引數
  options: {
    limit: 1024*7
  },
},

下面我們模擬一下引數的獲取和驗證。需要用到如下幾個包:

  • loader-utils,utils for webpack loaders 。用於取得引數
  • schema-utils,validate options in loaders and plugins。用於驗證引數

核心程式碼如下:

myLoaders/loader1.js:

const {getOptions} = require('loader-utils')
// 驗證規則
const schema = require('./loader1-schema.json')
// 獲取驗證的方法
const {validate} = require('schema-utils')

module.exports = function webpackLoader(content, map, meta) {
    const callback = this.async();
    // 獲取配置的引數
    const options = getOptions(this)
    console.log(schema)
    // { limit: '1024*7' }
    console.log(options)
    // name:loader或plugin的名字
    configuration = {name:'loader1'}
    // 驗證
    validate(schema, options, configuration)
    // 驗證通過才會輸出下面的語句
    console.log(`loader1: ${content}`)
    callback(null, content, map, meta);
}

myLoaders/loader1-schema.json:

{
    "type": "object",
    "properties": {
      "limit": {
        "type": "number"
      }
    },
    "additionalProperties": false
}

配置檔案:

rules: [
      {
          loader: 'loader1',
          options: {
              limit: 1024*7
              // 若將值改為字串,打包則會報錯:configuration.limit should be a number
              // limit: "1024*7"
              
          },
      }
  ]

打包:

// 安裝依賴包
> npm i -D loader-utils@2 schema-utils@3

> npx webpack
{
  type: 'object',
  properties: { limit: { type: 'number' } },
  additionalProperties: false
}
{ limit: 7168 }
loader1: console.log('hello')
asset main.js 21 bytes [compared for emit] [minimized] (name: main)
./src/index.js 20 bytes [built] [code generated]

自定義 babel-loader

在“webpack 快速入門 系列 —— 實戰一”一文中,我們使用如下配置,將箭頭函式打包成了普通函式。

module: {
  rules: [
    // +
    {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env']
            ]
          }
        }
    }
  ]
}

現在我們自己實現一個 babel-loader,來完成類似的工作。

主要思路是:

  • 根據上一節(獲取和校驗引數)將框架搭好。包括修改配置檔案的rules、新建babel-loader.js、新建babel-schema.json
  • 使用 @babel/core 中 transform() 方法將程式碼轉換

程式碼如下:

修改 webpack.config.js:

...
module: {
    rules: [
        {
            test: /\.js$/,
            exclude: /node_modules/,
            use: {
                loader: 'babel-loader',
                options: {
                presets: [
                    ['@babel/preset-env']
                ]
                }
            }
        }
    ]
}

myLoaders/babel-loader.js:

const {getOptions} = require('loader-utils')
const schema = require('./babel-schema.json')
const {validate} = require('schema-utils')
// 包 @babel/core,Babel 編譯器核心
const babelCore = require('@babel/core')
// 使用 util.promisify 方法
const util = require('util')

module.exports = function webpackLoader(content, map, meta) {
    const callback = this.async();
    const options = getOptions(this)
    configuration = {name:'babel-loader'}
    validate(schema, options, configuration)
    console.log(options)
    // 將非同步回撥轉為 promise
    const transform = util.promisify(babelCore.transform) 
    transform(content, options).then(({code, map, meta}) => {
        callback(null, code, map, meta)
    }).catch((err) => {
        // node 中採用錯誤優先
        callback(err)
    })
}

myLoaders/babel-schema.json:

{
    "type": "object",
    "properties": {
      "presets": {
        "type": "array"
      }
    },
    "additionalProperties": false
}

src/index.js:

class People{
    constructor(name, sex){
        this.name = name;
        this.sex = sex;
    }
}
const p1 = new People('aaron', 0);
console.log(p1)

打包:

// 按照我們的 loader 依賴的包
// 如果只安裝@babel/core,打包時會報錯,並提示我們安裝 @babel/preset-env
> npm i @babel/core@7 @babel/preset-env@7 -D
// 打包
> npx webpack
{ presets: [ [ '@babel/preset-env' ] ] }
asset main.js 201 bytes [emitted] [compared for emit] [minimized] (name: main)
./src/index.js 337 bytes [built] [code generated]

檢視編譯後的檔案(dist/main.js):

(()=>{"use strict";var n=new function n(a,o){!function(n,a){if(!(n instanceof a))throw new TypeError("Cannot call a class as a function")}(this,n),this.name=a,this.sex=o}("aaron",0);console.log(n)})();

將這個檔案在 node 中執行:

> node dist/main.js
{ name: 'aaron', sex: 0 }

至此,編譯後的程式碼等效於我們寫的原始碼,我們的 babel-loader 驗證通過。

Tip: util.Promisify() 將一個遵循常見的錯誤優先的回撥風格的函式轉為 Promise。請看示例:

const fs = require('fs')
const util = require('util');

// fs.stat是獲取檔案狀態的非同步函式
// 且以 (err, stats) => {...} 作為最後一個引數
fs.stat('./index.js', (err, stats) => {
    console.log(`檔案大小:${stats.size}`)
})


// 將 fs.stat 轉為 Promise
const stat = util.promisify(fs.stat);
stat('./index.js').then(stats => {
    console.log(`檔案大小:${stats.size}`)
}).catch(err => {

})

外掛

plugin 的本質

plugin 是一個具有 apply 方法的 JavaScript 物件。

我們新建一個 plugin,修改配置檔案,然後打包。請看示例:

myPlugins/myPlugin.js:

const pluginName = 'myPlugin';

class myPlugin {
  apply(compiler) {
    // tap 方法的第一個引數,應該是駝峰式命名的外掛名稱,建議使用常量
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('webpack 構建過程開始!');
    });
  }
}

module.exports = myPlugin;

webpack.config.js:

const myPlugin = require('./myPlugins/myPlugin')
module.exports = {
    plugins:[
      new myPlugin()
    ]
};

打包:

> npx webpack
// myPlugin 輸出
webpack 構建過程開始!
asset main.js 96 bytes [compared for emit] [minimized] (name: main)
./src/index.js 161 bytes [built] [code generated]

自定義 plugin 驗證通過。

我們再來看一下 myPlugin.js,apply 方法會被 webpack compiler 呼叫,並且在 整個 編譯生命週期都可以訪問 compiler 物件。這段程式碼提到compilertapcompilation

由於存在某種關係,即 Compiler 擴充套件自 Tapable,Compiler 建立 compilation。所以下面我們依次介紹:Tapable、Compiler 和 compilation。

tapable

tapable 包匯出了很多鉤子類,用於建立 plugins 的鉤子。

SyncHook

首選執行一個例項來感受一下。請看示例:

新建測試檔案(src/tapable.test.js):

const {SyncHook} = require('tapable')

class Car {
	constructor() {
		this.hooks = {
			accelerate: new SyncHook(["newSpeed"]),
            // 引數可選
			brake: new SyncHook(),
		}
	}
}
const myCar = new Car()
// 通過 tap 給鉤子新增外掛
myCar.hooks.accelerate.tap("LoggerPlugin", 
    newSpeed => console.log(`Accelerating to ${newSpeed}`)
);

// 繼續給鉤子新增外掛
myCar.hooks.accelerate.tap("LoggerPlugin2", 
    newSpeed => console.log(`Accelerating2 to ${newSpeed}`)
);

// 通過 call 觸發鉤子
myCar.hooks.accelerate.call('newSpeed 1')

安裝依賴包,並在 node 中執行此檔案:

// 安裝依賴包
> npm i -D tapable@2
> nodemon src/tapable.test.js
// 輸出
Accelerating to newSpeed 1
Accelerating2 to newSpeed 1

這段程式碼,我們使用了 tapable 其中一個鉤子 SyncHook(即同步鉤子)。我們通過 new SyncHook 建立鉤子,通過 tap() 方法給鉤子新增外掛,最後我們通過 call() 方法觸發鉤子。

SyncBailHook

一旦有返回值(例如 return ''),立即將停止執行剩餘函式。請看示例(僅展示變動之處):

// 引入 SyncBailHook
const {SyncHook, SyncBailHook} = require('tapable')

// 改為 SyncBailHook 來建立鉤子
accelerate: new SyncBailHook(["newSpeed"]),

// 給回撥函式新增返回值:`return ''`
myCar.hooks.accelerate.tap("LoggerPlugin", 
    newSpeed => {
        console.log(`Accelerating to ${newSpeed}`)
        return ''
    }
);

// 輸出:Accelerating to newSpeed 1
AsyncParallelHook

AsyncParallelHook 是非同步並行鉤子。請看完整示例:

const {AsyncParallelHook} = require('tapable')

class Car {
	constructor() {
		this.hooks = {
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
		}
	}
}
const myCar = new Car()

// 對於同步鉤子,只能用 tap 來註冊外掛
myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
	setTimeout(() => {
        console.log('11')
        callback()
    }, 4000)
});

myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
	setTimeout(() => {
        console.log('22')
        callback()
    }, 5000)
});

myCar.hooks.calculateRoutes.callAsync(null, null, null, err => {
    if(err) return;
    console.log('callAsync')
})

/*
11
22
callAsync
*/

這段程式碼通過 tapAsync() 註冊了兩個外掛,一個需要 4 秒,一個需要 5 秒。所謂並行,指的是過 4 秒輸出 11,
再過 1 秒輸出 22。

把第二個 tapAsync() 換成 tapPromise() 的形式,執行後也是相同的結果:

// 非同步的另一種寫法是 tapPromise,則無需callback,需要返回一個 promise
myCar.hooks.calculateRoutes.tapPromise("BingMapsPlugin", (source, target, routesList) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('22')
            resolve()
        }, 5000)
    })
});
AsyncSeriesHook

AsyncSeriesHook 非同步序列鉤子,和 AsyncParallelHook 的差異在序列上。

比如將 AsyncParallelHook 的例子,改為 AsyncSeriesHook,效果則是輸出 11 後,需要再等待 5 秒才輸出 22。

其他鉤子就不在此介紹。

compiler 鉤子

Compiler 模組是 webpack 的主要引擎,它通過 CLI 傳遞的所有選項, 或者 Node API,建立出一個 compilation 例項。 它擴充套件(extend)自 Tapable 類,用來註冊和呼叫外掛。 大多數面向使用者的外掛會首先在 Compiler 上註冊。—— 官網

下面我們就通過幾個鉤子來稍微介紹下 compiler。請看示例:

僅修改 myPlugins/myPlugin.js:

const pluginName = 'myPlugin';

class myPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('run !');
    });

    // thisCompilation,初始化 compilation 時呼叫,在觸發 compilation 事件之前呼叫
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      console.log('thisCompilation  !');
    });

    // emit,輸出 asset 到 output 目錄之前執行
    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      setTimeout(() => {
        console.log('emit !')
        callback()
      }, 1000)
    });

    // afterEmit,輸出 asset 到 output 目錄之後執行
    compiler.hooks.afterEmit.tapAsync(pluginName, (compilation, callback) => {
      setTimeout(() => {
        console.log('afterEmit !')
        callback()
      }, 1000)
    });
  }
}

module.exports = myPlugin;

打包:

> npx webpack
run !
thisCompilation  !
emit !
afterEmit !
asset main.js 96 bytes [compared for emit] [minimized] (name: main)
./src/index.js 161 bytes [built] [code generated]

這段程式碼,我們使用了 compiler 的 4 個鉤子:

  • run,屬於 AsyncSeriesHook。在開始讀取 records 之前呼叫。
  • thisCompilation,屬於 SyncHook。初始化 compilation 時呼叫,在觸發 compilation 事件之前呼叫。
  • emit,屬於 AsyncSeriesHook。輸出 asset 到 output 目錄之前執行。
  • afterEmit,屬於 AsyncSeriesHook。輸出 asset 到 output 目錄之後執行。

由於 emit 和 afterEmit 屬於 AsyncSeriesHook,所以輸出 "emit !",需要等1秒在輸出 "afterEmit !"。

compilation

Compilation 模組會被 Compiler 用來建立新的 compilation 物件(或新的 build 物件)。 compilation 例項能夠訪問所有的模組和它們的依賴(大部分是迴圈依賴)。 它會對應用程式的依賴圖中所有模組, 進行字面上的編譯(literal compilation)。 在編譯階段,模組會被載入(load)、封存(seal)、優化(optimize)、 分塊(chunk)、雜湊(hash)和重新建立(restore)。—— 官網

Compilation 類擴充套件(extend)自 Tapable,並提供了以下生命週期鉤子。 可以按照 compiler 鉤子的相同方式來呼叫 tap。

我們讓打包時給 dist 目錄輸出一個檔案。請看示例:

myPlugins/myPlugin.js:

const pluginName = 'myPlugin';
class myPlugin {
  apply(compiler) {
    // thisCompilation,初始化 compilation 時呼叫,在觸發 compilation 事件之前呼叫
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      // additionalAssets,為 compilation 建立額外 asset
      compilation.hooks.additionalAssets.tapAsync(pluginName,  (callback) => {
        const cnt = 'hello world'
        // 給 assets 增加 a.txt 檔案
        compilation.assets['a.txt'] = {
            size(){
                return cnt.length
            },
            source(){
                return cnt
            }
        }
        callback()
      });
    });
  }
}

module.exports = myPlugin;

打包:

> npx webpack
asset main.js 96 bytes [emitted] [minimized] (name: main)
asset a.txt 11 bytes [emitted]
./src/index.js 161 bytes [built] [code generated]

Tip:如果你想看一下 compilation 到底有什麼方法,可以使用 debugger 模式啟動,具體做法如下:

// 在 package.json 中增加如下 debugger 命令
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "debugger": "nodemon --inspect-brk ./node_modules/webpack/bin/webpack.js"
},

// 在 myPlugin.js 中輸入 debugger 打斷點,例如:
const cnt = 'hello world'
debugger
...

// 打包
> npm run debugger

> loader-webpack@1.0.0 debugger
> nodemon --inspect-brk ./node_modules/webpack/bin/webpack.js

[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node --inspect-brk ./node_modules/webpack/bin/webpack.js`
Debugger listening on ws://127.0.0.1:9229/ddafe5b2-b184-4fed-b636-77ab74ce63f1
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.

開啟瀏覽器,進入開發者模式,會看見一個六邊形的node小圖示,點選進入,後面的操作和瀏覽器中 debugger 相同。

上面我們生成檔案的寫法不好,換一種方式。請看示例:

const pluginName = 'myPlugin';
const webpack = require('webpack');
// webpack5以前,webpack-sources是一個庫
const {RawSource} = webpack.sources;
const {promisify} = require('util')
let {readFile} = require('fs')

readFile = promisify(readFile)

class myPlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.additionalAssets.tapAsync(pluginName,  (callback) => {
        readFile('./src/index.js').then( data => {
          // emitAsset function (file, source, assetInfo = {})
          compilation.emitAsset('a.txt', new RawSource(data))
          callback()
        }).catch( err => {
          callback(err)
        })
      });
    });
  }
}

module.exports = myPlugin;

這段程式碼主要使用了 RawSource 以及 emitAsset 方法,執行後也會在 dist 目錄中生成 a.txt 檔案。

plugin 實戰

需求,將 myLoaders 資料夾中 js 檔案打包到 dist/myLoaders 中。

思路:

  • 使用 compilation 把檔案輸出到 dist/myLoaders 中
  • 使用 globby 庫讀取指定資料夾中的檔案路徑,並排除所有 json 檔案

myPlugins/myPlugin.js:

const pluginName = 'myPlugin';
const webpack = require('webpack');
const {RawSource} = webpack.sources;
const {promisify} = require('util')
let {readFile} = require('fs')
const globby = require('globby');
const path = require('path');

readFile = promisify(readFile)

class myPlugin {
  constructor(){
    // 取得 options,並驗證
    this.options = {
      to: 'myLoaders',
      // 排除 json 檔案
      ignore: ['**/**.json']
    }
  }
  apply(compiler) {
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.additionalAssets.tapAsync(pluginName, async (callback) => {
        const {ignore, to} = this.options;
        const context = compiler.options.context;
        
        try{
          // 取得 myLoaders 目錄中的檔案路徑,並自動排除 json 檔案
          let paths = await globby('./myLoaders', {ignore});
          // 取得檔案。包含檔名和檔案資料
          const filePromises = paths.map(async v => {
            const absolutePath = path.resolve(context, v);
            const data = await readFile(absolutePath)
            return {
              data,
              name: path.basename(absolutePath)
            }
          })
          const files = await Promise.all(filePromises)
          // 將檔案依次寫入 asset 中
          files.forEach(({name, data}) => {
            const fileName = path.join(to, name)
            compilation.emitAsset(fileName, new RawSource(data))
          })
          callback()
        }catch(e){
          callback(e)
        }
      });
    });
  }
}

module.exports = myPlugin;

打包:

> npm i -D globby@11

> npx webpack
asset myLoaders\babel-loader.js 434 bytes [emitted] [minimized]
asset myLoaders\loader1.js 331 bytes [emitted] [minimized]
asset myLoaders\loader2.js 285 bytes [emitted] [minimized]
asset main.js 96 bytes [emitted] [minimized] (name: main)
./src/index.js 157 bytes [built] [code generated]

Tip:引數校驗部分可自行完成。

其他章節請看:

webpack 快速入門 系列

相關文章