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