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

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

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

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

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

本篇文章會教你們從淺到深的實現一個個webpack插件,案例雖然都不是什麼特別難的插件,可是一旦你掌握瞭如何寫一個插件的方法以後,剩下的就只是在上面作增量了。呆呆仍是那句話:"授人予魚不如授人予漁"前端

OK👌,讓咱們來看看經過閱讀本篇文章你能夠學習到:java

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

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

webpack系列介紹

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

建議先mark再花時間來看。git

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

全部文章webpack版本號^4.41.5, webpack-cli版本號^3.3.10web

(本章節教材案例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題彙總(含超詳細答案) | 掘金技術徵文》

相關文章
相關標籤/搜索