webpack插件編寫及原理解析

作為先進最為流行的前端構(gòu)建工具之一,webpack成為了前端開發(fā)必須掌握的技能秆吵。其諸多的插件為我們的工作帶來了大大的便利屁桑,本文將對webpack plugin的基本原理以及編寫方式做一個介紹。編寫插件需要對webpack的底層特性有一定的了解索抓,本文中也會對這些內(nèi)容做一些基本介紹碳蛋。

后文的介紹和樣例代碼編寫所對應(yīng)的webpack版本號為4.35.0

創(chuàng)建一個最基礎(chǔ)的Plugin

首先我們不來扯別的原理胚泌,先來看看一個最為基本的webpack plugin結(jié)構(gòu)。

// 聲明一個js函數(shù)
function ExamplePlugin(option) {
  this.option = option
}
// 在函數(shù)的原型上聲明一個apply方法
ExamplePlugin.prototype.apply = function(compiler) {}

你也可以采用ES6來進行編寫

// 采用ES6
class ExamplePlugin {
  constructor(option) {
    this.option = option
  }
  apply(compiler) {}
}

以上就是一個最為基本的plugin結(jié)構(gòu)肃弟。webpack plugin最為核心的便是這個apply方法玷室。
webpack執(zhí)行時,先生成了插件的實例對象笤受,之后會調(diào)用插件上的apply方法穷缤,并將compiler對象(webpack實例對象,包含了webpack的各種配置信息...)作為參數(shù)傳遞給apply箩兽。
之后我們便可以在apply方法中使用compiler對象去監(jiān)聽webpack在不同時刻觸發(fā)的各種事件來進行我們想要的操作了津肛。

接下來看一個簡單的示例

class plugin1 {
  constructor(option) {
    this.option = option
    console.log(option.name + '初始化')
  }
  apply(compiler) {
    console.log(this.option.name + ' apply被調(diào)用')

    //在webpack的emit生命周期上添加一個方法
    compiler.hooks.emit.tap('plugin1', (compilation) => {
      console.log('生成資源到 output 目錄之前執(zhí)行的生命周期')
    })
  }
}

class plugin2 {
  constructor(option) {
    this.option = option
    console.log(option.name + '初始化')
  }
  apply(compiler) {
    console.log(this.option.name + ' apply被調(diào)用')

    //在webpack的afterPlugins生命周期上添加一個方法
    compiler.hooks.afterPlugins.tap('plugin2', (compilation) => {
      console.log('webpack設(shè)置完初始插件之后執(zhí)行的生命周期')
    })
  }
}

module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new plugin1({ name: 'plugin1' }),
    new plugin2({ name: 'plugin2' })
  ]
}

//執(zhí)行webpack命令后輸出結(jié)果如下:
/*
plugin1初始化
plugin2初始化
plugin1 apply被調(diào)用
plugin2 apply被調(diào)用
webpack設(shè)置完初始插件之后執(zhí)行的生命周期
生成資源到 output 目錄之前執(zhí)行的生命周期
*/

首先webpack會按順序?qū)嵗痯lugin對象,之后再依次調(diào)用plugin對象上的apply方法汗贫。
也就是對應(yīng)輸出 plugin1初始化身坐、plugin2初始化plugin1 apply被調(diào)用芳绩、plugin2 apply被調(diào)用掀亥。
webpack源代碼中我們也可以看到這么一行撞反,options.plugins便是配置文件中的被實例化的plugin數(shù)組妥色。

插件中的apply被調(diào)用,對應(yīng)源碼目錄lib/webpack.js

之前我們也提到了遏片,webpack在運行過程中會觸發(fā)各種事件嘹害,而在apply方法中我們能接收一個compiler對象撮竿,我們可以通過這個對象監(jiān)聽到webpack觸發(fā)各種事件的時刻,然后執(zhí)行對應(yīng)的操作函數(shù)笔呀。這套機制類似于Node.js的EventEmitter幢踏,總的來說就是一個發(fā)布訂閱模式。

compiler.hooks中定義了各式各樣的事件鉤子许师,這些鉤子會在不同的時機被執(zhí)行房蝉。而上文中的compiler.hooks.emitcompiler.hooks.afterPlugin這兩個生命周期鉤子,分別對應(yīng)了設(shè)置完初始插件以及生成資源到 output 目錄之前這兩個時間節(jié)點微渠,afterPlugin是在emit之前被觸發(fā)的搭幻,所以輸出順序更靠前。

compiler對象上具體的鉤子也可以查看官方文檔 compiler鉤子逞盆。

在繼續(xù)記下來的內(nèi)容之前檀蹋,我們先來對compilercompilation做一個更為詳細的介紹。

compiler和compilation介紹

webpack的compiler模塊是其核心部分云芦。其包含了webpack配置文件傳遞的所有選項俯逾,包含了諸如loader、plugins等信息舅逸。

我們可以看看Compiler類中定義的一些核心方法桌肴。

//繼承自Tapable類,使得自身擁有發(fā)布訂閱的能力
class Compiler extends Tapable {
  //構(gòu)造函數(shù)堡赔,context實際傳入值為process.cwd()识脆,代表當前的工作目錄
  constructor(context) {
    super();
    // 定義了一系列的事件鉤子,分別在不同的時刻觸發(fā)
    this.hooks = {
      shouldEmit: new SyncBailHook(["compilation"]),
      done: new AsyncSeriesHook(["stats"]),
      //....更多鉤子
    };
    this.running = true;
    //其他一些變量聲明
  }

  //調(diào)用該方法之后會監(jiān)聽文件變更善已,一旦變更則重新執(zhí)行編譯
  watch(watchOptions, handler) {
    this.running = true;
    return new Watching(this, watchOptions, handler)
  }
  
  //用于觸發(fā)編譯時所有的工作
  run(callback) {
    //編譯之后的處理灼捂,省略了部分代碼
    const onCompiled = (err, compilation) => {
      this.emitAssets(compilation, err => {...})
    }
  }

  //負責(zé)將編譯輸出的文件寫入本地
  emitAssets(compilation, callback) {}

  //創(chuàng)建一個compilation對象,并將compiler自身作為參數(shù)傳遞
  createCompilation() {
    return new Compilation(this);
  }

  //觸發(fā)編譯换团,在內(nèi)部創(chuàng)建compilation實例并執(zhí)行相應(yīng)操作
  compile() {}


  //以上核心方法中很多會通過this.hooks.someHooks.call來觸發(fā)指定的事件
  
}

可以看到悉稠,compiler中設(shè)置了一系列的事件鉤子和各種配置參數(shù),并定義了webpack諸如啟動編譯艘包、觀測文件變動的猛、將編譯結(jié)果文件寫入本地等一系列核心方法。在plugin執(zhí)行的相應(yīng)工作中我們肯定會需要通過compiler拿到webpack的各種信息想虎。

接下來看看compilation

如果把compiler算作是總控制臺卦尊,那么compilation則專注于編譯處理這件事上。

在啟用Watch模式后舌厨,webpack將會監(jiān)聽文件是否發(fā)生變化岂却,每當檢測到文件發(fā)生變化,將會執(zhí)行一次新的編譯,并同時生成新的編譯資源和新的compilation對象躏哩。
compilation對象中包含了模塊資源署浩、編譯生成資源以及變化的文件和被跟蹤依賴的狀態(tài)信息等等,以供插件工作時使用扫尺。如果我們在插件中需要完成一個自定義的編譯過程筋栋,那么必然會用到這個對象。

tips: 在webpack-dev-server和webpack-dev-middleware里Watch模式默認開啟

插件編寫示例

首先看一個插件示例正驻,這個插件在我們構(gòu)建完相關(guān)的文件后弊攘,會輸出一個記錄所有構(gòu)建文件名的filelist.md文件。

class myPlugin {
  constructor(option) {
    this.option = option
  }
  apply(compiler) {
    compiler.hooks.emit.tap('myPlugin', compilation => {
      let filelist = '構(gòu)建后的文件: \n'
      for (var filename in compilation.assets) {
        filelist += '- ' + filename + '\n';
      }

      compilation.assets['filelist.md'] = {
        source: function() {
          return filelist
        },
        size: function() {
          return filelist.length
        }
      }
    })
  }
}

在webpack的emit事件被觸發(fā)之后姑曙,我們的插件會執(zhí)行指定的工作肴颊,并將包含了編譯生成資源的compilation作為參數(shù)傳入了函數(shù)。我們可以通過compilation.assets拿到生成的文件渣磷,并獲取其中的filename值婿着。

同樣的,我們也可以獲取到構(gòu)建后的文件內(nèi)容醋界。
接下來我們編寫一個插件竟宋,將編譯后的.js.css文件進行g(shù)zip壓縮。

const zlib = require('zlib')

class gzipPlugin {
  constructor(option) {
    this.option = option
  }
  apply(compiler) {
    compiler.hooks.emit.tap('myPlugin', compilation => {

      for (var filename in compilation.assets) {
        if (/(.js|.css)/.test(filename)) {
          const gzipFile = zlib.gzipSync(compilation.assets[filename]._value, {
            //壓縮等級

            level: this.option.level || 7
          })

          compilation.assets[filename + '.gz'] = {
            source: function () {
              return gzipFile 
            },
            size: function () {
              return gzipFile.length
            }
          }
        }
      }
    })
  }
}

//webpack.config.js中調(diào)用
{
  ...
  plugins: [
    new gzipPlugin({
      //設(shè)置壓縮等級
      level: 9
    })
  ]
}

在這個插件中形纺,我們同樣監(jiān)聽compiler的emit事件丘侠,通過compilation.assets[filename]._value拿到文件內(nèi)容,之后通過node自帶的zlib庫便可生成gzip文件了逐样。

壓縮后結(jié)果如下:


js文件壓縮結(jié)果
css文件壓縮結(jié)果

關(guān)于gzip的更多實踐內(nèi)容蜗字,可以去這篇文章查看 gzip壓縮實踐

異步事件鉤子
webpack有些事件鉤子是支持異步的。
具體可以通過tapAsync或者tapPromise來實現(xiàn)脂新,接下來看分別看一個示例挪捕。

class AsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('asyncEmit', (compilation, callback) => {
      console.log('asyncEmit')
      setTimeout(() => {
        //異步完成后調(diào)用callback函數(shù)以繼續(xù)流程
        callback()
      }, 2000)
    })
  }
}

class LogPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('log', (compilation, callback) => {
      console.log('LogPlugin')
    })
    compiler.hooks.done.tap('done', () => {
      console.log('done')
    })
  }
}

//webpack.config.js中調(diào)用
{
  //...
  plugins: [
    new AsyncPlugin(),
    new LogPlugin()
  ]
}

以上代碼輸出順序如下:asyncEmit,2秒后輸出LogPlugin争便,緊跟著輸出done级零。

使用tapPromise也同理,只需稍稍改變一下寫法即可:

class AsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapPromise('asyncEmit', compilation => {
      // 返回一個 Promise滞乙,在我們的異步任務(wù)完成時 resolve……
      return new Promise((resolve, reject) => {
        setTimeout(function() {
          console.log('異步工作完成……')
          resolve()
        }, 1000);
      })
    })
  }
}

結(jié)合Tapable在插件中使用自定義事件

Tapable是一個小型的庫奏纪,類似于Node.js的EventEmitter類,負責(zé)自定義事件的注冊和觸發(fā)斩启。

const {SyncHook} = require('tapable')

class MainPlugin {
    apply(compiler) {
        //在hooks上自定義一個名為mainPlugin的鉤子
        compiler.hooks.mainPlugin = new SyncHook(['data'])

        //在webpack的environment事件觸發(fā)時序调,廣播自定義的mainPlugin事件,并傳參
        compiler.hooks.environment.tap('mainPlugin', (compilation) => {
            compiler.hooks.mainPlugin.call({
                text: 'MainPlugin Call'
            })
        })
    }
}

class ListenPlugin {
    apply(compiler) {
        //監(jiān)聽自定義的mainPlugin被觸發(fā)后兔簇,執(zhí)行對應(yīng)的函數(shù)发绢,輸出data.text
        compiler.hooks.mainPlugin.tap('listenPlugin', (data) => {
            console.log(data.text)
        })
        
    }
}

//在webpack.config.js中引用

{
  // ...
  plugins: [
    new MainPlugin(),
    new ListenPlugin()
  ]
}

可以看到荣挨,借助tapable我們可以在webpack插件中自定義一些事件,用來進行特定的操作朴摊。插件之間也可以通過自定義事件互相調(diào)用部分邏輯恭朗。
webpack自身的compiler句各、complation類也是繼承自tapable來實現(xiàn)自身事件的注冊和觸發(fā)的跷跪。

通過以上的學(xué)習(xí)滩褥,我們接下來對上面的內(nèi)容進行一個小小的總結(jié)伏伐。
1. webpack插件本質(zhì)上是一個函數(shù)糊肠,它的原型上存在一個名為apply函數(shù)皆看。webpack在初始化時 (在最早觸發(fā)的environment事件之前) 會執(zhí)行這個函數(shù)龙誊,并將一個包含了webpack所有配置信息的compiler作為參數(shù)傳遞給apply函數(shù)韭寸。
2. 插件可以通過監(jiān)聽webpack本身觸發(fā)的事件春哨,在不同的時間階段介入進行你想做的操作。
3. 通過獲取到的compiler對象恩伺,我們可以結(jié)合tapable在插件中自定義事件并將其廣播赴背。
4. 在插件中監(jiān)聽一些特定的事件 (thisCompilation到afterEmit這個階段的事件),你可以拿到一個compilation對象晶渠,里面包含了各種編譯資源凰荚,你可以通過操作這個對象對生成的資源進行添加和修改等操作。

通過上面的學(xué)習(xí)褒脯,相信大家插件的編寫和大致原理有了一定的了解和認識便瑟。

webpack執(zhí)行流程

最后我們來對webpack本身的執(zhí)行流程進行一個概述,并將其和compiler事件鉤子的觸發(fā)時機進行一個對照番川。

webpack流程

webpack首先會讀取配置文件到涂,創(chuàng)建compiler對象,之后調(diào)用所有插件中的apply方法颁督,并將參數(shù)傳入其中践啄。
在完成之后會廣播environment這個事件鉤子。然后讀取配置文件的entry屬性沉御,遍歷所有入口js文件往核。

接下來compiler對象會調(diào)用run方法,正式開始啟動各方面的工作嚷节。

webpack開始為創(chuàng)建compilation對象做準備工作聂儒,首先會調(diào)用一個newCompilationParams方法,創(chuàng)建compilation對象所需的參數(shù)硫痰,緊接著立刻廣播beforeCompile和compile這兩個事件衩婚。之后compilation對象被創(chuàng)建,并廣播compilation和make事件效斑。

webpack接下來就開始了編譯相關(guān)的工作非春。調(diào)用loader處理各模塊之間的依賴,對每一個require調(diào)用對應(yīng)的loader進行加工,再將加工后的文件處理生成AST抽象語法樹并遍歷這顆抽象語法樹奇昙,構(gòu)建該模塊所依賴的模塊护侮。最后再將所有模塊中的require語法轉(zhuǎn)換成 __webpack_require__

以上步驟完成之后webpack會觸發(fā)emit事件储耐,你可以在這個事件中通過compilation.assets拿到生成的各種資源羊初。最后,webpack通過compiler的emitAssets方法將文件輸出到對應(yīng)的構(gòu)建目錄中什湘,操作完成长赞。

本文篇幅有限,對webpack流程只是進行了一個簡單的介紹闽撤,但通過對流程的學(xué)習(xí)和了解得哆,你能夠更合理地運用、編寫插件哟旗。

以上是這篇文章的全部內(nèi)容贩据,希望對您有所幫助。

參考文獻

Webpack揭秘——走向高階前端的必經(jīng)之路
細說webpack 之流程篇
webpack官方文檔

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末闸餐,一起剝皮案震驚了整個濱河市乐设,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌绎巨,老刑警劉巖近尚,帶你破解...
    沈念sama閱讀 216,496評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異场勤,居然都是意外死亡戈锻,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評論 3 392
  • 文/潘曉璐 我一進店門和媳,熙熙樓的掌柜王于貴愁眉苦臉地迎上來格遭,“玉大人,你說我怎么就攤上這事留瞳【苎福” “怎么了?”我有些...
    開封第一講書人閱讀 162,632評論 0 353
  • 文/不壞的土叔 我叫張陵她倘,是天一觀的道長璧微。 經(jīng)常有香客問我,道長硬梁,這世上最難降的妖魔是什么前硫? 我笑而不...
    開封第一講書人閱讀 58,180評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮荧止,結(jié)果婚禮上屹电,老公的妹妹穿的比我還像新娘阶剑。我一直安慰自己,他們只是感情好危号,可當我...
    茶點故事閱讀 67,198評論 6 388
  • 文/花漫 我一把揭開白布牧愁。 她就那樣靜靜地躺著,像睡著了一般外莲。 火紅的嫁衣襯著肌膚如雪猪半。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,165評論 1 299
  • 那天苍狰,我揣著相機與錄音,去河邊找鬼烘绽。 笑死淋昭,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的安接。 我是一名探鬼主播翔忽,決...
    沈念sama閱讀 40,052評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼盏檐!你這毒婦竟也來了歇式?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,910評論 0 274
  • 序言:老撾萬榮一對情侶失蹤胡野,失蹤者是張志新(化名)和其女友劉穎材失,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體硫豆,經(jīng)...
    沈念sama閱讀 45,324評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡龙巨,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,542評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了熊响。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片旨别。...
    茶點故事閱讀 39,711評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖汗茄,靈堂內(nèi)的尸體忽然破棺而出秸弛,到底是詐尸還是另有隱情,我是刑警寧澤洪碳,帶...
    沈念sama閱讀 35,424評論 5 343
  • 正文 年R本政府宣布递览,位于F島的核電站,受9級特大地震影響瞳腌,放射性物質(zhì)發(fā)生泄漏非迹。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,017評論 3 326
  • 文/蒙蒙 一纯趋、第九天 我趴在偏房一處隱蔽的房頂上張望憎兽。 院中可真熱鬧冷离,春花似錦、人聲如沸纯命。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽亿汞。三九已至瞭空,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間疗我,已是汗流浹背咆畏。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留吴裤,地道東北人旧找。 一個月前我還...
    沈念sama閱讀 47,722評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像麦牺,于是被迫代替她去往敵國和親钮蛛。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,611評論 2 353