webpack 快速入門 系列 —— 自定義 webpack 上


其他章節請看:

webpack 快速入門 系列

自定義 webpack 上

通過“初步認識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 快速入門 系列


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM