霖呆呆的六個自定義Webpack外掛詳解-自定義plugin篇(3)

語言: CN / TW / HK

霖呆呆的webpack之路-自定義plugin篇

你盼世界,我盼望你無bug。Hello 大家好!我是霖呆呆!

有很多小夥伴在打算學寫一個webpack外掛的時候,就被官網上那一長條一長條的API給嚇到了,亦或者翻閱了幾篇文章之後但還是不知道從何下手。

而呆呆認為,當你瞭解了整個外掛的建立方式以及執行機制之後,那些個長條的API就只是你後期用來開發的"工具庫"而已,我需要什麼,我就去文件上找,大可不必覺得它有多難 😊。

本篇文章會教大家從淺到深的實現一個個webpack外掛,案例雖然都不是什麼特別難的外掛,但是一旦你掌握瞭如何寫一個外掛的方法之後,剩下的就只是在上面做增量了。呆呆還是那句話:"授人予魚不如授人予漁"

OK👌,讓我們來看看通過閱讀本篇文章你可以學習到:

  • No1-webpack-plugin案例
  • Tapable
  • compiler?compile?compilation?
  • No2-webpack-plugin案例
  • fileList.md案例
  • Watch-plugin案例
  • Decide-html-plugin案例
  • Clean-plugin案例

所有文章內容都已整理至 LinDaiDai/niubility-coding-js 快來給我Star呀😊~

webpack系列介紹

此係列記錄了我在webpack上的學習歷程。如果你也和我一樣想要好好的掌握webpack,那麼我認為它對你是有一定幫助的,因為教材中是以一名webpack小白的身份進行講解, 案例demo也都很詳細, 涉及到:

建議先mark再花時間來看。

(其實這個系列在很早之前就寫了,一直沒有發出來,當時還寫了一大長串前言可把我感動的,想看廢話的可以點這裡:GitHub地址,不過現在讓我們正式開始學習吧)

所有文章webpack版本號^4.41.5, webpack-cli版本號^3.3.10

(本章節教材案例GitHub地址: LinDaiDai/webpack-example/tree/webpack-custom-plugin ⚠️:請仔細檢視README說明)

前期準備

從使用的角度來看外掛

好了,我已經準備好閱讀呆呆的這篇文章然後寫一個炒雞牛x的外掛了,趕緊的。

額,等等,在這之前我們不是得知道需要怎麼去做嗎?我們總是聽到的外掛外掛的,它到底是個啥啊?

物件?函式?類?

小夥伴們不妨結合我們已經用過的一些外掛來猜猜,比如HtmlWebpackPlugin,我們會這樣使用它:

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      title: 'custom-plugin'
    })
  ]
}
複製程式碼

可以看到,這很明顯的就是個建構函式,或者是一個類嘛。我們使用new就可以例項化一個外掛的物件。並且,這個函式或者類是可以讓我們傳遞引數進去的。

那你腦子裡是不是已經腦補出一個輪廓了呢?

function CustomPlugin (options) {}

// or
class CustomPlugin {
  constructor (options) {}
}
複製程式碼

從構建的角度來看外掛

知道了plugin大概的輪廓,讓我們從構建的角度來看看它。外掛不同於loader一個很大的區別就是,loader它是一個轉換器,它只專注於轉換這一個領域,例如babel-loader能將ES6+的程式碼轉換為ES5或以下,以此來保證相容性,那麼它是執行在打包之前的。

plugin呢?你會發現市場上有各種讓人眼花繚亂的外掛,它可能執行在打包之前,也可能執行在打包的過程中,或者打包完成之後。總之,它不侷限於打包,資源的載入,還有其它的功能。所以它是在整個編譯週期都起作用。

那麼如果讓我們站在一個編寫外掛者的角度上來看的話,是不是在編寫的時候需要明確兩件事情:

  • 我要如何拿到完整的webpack環境配置呢?因為我在編寫外掛的時候肯定是要與webpack的主環境結合起來的
  • 我如何告訴webpack我的外掛是在什麼時候發揮作用呢?在打包之前?還是之後?也就是我們經常聽到的鉤子。

所以這時候我們就得清楚這幾個硬知識點:

(看不懂?問題不大,呆呆也是從官網cv過來的,不過後面會詳細講到它們哦)

  • compiler 物件代表了完整的 webpack 環境配置。這個物件在啟動 webpack 時被一次性建立,並配置好所有可操作的設定,包括 options,loader 和 plugin。當在 webpack 環境中應用一個外掛時,外掛將收到此 compiler 物件的引用。可以使用它來訪問 webpack 的主環境。

  • compilation 物件代表了一次資源版本構建。當執行 webpack 開發環境中介軟體時,每當檢測到一個檔案變化,就會建立一個新的 compilation,從而生成一組新的編譯資源。一個 compilation 物件表現了當前的模組資源、編譯生成資源、變化的檔案、以及被跟蹤依賴的狀態資訊。compilation 物件也提供了很多關鍵時機的回撥,以供外掛做自定義處理時選擇使用。

  • 鉤子的本質其實就是事件

案例準備

老規矩,為了能更好的讓我們掌握本章的內容,我們需要本地建立一個案例來進行講解。

建立專案的這個過程我就快速的用指令來實現一下哈:

mkdir webpack-custom-plugin && cd webpack-custom-plugin
npm init -y
cnpm i webpack webpack-cli clean-webpack-plugin html-webpack-plugin --save-dev
touch webpack.config.js
mkdir src && cd src
touch index.js
複製程式碼

(mkdir:建立一個資料夾;touch:建立一個檔案)

OK👌,此時專案目錄變成了:

 webpack-custom-plugin
    |- package.json
    |- webpack.config.js
    |- /src
      |- index.js
複製程式碼

接著讓我們給src/index.js隨便加點東西意思一下,省得太空了:

src/index.js

function createElement () {
  const element = document.createElement('div')
  element.innerHTML = '孔子曰:中午不睡,下午崩潰!孟子曰:孔子說的對!';

  return element
}
document.body.appendChild(createElement())
複製程式碼

webpack.config.js也簡單的來配置一下吧,這些應該都是基礎了,之前有詳細說過了喲:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'custom-plugin'
    }),
    new CleanWebpackPlugin()
  ]
}
複製程式碼

(clean-webpack-plugin外掛會在我們每次打包之前自動清理掉舊的dist資料夾,對這些內容還不熟悉的小夥伴得再看看這篇文章了:跟著"呆妹"來學webpack(基礎篇))

另外還需要在package.json中配置一條打包指令哈:

{
  "script": {
    "build": "webpack --mode development"
  }
}
複製程式碼

這裡的"webpack"實際上是"webpack --config webpack.config.js"的縮寫,這點在基礎篇中也有說到咯。

--mode development就是指定一下環境為開發環境,因為我們後續可能有需要看到打包之後的程式碼內容,如果指定了為production的話,那麼webpack它會自動開啟UglifyJS的也就是會對我們打包成功之後的程式碼進行壓縮輸出,那一坨一坨的程式碼我們就不利於我們查看了。

No1-webpack-plugin案例

好的了,基本工作已經準備完畢了,讓我們動手來編寫我們的第一個外掛吧。

這個外掛案例主要是為了幫助你瞭解外掛大概的建立流程。

傳統形式的compiler.plugin

從易到難,讓我們來實現這麼一個簡單的功能:

  • 當我們在完成打包之後,控制檯會輸出一個"good boy!"

在剛剛的案例目錄中新建一個plugins資料夾,然後在裡面建立上我們的第一個外掛: No1-webpack-plugin

 webpack-custom-plugin
  |- package.json
  |- webpack.config.js
  |- /src
    |- index.js
+ |- /plugins
+   |-No1-webpack-plugin.js
複製程式碼

現在依照前面所說的外掛的結構,以及我們的需求,可以寫出以下程式碼:

plugins/No1-webpack-plugin.js:

// 1. 建立一個建構函式
function No1WebpackPlugin (options) {
  this.options = options
}
// 2. 重寫建構函式原型物件上的 apply 方法
No1WebpackPlugin.prototype.apply = function (compiler) {
  compiler.plugin('done', () => {
    console.log(this.options.msg)
  })
}
// 3. 將我們的自定義外掛匯出
module.exports = No1WebpackPlugin;
複製程式碼

接著,讓我們來看看如何使用它,也就是:

webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+ const No1WebpackPlugin = require('./plugins/No1-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'custom-plugin'
    }),
    new CleanWebpackPlugin(),
+   new No1WebpackPlugin({ msg: 'good boy!' })
  ]
}
複製程式碼

OK👌,程式碼已經編寫完啦,快npm run build一下看看效果吧:

可以看到,控制檯已經在誇你"good boy!"了😄。

那麼讓我們回到剛剛的那段自定義外掛的程式碼中:

plugins/No1-webpack-plugin.js:

// 1. 建立一個建構函式
function No1WebpackPlugin (options) {
  this.options = options
}
// 2. 在建構函式原型物件上定義一個 apply 方法
No1WebpackPlugin.prototype.apply = function (compiler) {
  compiler.plugin('done', () => {
    console.log(this.options.msg)
  })
}
// 3. 將我們的自定義外掛匯出
module.exports = No1WebpackPlugin;
複製程式碼

注意到這裡,我們一共是做了這麼三件事情,也就是我在程式碼中的註釋。

很顯然,為了能拿到webpack.config.js中我們傳遞的那個引數,也就是{ msg: 'good boy!' },我們需要在建構函式中定義一個例項物件上的屬性options

並且在prototype.apply中呢:

  • 我們需要呼叫compiler.plugin()並傳入第一個引數來指定我們的外掛是發生在哪個階段,也就是這裡的"done"(一次編譯完成之後,即打包完成之後);
  • 在這個階段我們要做什麼事呢?就可以在它的第二個引數回撥函式中來寫了,請注意這裡我們的回撥函式是一個箭頭函式哦,這也是能夠保證裡面的this獲取到的是我們的例項物件,也就是為了能保證我們拿到options,併成功的打印出msg。(如果對this還不熟悉的小夥伴你該看看呆呆的這篇文章了:【建議👍】再來40道this面試題酸爽繼續(1.2w字用手整理))

所以,現在你的思維是不是已經很清晰了呢?我們想要編寫一個外掛,只需要這麼幾步:

  1. 明確你的外掛是要怎麼呼叫的,需不需要傳遞引數(對應著webpack.config.js中的配置);
  2. 建立一個建構函式,以此來保證用它能建立一個個外掛例項;
  3. 在建構函式原型物件上定義一個 apply 方法,並在其中利用compiler.plugin註冊我們的自定義外掛。

那麼除了用建構函式的方式來建立外掛,是否也可以用類呢?讓我們一起來試試,將剛剛的程式碼改動一下:

plugins/No1-webpack-plugin.js:

// // 1. 建立一個建構函式
// function No1WebpackPlugin (options) {
//   this.options = options
// }
// // 2. 重寫建構函式原型物件上的 apply 方法
// No1WebpackPlugin.prototype.apply = function (compiler) {
//   compiler.plugin('done', () => {
//     console.log(this.options.msg)
//   })
// }
class No1WebpackPlugin {
  constructor (options) {
    this.options = options
  }
  apply (compiler) {
    compiler.plugin('done', () => {
      console.log(this.options.msg)
    })
  }
}
// 3. 將我們的自定義外掛匯出
module.exports = No1WebpackPlugin;
複製程式碼

這時候你執行打包指令效果也是一樣的哈。這其實也很好理解,class它不就是咱們建構函式的一個語法糖嗎,所以它肯定也可以用來實現一個外掛啦。

不過不知道小夥伴們注意到了,在我們剛剛輸出"good boy!"的上面,還有一段小小的警告:

它告訴我們Tabable.plugin這種的呼叫形式已經被廢棄了,請使用新的API,也就是.hooks來替代.plugin這種形式。

如果你和呆呆一樣,開始看的官方文件是 《編寫一個外掛》這裡的話,那麼現在請讓我們換個方向了戳這裡了: 《Plugin API》

但並不是說上面的文件就不能看了,我們依然還是可以通過閱讀它來了解更多外掛相關的知識。

推薦使用compiler.hooks

既然官方都推薦我們用compiler.hooks了,那我們就遵循唄。不過如果你直接去看Plugin API的話對新手來說好像又有點繞,裡面的Tapablecompilercompilecompilation它們直接到底是存在怎樣的關係呢?

沒關係,呆呆都會依次的進行講解。

現在讓我們將No1-webpack-plugin使用compiler.hooks改造一下吧:

plugins/No1-webpack-plugin.js:

// 第一版
// function No1WebpackPlugin (options) {
//   this.options = options
// }
// No1WebpackPlugin.prototype.apply = function (compiler) {
//   compiler.plugin('done', () => {
//     console.log(this.options.msg)
//   })
// }
// 第二版
// class No1WebpackPlugin {
//   constructor (options) {
//     this.options = options
//   }
//   apply (compiler) {
      // compiler.plugin('done', () => {
      //   console.log(this.options.msg)
      // })
//   }
// }
// 第三版
function No1WebpackPlugin (options) {
  this.options = options
}
No1WebpackPlugin.prototype.apply = function (compiler) {
  compiler.hooks.done.tap('No1', () => {
    console.log(this.options.msg)
  })
}
module.exports = No1WebpackPlugin;
複製程式碼

可以看到,第三版中,關鍵點就是在於:

compiler.hooks.done.tap('No1', () => {
  console.log(this.options.msg)
})
複製程式碼

它替換了我們之前的:

compiler.plugin('done', () => {
  console.log(this.options.msg)
})
複製程式碼

讓我們來拆分一下compiler.hooks.done.tap('No1', () => {})

  • compiler:一個擴充套件至Tapable的物件
  • compiler.hookscompiler物件上的一個屬性,允許我們使用不同的鉤子函式
  • .donehooks中常用的一種鉤子,表示在一次編譯完成後執行,它有一個回撥引數stats(暫時沒用上)
  • .tap:表示可以註冊同步的鉤子和非同步的鉤子,而在此處因為done屬於非同步AsyncSeriesHook型別的鉤子,所以這裡表示的是註冊done非同步鉤子。
  • .tap('No1')tap()的第一個引數'No1',其實tap()這個方法它的第一個引數是可以允許接收一個字串或者一個Tap類的物件的,不過在此處我們不深究,你先隨便傳一個字串就行了,我把它理解為這次呼叫鉤子的方法名。

所以讓我們連起來理解這段程式碼的意思就是:

  1. 在程式執行new No1WebpackPlugin()的時候,會初始化一個外掛例項且呼叫其原型物件上的apply方法
  2. 該方法會告訴webpack當你在一次編譯完成之後,得執行一下我的箭頭函式裡的內容,也就是打印出msg

現在我們雖然會寫一個簡單的外掛了,但是對於上面的一些物件、屬性啥的好像還不是很懂耶。想要一口氣吃完一頭大象🐘是有點難的哦(而且那樣也是犯法的),所以接下來讓我們來大概瞭解一下這些Tapablecompiler等等的東西是做什麼的😊。

Tapable

首先是Tapable這個東西,我看了一下網上有很多對它的描述:

  1. tapable 這個小型 library 是 webpack 的一個核心工具
  2. Webpack 的 Tapable 事件流機制保證了外掛的有序性,使得整個系統擴充套件性良好
  3. Tapable 為 webpack 提供了統一的外掛介面(鉤子)型別定義,它是 webpack 的核心功能庫、

當然這些說法肯定都是對的哈,所以總結一下:

  • 簡單來說Tapable就是webpack用來建立鉤子的庫,為webpack提供了外掛介面的支柱。

其實如果你去看了它Git上的文件的話,它就是暴露了9個Hooks類,以及3種方法(tap、tapAsync、tapPromise),可用於為外掛建立鉤子。

9種Hooks類與3種方法之間的關係:

  • Hooks類表示的是你的鉤子是哪一種型別的,比如我們上面用到的done,它就屬於AsyncSeriesHook這個類
  • tap、tapAsync、tapPromise這三個方法是用於注入不同型別的自定義構建行為,因為我們的鉤子可能有同步的鉤子,也可能有非同步的鉤子,而我們在注入鉤子的時候就得選對這三種方法了。

對於Hooks類你大可不必全都記下,一般來說你只需要知道我們要用的每種鉤子它們實際上是有型別區分的,而區分它們的就是Hooks類。

如果你想要清楚它們之前的區別的話,呆呆這裡也有找到一個解釋的比較清楚的總結:

Sync*

  • SyncHook --> 同步序列鉤子,不關心返回值
  • SyncBailHook --> 同步序列鉤子,如果返回值不為null 則跳過之後的函式
  • SyncLoopHook --> 同步迴圈,如果返回值為true 則繼續執行,返回值為false則跳出迴圈
  • SyncWaterfallHook --> 同步序列,上一個函式返回值會傳給下一個監聽函式

Async*

  • AsyncParallel*:非同步併發
    • AsyncParallelBailHook --> 非同步併發,只要監聽函式的返回值不為 null,就會忽略後面的監聽函式執行,直接跳躍到callAsync等觸發函式繫結的回撥函式,然後執行這個被繫結的回撥函式
    • AsyncParallelHook --> 非同步併發,不關心返回值
  • AsyncSeries*:非同步序列
    • AsyncSeriesHook --> 非同步序列,不關心callback()的引數
    • AsyncSeriesBailHook --> 非同步序列,callback()的引數不為null,就會忽略後續的函式,直接執行callAsync函式繫結的回撥函式
    • AsyncSeriesWaterfallHook --> 非同步序列,上一個函式的callback(err, data)的第二個引數會傳給下一個監聽函式

(總結來源:XiaoLu-寫一個簡單webpack plugin所引發的思考)

而對於這三種方法,我們必須得知道它們分別是做什麼用的:

  • tap:可以註冊同步鉤子也可以註冊非同步鉤子
  • tapAsync:回撥方式註冊非同步鉤子
  • tapPromisePromise方式註冊非同步鉤子

OK👌,聽了霖呆呆這段解釋之後,我相信你起碼能看得懂官方文件-compiler 鉤子這裡面的鉤子是怎樣用的了:

就比如,我現在想要註冊一個compile的鉤子,根據官方文件,我發現它是SyncHook型別的鉤子,那麼我們就只能使用tap來註冊它。如果你試圖用tapAsync的話,打包的話你就會發現控制檯已經報錯了,比如這樣:

(額,不過我在使用compiler.hooks.done.tapAsync()的時候,查閱文件上它也是SyncHook類,但是卻可以用tapAsync方法註冊,這邊呆呆也有點沒搞明白是為什麼,有知道的小夥伴還希望可以評論區留言呀😄)

compiler?compile?compilation?

接下來就得說一說外掛中幾個重要的東西了,也就是這一小節的標題裡的這三個東西。

首先讓我們在官方的文件上找尋一下它們的足跡:

可以看到,這幾個屬性都長的好像啊,而且更過分的是,compilation竟然還有兩個同名的,你這是給👴整真假美猴王呢?

那麼呆呆這邊就對這幾個屬性做一下說明。

首先對於文件左側選單上的compiler鉤子和compilation鉤子(也就是第一個和第四個)我們在之後稱它們為CompilerCompilation好了,也是為了和compile做區分,其實我認為你可以把"compiler鉤子"理解為"compiler的鉤子",這樣會更好一些。

  • Compiler:是一個物件,該物件代表了完整的webpack環境配置。整個webpack在構建的時候,會先初始化引數也就是從配置檔案(webpack.config.js)和Shell語句("build": "webpack --mode development")中去讀取與合併引數,之後開始編譯,也就是將最終得到的引數初始化這個Compiler物件,然後再會載入所有配置的外掛,執行該物件的run()方法開始執行編譯。因此我們可以理解為它是webpack的支柱引擎。
  • Compilation:也是一個物件,不過它表示的是某一個模組的資源、編譯生成的資源、變化的檔案等等,因為我們知道我們在使用webpack進行構建的時候可能是會生成很多不同的模組的,而它的顆粒度就是在每一個模組上。

所以你現在可以看到它倆的區別了,一個是代表了整個構建的過程,一個是代表構建過程中的某個模組。

還有很重要的一點,它們兩都是擴充套件至我們上面👆提到的Tapable類,這也就是為什麼它兩都能有這麼多生命週期鉤子的原因。

再來看看兩個小寫的compile和compilation,這兩個其實就是Compiler物件下的兩個鉤子了,也就是我們可以通過這樣的方式來呼叫它們:

No1WebpackPlugin.prototype.apply = function (compiler) {
  compiler.hooks.compile.tap('No1', () => {
    console.log(this.options.msg)
  })
  compiler.hooks.compilation.tap('No1', () => {
    console.log(this.options.msg)
  })
}
複製程式碼

區別在於:

  • compile:一個新的編譯(compilation)建立之後,鉤入(hook into) compiler。
  • compilation:編譯(compilation)建立之後,執行外掛。

(為什麼感覺還是沒太讀懂它們的意思呢?別急,呆呆會在下個例子中來進行說明的)

No2-webpack-plugin案例

這個外掛案例主要是為了幫你理解Compiler、Compilation、compile、compilation四者之間的關係。

compile和compilation

還是在上面👆那個專案中,讓我們在plugins資料夾下再新增一個外掛,叫做No2-webpack-plugin

plugins/No2-webpack-plugin.js:

function No2WebpackPlugin (options) {
  this.options = options
}
No2WebpackPlugin.prototype.apply = function (compiler) {
  compiler.hooks.compile.tap('No2', () => {
    console.log('compile')
  })
  compiler.hooks.compilation.tap('No2', () => {
    console.log('compilation')
  })
}
module.exports = No2WebpackPlugin;
複製程式碼

在這個外掛中,我分別呼叫了compilecompilation兩個鉤子函式,等會讓我們看看會發生什麼事情。

同時,把webpack.config.js中的No1外掛替換成No2外掛:

webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
// const No1WebpackPlugin = require('./plugins/No1-webpack-plugin');
const No2WebpackPlugin = require('./plugins/No2-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'custom-plugin'
    }),
    new CleanWebpackPlugin(),
    // new No1WebpackPlugin({ msg: 'good boy!' })
    new No2WebpackPlugin({ msg: 'bad boy!' })
  ]
}
複製程式碼

現在專案的目錄結構是這樣的:

 webpack-custom-plugin
  |- package.json
  |- webpack.config.js
  |- /src
    |- index.js
  |- /plugins
    |-No1-webpack-plugin.js
+   |-No2-webpack-plugin.js
複製程式碼

OK👌,來執行npm run build看看:

哈哈哈😄,是不是給了你點什麼啟發呢?

我們最終生成的dist資料夾下會有兩個檔案,那麼compilation這個鉤子就被呼叫了兩次,而compile鉤子就只被呼叫了一次。

有小夥伴可能就要問了,我們這裡的src下明明就只有一個index.js檔案啊,為什麼最終的dist下會有兩個檔案呢?

  • main.bundle.js
  • index.html

別忘了,在這個專案中我們可是使用了html-webpack-plugin這個外掛的,它會幫我自動建立一個html檔案。

為了驗證這個compilation是跟著檔案的數量走的,我們暫時先把new HtmlWebpackPlugin給去掉看看:

const path = require('path');
// const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
// const No1WebpackPlugin = require('./plugins/No1-webpack-plugin');
const No2WebpackPlugin = require('./plugins/No2-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    // new HtmlWebpackPlugin({
    //   title: 'custom-plugin'
    // }),
    new CleanWebpackPlugin(),
    // new No1WebpackPlugin({ msg: 'good boy!' })
    new No2WebpackPlugin({ msg: 'bad boy!' })
  ]
}
複製程式碼

試試效果?

這時候,compilation就只執行一次了,而且dist中也沒有再生成html檔案了。

(當然,我這裡只是為了演示哈,在確定完了之後,我又把html-webpack-plugin給啟用了)

Compiler和Compilation

想必上面兩個鉤子函式的區別大家應該都搞懂了吧,接下來就讓我們看看CompilerCompilation這兩個物件的區別。

通過檢視官方文件,我們發現,剛剛用到的compiler.hooks.compilation這個鉤子,是能夠接收一個引數的:

貌似這個形參的名字就是叫做compilation,它和Compilation物件是不是有什麼聯絡呢?或者說,它就是一個Compilation?。

OK👌,我就假設它是吧,接下來我去查看了一下compilation鉤子,哇,這鉤子的數量是有點多哈,隨便挑個順眼的來玩玩?額,翻到最下面,有個chunkAsset,要不就它吧:

可以看到這個鉤子函式是有兩個引數的:

  • chunk:表示的應該就是當前的模組吧
  • filename:模組的名稱

接著讓我們來改寫一下No2-webpack-plugin外掛:

src/No2-webpack-plugin.js:

function No2WebpackPlugin (options) {
  this.options = options
}
No2WebpackPlugin.prototype.apply = function (compiler) {
  compiler.hooks.compile.tap('No2', (compilation) => {
    console.log('compile')
  })
  compiler.hooks.compilation.tap('No2', (compilation) => {
    console.log('compilation')
+   compilation.hooks.chunkAsset.tap('No2', (chunk, filename) => {
+     console.log(chunk)
+     console.log(filename)
+   })
  })
}
module.exports = No2WebpackPlugin;
複製程式碼

我們做了這麼幾件事:

  • Compilercompilation鉤子函式中,獲取到Compilation物件
  • 之後對每一個Compilation物件呼叫它的chunkAsset鉤子
  • 根據文件我們發現chunkAsset鉤子是一個SyncHook型別的鉤子,所以只能用tap去呼叫

如果和我們猜測的一樣,每個Compilation物件都對應著一個輸出資源的話,那麼當我們執行npm run build之後,控制檯肯定會打印出兩個chunk以及兩個filename

一個是index.html,一個是main.bundle.js

OK👌,來瞅瞅。

現在看看你的控制檯是不是打印出了一大長串呢?呆呆這裡簡寫一下輸出結果:

'compile'
'compilation'
'compilation'
Chunk {
  id: 'HtmlWebpackPlugin_0',
  ...
}
'__child-HtmlWebpackPlugin_0'
Chunk {
  id: 'main',
  ...
}
'main.bundle.js'
複製程式碼

可以看到,確實是有兩個Chunk物件,還有兩個檔名稱。

只不過index.html不是按照我們預期的輸出為"index.html",而是輸出為了__child-HtmlWebpackPlugin_0,這點呆呆猜測是html-webpack-plugin外掛本身做了一些處理吧。

Compiler和Compilation物件的內容

如果大家把這兩個物件列印在控制檯上的話會發現有一大長串,呆呆這邊找到了一份比較全面的物件屬性的清單,大家可以看一下:

(圖片與總結來源:編寫一個自己的webpack外掛plugin)

Compiler 物件包含了 Webpack 環境所有的的配置資訊,包含 optionshookloadersplugins 這些資訊,這個物件在 Webpack 啟動時候被例項化,它是全域性唯一的,可以簡單地把它理解為 Webpack 例項;Compiler中包含的東西如下所示:

Compilation 物件包含了當前的模組資源、編譯生成資源、變化的檔案等。當 Webpack 以開發模式執行時,每當檢測到一個檔案變化,一次新的 Compilation 將被建立。Compilation 物件也提供了很多事件回撥供外掛做擴充套件。通過 Compilation 也能讀取到 Compiler 物件。

好了,看到這裡我相信你已經掌握了一個webpack外掛的基本開發方式了。這個東西咋說呢,只有自己去多試試,多玩玩上手才能快,下面呆呆也會為大家演示一些稍微複雜一些的外掛的開發案例。可以跟著一起來玩玩呀。

fileList.md案例

唔...看了網上挺多這個fileList.md案例的,要不咱也給整一個?

明確需求

它的功能點其實很簡單:

  • 在每次webpack打包之後,自動產生一個打包檔案清單,實際上就是一個markdown檔案,上面記錄了打包之後的資料夾dist裡所有的檔案的一些資訊。

大家在接收到這個需求的時候,可以先想想要如何去實現:

  • 首先要確定我們的外掛是不是需要傳遞引數進去
  • 確定我們的外掛是要在那個鉤子函式中執行
  • 我們如何建立一個markdown檔案並塞到dist
  • markdown檔案內的內容是長什麼樣的

針對第一點,我認為我們可以傳遞一個最終生成的檔名進去,例如這樣呼叫:

module.exports = {
  new FileListPlugin({
    filename: 'fileList.md'
  })
}
複製程式碼

第二點,因為是在打包完成之前,所以我們可以去compiler 鉤子來查查有沒有什麼可以用的。

咦~這個叫做emit的好像挺符合的:

  • 型別: AsyncSeriesHook
  • 觸發的事件:生成資源到 output 目錄之前。
  • 引數:compilation

第三點的話,難道要弄個nodefs?再建立個檔案之類的?唔...不用搞的那麼複雜,等會讓我們看個簡單點的方式。

第四點,我們就簡單點,例如寫入這樣的內容就可以了:

# 一共有2個檔案

- main.bundle.js
- index.html

複製程式碼

程式碼分析

由於功能也並不算很複雜,呆呆這裡就直接上程式碼了,然後再來一步一步解析。

還是基於剛剛的案例,讓我們繼續在plugins資料夾下建立一個新的外掛:

plugins/File-list-plugin.js:

function FileListPlugin (options) {
  this.options = options || {};
  this.filename = this.options.filename || 'fileList.md'
}

FileListPlugin.prototype.apply = function (compiler) {
  // 1.
  compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, cb) => {
    // 2.
    const fileListName = this.filename;
    // 3.
    let len = Object.keys(compilation.assets).length;
    // 4.
    let content = `# 一共有${len}個檔案\n\n`;
    // 5.
    for (let filename in compilation.assets) {
      content += `- ${filename}\n`
    }
    // 6.
    compilation.assets[fileListName] = {
      // 7.
      source: function () {
        return content;
      },
      // 8.
      size: function () {
        return content.length;
      }
    }
    // 9.
    cb();
  })
}
module.exports = FileListPlugin;
複製程式碼

程式碼分析:

  1. 通過compiler.hooks.emit.tapAsync()來觸發生成資源到output目錄之前的鉤子,且回撥函式會有兩個引數,一個是compilation,一個是cb回撥函式
  2. 要生成的markdown檔案的名稱
  3. 通過compilation.assets獲取到所有待生成的檔案,這裡是獲取它的長度
  4. 定義markdown檔案的內容,也就是先定義一個一級標題,\n表示的是換行符
  5. 將每一項檔案的名稱寫入markdown檔案內
  6. 給我們即將生成的dist資料夾裡新增一個新的資源,資源的名稱就是fileListName變數
  7. 寫入資源的內容
  8. 指定新資源的大小,用於webpack展示
  9. 由於我們使用的是tapAsync非同步呼叫,所以必須執行一個回撥函式cb,否則打包後就只會建立一個空的dist資料夾。

好滴,大功告成,讓我們趕緊來試試這個新外掛吧,修改webpack.config.js的配置:

webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
// const No1WebpackPlugin = require('./plugins/No1-webpack-plugin');
// const No2WebpackPlugin = require('./plugins/No2-webpack-plugin');
const FileListPlugin = require('./plugins/File-list-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'custom-plugin'
    }),
    new CleanWebpackPlugin(),
    // new No1WebpackPlugin({ msg: 'good boy!' })
    // new No2WebpackPlugin({ msg: 'bad boy!' })
    new  FileListPlugin()
  ]
}
複製程式碼

來執行一下npm run build看看吧:

使用tapPromise重寫

可以看到,上面👆的案例我們是使用tapAsync來呼叫鉤子函式,這個tapPromise好像還沒有玩過,唔...我們看看它是怎樣用的。

現在讓我們來改下需求,剛剛我們好像看不太出來是非同步執行的。現在我們改為1s後才輸出資源。

重寫一下剛剛的外掛:

plugins/File-list-plugin.js:

function FileListPlugin (options) {
  this.options = options || {};
  this.filename = this.options.filename || 'fileList.md'
}

FileListPlugin.prototype.apply = function (compiler) {
  // 第二種 Promise
  compiler.hooks.emit.tapPromise('FileListPlugin', compilation => {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve()
      }, 1000)
    }).then(() => {
      const fileListName = this.filename;
      let len = Object.keys(compilation.assets).length;
      let content = `# 一共有${len}個檔案\n\n`;
      for (let filename in compilation.assets) {
        content += `- ${filename}\n`;
      }
      compilation.assets[fileListName] = {
        source: function () {
          return content;
        },
        size: function () {
          return content.length;
        }
      }
    })
  })
}
module.exports = FileListPlugin;
複製程式碼

可以看到它與第一種tapAsync寫法的區別了:

  • 回撥函式中只需要一個引數compilation,不需要再呼叫一下cb()
  • 返回的是一個Promise,這個Promise1s後才resolve()

大家可以自己寫寫看看效果,應該是和我們預期的一樣的。

另外,tapPromise還允許我們使用async/await的方式,比如這樣:

function FileListPlugin (options) {
  this.options = options || {};
  this.filename = this.options.filename || 'fileList.md'
}

FileListPlugin.prototype.apply = function (compiler) {
  // 第三種 await/async
  compiler.hooks.emit.tapPromise('FileListPlugin', async (compilation) => {
    await new Promise(resolve => {
      setTimeout(() => {
        resolve()
      }, 1000)
    })
    const fileListName = this.filename;
    let len = Object.keys(compilation.assets).length;
    let content = `# 一共有${len}個檔案\n\n`;
    for (let filename in compilation.assets) {
      content += `- ${filename}\n`;
    }
    compilation.assets[fileListName] = {
      source: function () {
        return content;
      },
      size: function () {
        return content.length;
      }
    }
  })
}
module.exports = FileListPlugin;
複製程式碼

嘻嘻😁,貌似真的也不難。

Watch-plugin案例

明確需求

話不多說,讓我們接著來看一個監聽的案例。需求如下:

  • 當專案在開啟觀察者watch模式的時候,監聽每一次資源的改動
  • 當每次資源變動了,將改動資源的個數以及改動資源的列表輸出到控制檯中
  • 監聽結束之後,在控制檯輸出"本次監聽停止了喲~"

那麼首先為了滿足第一個條件,我們得設計一條watch的指令,以保證使用npm run watch命令之後,會看到編譯過程,但是不會退出命令列,而是實時監控檔案。這也很簡單,加一條指令碼命令就可以了。

呆呆在霖呆呆向你發起了多人學習webpack-構建方式篇(2)中也有說的很詳細了。

package.json:

{
  "script": "webpack --watch --mode development"
}
複製程式碼

然後想一想我們的外掛該如何設計,這時候就要知道我們需要呼叫哪個鉤子函數了。

去官網上看一看,這個watchRun就很符合呀:

  • 型別:AsyncSeriesHook
  • 觸發的事件:監聽模式下,一個新的編譯(compilation)觸發之後,執行一個外掛,但是是在實際編譯開始之前。
  • 引數:compiler

針對第三點,監聽結束之後,watchClose就可以了:

  • 型別:SyncHook
  • 觸發的事件:監聽模式停止。
  • 引數:無

程式碼分析

好的👌,讓我們開幹吧。在此專案的plugins資料夾下再新建一個叫做Watch-plugin的外掛。

先搭一下外掛的架子吧:

plugins/Watch-plugin.js:

function WatcherPlugin (options) {
  this.options = options || {};
}

WatcherPlugin.prototype.apply = function (compiler) {
  compiler.hooks.watchRun.tapAsync('WatcherPlugin', (compiler, cb) => {
    console.log('我可是時刻監聽著的 🚀🚀🚀')
    console.log(compiler)
    cb()
  })
  compiler.hooks.watchClose.tap('WatcherPlugin', () => {
    console.log('本次監聽停止了喲~👋👋👋')
  })
}
module.exports = WatcherPlugin;
複製程式碼

(額,這個火箭🚀呆呆是用Mac自帶的輸入法打出來的,其它輸入法應該也有吧)

通過上面幾個案例的講解,這段程式碼大家應該都沒有什麼疑問了吧。

那麼現在的問題就是如何知道哪些檔案改變了。其實我們在研究一個新東西的時候,如果沒啥思路,不如就在已有的條件上先找一下,比如這裡我們就只知道一個compiler,那麼我們就可以查詢一下它裡面的屬性,看看有什麼是我們能用的嗎。

也就是上面的這張圖:

可以看到,有一個叫做watchFileSystem的屬性應該就是我們想要的監聽檔案的屬性了,打印出來看看?

好滴👌,那就先讓我啟動這個外掛吧,也就是改一下webpack.config.js那裡的配置,由於上面幾個案例都已經演示過了,呆呆這裡就不再累贅,直接跳過講解這一步了。

直接讓我們來npm run watch一下吧,控制檯已經輸出了它,可是由於我們是需要監聽檔案的改變,所以雖然控制檯輸出了watchFileSystem,但是這一次是初始化時列印的,也就是說我們需要改動一下本地的一個資源然後儲存再來看看效果。

例如,我隨便改動一下src/index.js中的內容然後儲存。這時候就觸發了監聽事件了,讓我們來看一下列印的結果:

可以看到watchFileSystem中確實有一個watch屬性,而且裡面有一個fileWatchers的列表,還有一個mtimes物件。這兩個屬性引起了我的注意。貌似mtimes物件就是我們想要的了。

它是一個鍵值對,鍵名為改動的檔案的路徑,值為時間。

那麼我們就可以直接來獲取它了:

function WatcherPlugin (options) {
  this.options = options || {};
}

WatcherPlugin.prototype.apply = function (compiler) {
  compiler.hooks.watchRun.tapAsync('WatcherPlugin', (compiler, cb) => {
    console.log('我可是時刻監聽著的 🚀🚀🚀')
    let mtimes = compiler.watchFileSystem.watcher.mtimes;
    let mtimesKeys = Object.keys(mtimes);
    if (mtimesKeys.length > 0) {
      console.log(`本次一共改動了${mtimesKeys.length}個檔案,目錄為:`)
      console.log(mtimesKeys)
      console.log('------------分割線-------------')
    }
    cb()
  })
  compiler.hooks.watchClose.tap('WatcherPlugin', () => {
    console.log('本次監聽停止了喲~👋👋👋')
  })
}
module.exports = WatcherPlugin;
複製程式碼

好滴,接著:

  • 儲存檔案
  • 重新執行npm run watch
  • 第一次列印看不出效果,接著讓我們改動一下src/index.js,隨便加個註釋
  • 再儲存src/index.js檔案,列印結果如下:

好滴👌,這樣就實現了一個簡單的檔案監聽功能。不過使用mtimes只能獲取到簡單的檔案的路徑和修改時間。如果要獲取更加詳細的資訊可以使用compiler.watchFileSystem.watcher.fileWatchers,但是我試了一下這裡面的陣列是會把node_modules裡的改變也算上的,例如這樣:

所以如果針對於這道題的話,我們可以寫一個正則小小的判斷一下,去除node_modules資料夾裡的改變,程式碼如下:

function WatcherPlugin (options) {
  this.options = options || {};
}

WatcherPlugin.prototype.apply = function (compiler) {
  compiler.hooks.watchRun.tapAsync('WatcherPlugin', (compiler, cb) => {
    console.log('我可是時刻監聽著的 🚀🚀🚀')
    // let mtimes = compiler.watchFileSystem.watcher.mtimes;
    // let mtimesKeys = Object.keys(mtimes);
    // if (mtimesKeys.length > 0) {
    //   console.log(`本次一共改動了${mtimesKeys.length}個檔案,目錄為:`)
    //   console.log(mtimesKeys)
    //   console.log('------------分割線-------------')
    // }
    const fileWatchers = compiler.watchFileSystem.watcher.fileWatchers;
    console.log(fileWatchers)
    let paths = fileWatchers.map(watcher => watcher.path).filter(path => !/(node_modules)/.test(path))
    
    if (paths.length > 0) {
      console.log(`本次一共改動了${paths.length}個檔案,目錄為:`)
      console.log(paths)
      console.log('------------分割線-------------')
    }
    cb()
  })
  compiler.hooks.watchClose.tap('WatcherPlugin', () => {
    console.log('本次監聽停止了喲~👋👋👋')
  })
}
module.exports = WatcherPlugin;
複製程式碼

另外呆呆在讀 《深入淺出Webpack》的時候,裡面也有提到:

預設情況下 Webpack 只會監視入口和其依賴的模組是否發生變化,在有些情況下專案可能需要引入新的檔案,例如引入一個 HTML 檔案。 由於 JavaScript 檔案不會去匯入 HTML 檔案,Webpack 就不會監聽 HTML 檔案的變化,編輯 HTML 檔案時就不會重新觸發新的 Compilation。 為了監聽 HTML 檔案的變化,我們需要把 HTML 檔案加入到依賴列表中,為此可以使用如下程式碼:

compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML 檔案新增到檔案依賴列表,好讓 Webpack 去監聽 HTML 模組檔案,在 HTML 模版檔案發生變化時重新啟動一次編譯
    compilation.fileDependencies.push(filePath);
    callback();
})
複製程式碼

感興趣的小夥伴可以自己去實驗一下,呆呆這裡就不做演示了。

Decide-html-plugin案例

再來看個案例,這個外掛是用來檢測我們有沒有使用html-webpack-plugin外掛的。

還記得我們前面說的Compiler物件中,包含了 Webpack 環境所有的的配置資訊,包含 optionshookloadersplugins 這些資訊。

那麼這樣我就可以通過plugins來判斷是否使用了html-webpack-plugin了。

由於功能不復雜,呆呆這就直接上程式碼了:

function DecideHtmlPlugin () {}

DecideHtmlPlugin.prototype.apply = function (compiler) {
  compiler.hooks.afterPlugins.tap('DecideHtmlPlugin', compiler => {
    const plugins = compiler.options.plugins;
    const hasHtmlPlugin = plugins.some(plugin => {
      return plugin.__proto__.constructor.name === 'HtmlWebpackPlugin'
    })
    if (hasHtmlPlugin) {
      console.log('使用了html-webpack-plugin')
    }
  })
}

module.exports = DecideHtmlPlugin
複製程式碼

有需要注意的點⚠️:

  • afterPlugins:設定完初始外掛之後,執行外掛。
  • plugins拿到的會是一個外掛列表,包括我們的自定義外掛DecideHtmlPlugin也會在裡面
  • some()Array.prototype上的方法,用於判斷某個陣列是否有符合條件的項,只要有一項滿足就返回true,否則返回false

配置一下webpack.config.js,來看看效果是可以的:

Clean-plugin案例

還記得上面👆的專案我們用到的那個clean-webpack-plugin,現在我們自己來實現一個簡易版的clean-webpack-plugin吧,名稱就叫Clean-plugin

明確需求

一樣的,首先還是明確一下我們的需求:

我們需要設計這麼一個外掛,在每次重新編譯之後,都會自動清理掉上一次殘餘的dist資料夾中的內容,不過需要滿足以下需求:

  • 外掛的options中有一個屬性為exclude,為一個數組,用來定義不需要清除的檔案列表
  • 每次打包如果檔案有修改則會生成新的檔案且檔案的指紋也會變(檔名以hash命名)
  • 生成了新的檔案,則需要把以前的檔案給清理掉。

例如我第一次打包之後,生成的dist目錄結構是這樣的:

/dist
  |- main.f89e7ffee29ee9dbf0de.js
  |- main.f97284d8479b13c49723.css
複製程式碼

然後我修改了一下js檔案並重新編譯,新的目錄結構應該是這樣的:

/dist
  |- main.e0c6be8f72d73a68f73a.js
  |- main.f97284d8479b13c49723.css
複製程式碼

可以看到,如果我們是用chunkhash給輸出檔案命名的話,只改變js檔案,則js檔案的檔名會發生變化,而不會影響css檔案。

如果對三種hash命名還不清楚的小夥伴,可以花上十分種看下我的這篇文章:霖呆呆的webpack之路-三種hash的區別,裡面對三種hash的使用場景以及區別都說的很清楚。

此時,我們就需要將舊的js檔案給替換成新的,也就是隻刪除main.f89e7ffee29ee9dbf0de.js檔案。

而如果我們在配置外掛的時候加了exclude屬性的話,則不需要把這個屬性中的檔案給刪除。例如如果我是這樣配置的話:

module.exports = {
  new CleanPlugin({
    exclude: [
      "main.f89e7ffee29ee9dbf0de.js"
    ]
  })
}
複製程式碼

那麼這時候就算你修改了js檔案,結果雖然會生成新的js檔案,但是也不會把舊的給刪除,而是共存:

/dist
  |- main.f89e7ffee29ee9dbf0de.js
  |- main.e0c6be8f72d73a68f73a.js
  |- main.f97284d8479b13c49723.css
複製程式碼

程式碼分析

所以針對於上面這個需求,我們先給自己幾個靈魂拷問:

  1. 此外掛在哪個鉤子函式中執行
  2. 如何獲取舊的dist資料夾中的所有檔案
  3. 如何獲取新生成的所有檔案,以及options.exclude中的檔名稱,併合併為一個無重複項的陣列
  4. 如何將舊的所有檔案和新的所有檔案做一個對比得出需要刪除的檔案列表
  5. 如何刪除被廢棄的檔案

(在這個過程中我們肯定會碰到很多自己不知道的知識點,請不要慌,大家都是有這麼一個不會到會的過程)

問題一

在哪個鉤子函式中執行,我覺得可以在"done"中,因為我們其中的一個目的就是既能拿到舊的資料夾內容,又能拿到新的。而在這個階段,表示已經編譯完成了,所以是可以拿到最新的資源了。

問題二

獲取舊的dist資料夾內的內容。還記得我們的dist資料夾是怎麼來的嗎?它是在我們webpack.config.js這個檔案中配置的output項:

module.exports = {
  output: {
    path: path.resolve(__dirname, 'dist')
  }
}
複製程式碼

所以很輕鬆的我們可以通過compiler.options.output.path就拿到這個舊的輸出路徑了,然後我們需要去讀取這個路徑資料夾下的所有檔案,也就是遍歷dist資料夾。

這邊我們需要用到一個叫recursive-readdir-sync的東西,稍後我們需要安裝它,它的作用就是以遞迴方式同步讀取目錄路徑的內容。(github地址為:github.com/battlejj/re…)

問題三

獲取新生成的所有檔案,也就是所有的資源。這點得看"done"回撥函式中的引數stats了。如果你把這個引數打印出來看的話會發現它包括了webpack中的很多配置,包括options包括assets等等。而這裡我們就是需要獲取打包完之後的所有最新資源也就是assets屬性。

你以為直接stats.assets獲取就完了嗎?如果你試圖這樣去做的話,就會報錯了。在webpack中它鼓勵你用stats.toJson().assets的方式來獲取。這點呆呆也不是很清楚原因,大家可以看一下這裡:

www.codota.com/code/javasc…

然後至於options.exclude中的檔名稱,這個在外掛的建構函式中定義一個options屬性就可以拿到了。

合併無重複項我們可以使用lodash.union方法,lodash它是一個高效能的 JavaScript 實用工具庫,裡面提供了許多的方法來使我們更方便的運算元組、物件、字串等。而這裡的union方法就是能把多個數組合併成一個無重複項的陣列,例如🌰:

_.union([2], [1, 2]);
// => [2, 1]
複製程式碼

至於為什麼要把這兩個陣列組合起來呢?那也是為了保證exclude中定義的檔案在後面比較的過程中不會被刪除。

問題四

將新舊檔案列表做對比,得出最終需要刪除的檔案列表。

唔...其實最難的點應該就是在這裡了。因為這裡並不是簡單的檔名稱字串匹配,它需要涉及到路徑問題。

例如,我們前面說到可以通過compiler.options.output.path拿到檔案的輸出路徑,也就是dist的絕對路徑,我們命名為outputPath,它可能是長這樣的:

/Users/lindaidai/codes/webpack/webpack-example/webpack-custom-plugin/dist
複製程式碼

而後我們會用一個叫recursive-readdir-sync的東西去處理這個絕對路徑,獲取裡面的所有檔案:

recursiveReadSync(outputPath)
複製程式碼

這裡得到的會是各個檔案:

[
  file /Users/lindaidai/codes/webpack/webpack-example/webpack-custom-plugin/dist/main.f89e7ffee29ee9dbf0de.js,
  file /Users/lindaidai/codes/webpack/webpack-example/webpack-custom-plugin/dist/css/main.124248e814cc2eeb1fd4.css
]
複製程式碼

以上得到的列表就是舊的dist資料夾中的所有檔案列表。

而後,我們需要得到新生成的檔案的列表,也就是stats.toJson().assets.map(file => file.name)exclude合併後的那個檔案列表,我們稱為newAssets。但是這裡需要注意的就是newAssets中的是各個新生成的檔案的名稱,也就是這樣:

[
  "main.e0c6be8f72d73a68f73a.js",
  "main.124248e814cc2eeb1fd4.css"
]
複製程式碼

所以我們需要做一些額外的路徑轉換的處理,再來進行比較。

而如果在路徑字首相同的情況下,我們只需要把recursiveReadSync(outputPath)處理之後的結果做一層過濾,排除掉newAssets裡的內容,那麼留下來的就是需要刪除的檔案,也就是unmatchFiles這個陣列。

有點繞?讓我們來寫下虛擬碼:

const unmatchFiles = recursiveReadSync(outputPath).filter(file => {
  // 這裡與 newAssets 做對比
  // 過濾掉存在 newAssets 中的檔案
})

// unmatchFiles 就是為我們需要清理的所有檔案
複製程式碼

在這個匹配的過程中,我們會需要用到一個minimatch的工具庫,它很適合用來做這種檔案路徑的匹配。

github地址可以看這裡:github.com/isaacs/mini…

問題五

在上一步中我們會得到需要刪除的檔案列表,這時候只需要呼叫一下fs模組中的unlinkSync方法就可以刪除了。

例如:

// 刪除未匹配檔案
unmatchFiles.forEach(fs.unlinkSync);
複製程式碼

案例準備

好滴,分析了這麼多,是時候動手來寫一寫了,還是基於之前的那個案例。讓我們先來安裝一下上面提到的一些模組或者工具:

cnpm i --save-dev recursive-readdir-sync minimatch lodash.union
複製程式碼

唔。然後為了能看到之後修改檔案有沒有刪除掉舊的檔案這個效果,我們可以來寫一些css的樣式,然後用MiniCssExtractPlugin這個外掛去提取出css程式碼,這樣打包之後就可以放到一個單獨的css檔案中了。

關於這個外掛,不清楚的小夥伴你就理解它為下面這個場景:

我的src下有一個index.js和一個style.css,如果在index.js中引用了style.css的話:

import './style.css';
複製程式碼

最終的css程式碼是會被打包進js檔案中的,webpack並不會那麼智慧的把它拆成一個單獨的css檔案。

所以這時候就可以用MiniCssExtractPlugin這個外掛來單獨的提前css。(不過這個外掛的主要作用還是為了提取公共的css程式碼哈,在這裡我們只是為了將css提取出來)

更多有關MiniCssExtractPlugin的功能可以看我的這篇介紹:霖呆呆的webpack之路-優化篇

好滴,首先讓我們來安裝它,順便安裝一下另兩個loader

cnpm i --save-dev style-loader css-loader mini-css-extract-plugin
複製程式碼

然後在src目錄下新建一個style.css檔案,並寫點樣式:

src/style.css:

.color_red {
  color: red;
}
.color_blue {
  color: blue;
}
複製程式碼

接著快速來配置一下webpack.config.js:

(這裡面有用到一個CleanPlugin的外掛,它是我們接下來要建立的檔案)

webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanPlugin = require('./plugins/Clean-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: [
    './src/index.js',
    './src/style.css'
  ],
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'custom-plugin'
    }),
    new CleanPlugin(),
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash].css'
    })
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader']
      }
    ]
  }
}
複製程式碼

這裡有一點前面也提到了,就是關於output.filename的命名和MiniCssExtractPlugin中生成css檔案的命名,我們採用contenthash的方式,這樣的話,如果我們只改變了js檔案的話,那麼重新打包之後,就只有js檔案的hash會被重新生成,而css不會。這也是為了之後看到效果。

coding

最後,在plugins資料夾下建立我們的Clean-plugin.js吧:

plugins/Clean-plugin.js:

const recursiveReadSync = require("recursive-readdir-sync");
const minimatch = require("minimatch");
const path = require("path");
const fs = require("fs");
const union = require("lodash.union");
function CleanPlugin (options) {
  this.options = options;
}
// 匹配檔案
function getUnmatchFiles(fromPath, exclude = []) {
  const unmatchFiles = recursiveReadSync(fromPath).filter(file =>
    exclude.every(
      excluded => {
        return !minimatch(path.relative(fromPath, file), path.join(excluded), {
          dot: true
        })
      }
    )
  );
  return unmatchFiles;
}
CleanPlugin.prototype.apply = function (compiler) {
  const outputPath = compiler.options.output.path;
  compiler.hooks.done.tap('CleanPlugin', stats => {
    if (compiler.outputFileSystem.constructor.name !== "NodeOutputFileSystem") {
      return;
    }
    const assets = stats.toJson().assets.map(asset => asset.name);
    // 多數組合並並且去重
    const newAssets = union(this.options.exclude, assets);
    // 獲取未匹配檔案
    const unmatchFiles = getUnmatchFiles(outputPath, newAssets);
    // 刪除未匹配檔案
    unmatchFiles.forEach(fs.unlinkSync);
  })
}

module.exports = CleanPlugin;
複製程式碼

比較難的技術難點在「程式碼分析」中都已經說明了,這裡主要說下:

path.relative()

path.relative() 方法根據當前工作目錄返回 fromto 的相對路徑。 如果 fromto 各自解析到相同的路徑(分別呼叫 path.resolve() 之後),則返回零長度的字串。

path.relative('/data/orandea/test/aaa', '/data/orandea/impl/bbb');
// 返回: '../../impl/bbb'
複製程式碼

試試效果

先來看看我們現在的目錄結構:

首先執行一遍npm run build,生成如下內容:

然後修改一下src/index.js中的內容,例如新增一行程式碼,之後再重新執行npm run build

可以看到,只有改變的index.js被重新刪除替換了,而css檔案沒有。

再來驗證一下options.exclude,在webpack.config.js中新增一個外掛的引數,就用上一次生成的js的名稱吧:

module.exports = {
  plugins: [
    new CleanPlugin({
      exclude: [
        "main.e0c6be8f72d73a68f73a.js"
      ]
    }),
  ]
}
複製程式碼

再去修改一下index.js的內容,例如加兩個註釋,然後執行npm run build,會發現這次舊的js檔案並不會被刪除,而是會在原來的基礎上新增一個新的js檔案。這也證明了我們的exclude屬性是可用的:

參考文章

知識無價,支援原創。

參考文章:

後語

你盼世界,我盼望你無bug。這篇文章就介紹到這裡。

可算是寫完了,希望這6個小小的外掛案例能夠幫助你對webpack的執行機制有一個更深入的瞭解,呆呆也會和你一起,一起加油⛽️。

(本章節教材案例GitHub地址: LinDaiDai/webpack-example/tree/webpack-custom-plugin ⚠️:請仔細檢視README說明)

喜歡霖呆呆的小夥還希望可以關注霖呆呆的公眾號 LinDaiDai 或者掃一掃下面的二維碼👇👇👇.

我會不定時的更新一些前端方面的知識內容以及自己的原創文章🎉

你的鼓勵就是我持續創作的主要動力 😊.

相關推薦:

《全網最詳bpmn.js教材》

《【建議改成】讀完這篇你還不懂Babel我給你寄口罩》

《【建議星星】要就來45道Promise面試題一次爽到底(1.1w字用心整理)》

《【建議👍】再來40道this面試題酸爽繼續(1.2w字用手整理)》

《【何不三連】比繼承家業還要簡單的JS繼承題-封裝篇(牛刀小試)》

《【何不三連】做完這48道題徹底弄懂JS繼承(1.7w字含辛整理-返璞歸真)》

《【精】從206個console.log()完全弄懂資料型別轉換的前世今生(上)》

《霖呆呆的近期面試128題彙總(含超詳細答案) | 掘金技術徵文》