手寫一個webpack插件

本文示例源代碼請戳 github博客,建議你們動手敲敲代碼。

webpack本質上是一種事件流的機制,它的工做流程就是將各個插件串聯起來,而實現這一切的核心就是Tapable,webpack中最核心的負責編譯的Compiler和負責建立bundles的Compilation都是Tapable的實例。Tapable暴露出掛載plugin的方法,使咱們能 將plugin控制在webapack事件流上運行(以下圖)。
圖片描述css

Tabable是什麼?

tapable庫暴露了不少Hook(鉤子)類,爲插件提供掛載的鉤子。webpack

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

圖片描述

Tabable 用法git

1.new Hook 新建鉤子github

  • tapable 暴露出來的都是類方法,new 一個類方法得到咱們須要的鉤子。
  • class 接受數組參數options,非必傳。類方法會根據傳參,接受一樣數量的參數。
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);

2.使用 tap/tapAsync/tapPromise 綁定鉤子
tapable提供了同步&異步綁定鉤子的方法,而且他們都有綁定事件和執行事件對應的方法。web

- Async* Sync*
綁定 tapAsync/tapPromise/tap tap
執行 callAsync/promise call

3.call/callAsync 執行綁定事件segmentfault

const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);

//綁定事件到webapck事件流
hook1.tap('hook1', (arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) //1,2,3

//執行綁定的事件
hook1.call(1,2,3)

舉個例子api

  • 定義一個Car方法,在內部hooks上新建鉤子。分別是同步鉤子 accelerateaccelerate接受一個參數)、break、異步鉤子calculateRoutes
  • 使用鉤子對應的綁定和執行方法
  • calculateRoutes使用tapPromise能夠返回一個promise對象。
//引入tapable
const { SyncHook, AsyncParallelHook } = require('tapable');

//建立類
class Car {
    constructor() {
        this.hooks = {
            accelerate: new SyncHook(["newSpeed"]),
            break: new SyncHook(),
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
        };
    }
}

const myCar = new Car();

//綁定同步鉤子
myCar.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin'));

//綁定同步鉤子 並傳參
myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));

//綁定一個異步Promise鉤子
myCar.hooks.calculateRoutes.tapPromise("calculateRoutes tapPromise", (source, target, routesList, callback) => {
    // return a promise
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
            console.log(`tapPromise to ${source} ${target} ${routesList}`)
            resolve();
        },1000)
    })
});

//執行同步鉤子
myCar.hooks.break.call();
myCar.hooks.accelerate.call('hello');

console.time('cost');

//執行異步鉤子
myCar.hooks.calculateRoutes.promise('i', 'love', 'tapable').then(() => {
    console.timeEnd('cost');
}, err => {
    console.error(err);
    console.timeEnd('cost');
})

運行結果數組

WarningLampPlugin
Accelerating to hello
tapPromise to i love tapable
cost: 1008.725ms

calculateRoutes也可使用tapAsync綁定鉤子,注意:此時用callback結束異步回調。promise

myCar.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => {
    // return a promise
    setTimeout(() => {
        console.log(`tapAsync to ${source} ${target} ${routesList}`)
        callback();
    }, 2000)
});

myCar.hooks.calculateRoutes.callAsync('i', 'like', 'tapable', err => {
    console.timeEnd('cost');
    if(err) console.log(err)
})

運行結果服務器

WarningLampPlugin
Accelerating to hello
tapAsync to i like tapable
cost: 2007.045ms

進階一下~
到這裏可能已經學會使用tapable了,可是它如何與webapck/webpack插件關聯呢?
咱們將剛纔的代碼稍做改動,拆成兩個文件:Compiler.jsMyplugin.js

Compiler.js

  • Class Car類名改爲webpack的核心Compiler
  • 接受options裏傳入的plugins
  • Compiler做爲參數傳給plugin
  • 執行run函數,在編譯的每一個階段,都觸發執行相對應的鉤子函數。
const {
    SyncHook,
    AsyncParallelHook
} = require('tapable');

class Compiler {
    constructor(options) {
        this.hooks = {
            accelerate: new SyncHook(["newSpeed"]),
            break: new SyncHook(),
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
        };
        let plugins = options.plugins;
        if (plugins && plugins.length > 0) {
            plugins.forEach(plugin => plugin.apply(this));
        }
    }
    run(){
        console.time('cost');
        this.accelerate('hello')
        this.break()
        this.calculateRoutes('i', 'like', 'tapable')
    }
    accelerate(param){
        this.hooks.accelerate.call(param);
    }
    break(){
        this.hooks.break.call();
    }
    calculateRoutes(){
        const args = Array.from(arguments)
        this.hooks.calculateRoutes.callAsync(...args, err => {
            console.timeEnd('cost');
            if (err) console.log(err)
        });
    }
}

module.exports = Compiler

MyPlugin.js

  • 引入Compiler
  • 定義一個本身的插件。
  • apply方法接受 compiler參數。
  • compiler上的鉤子綁定方法。
  • 仿照webpack規則,向 plugins 屬性傳入 new 實例。
webpack 插件是一個具備 apply 方法的 JavaScript 對象。 apply 屬性會被 webpack compiler 調用,而且 compiler 對象可在整個編譯生命週期訪問。
const Compiler = require('./Compiler')

class MyPlugin{
    constructor() {

    }
    apply(conpiler){//接受 compiler參數
        conpiler.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin'));
        conpiler.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));
        conpiler.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => {
            setTimeout(() => {
                console.log(`tapAsync to ${source}${target}${routesList}`)
                callback();
            }, 2000)
        });
    }
}


//這裏相似於webpack.config.js的plugins配置
//向 plugins 屬性傳入 new 實例

const myPlugin = new MyPlugin();

const options = {
    plugins: [myPlugin]
}
let compiler = new Compiler(options)
compiler.run()

運行結果

Accelerating to hello
WarningLampPlugin
tapAsync to iliketapable
cost: 2009.273ms

改造後運行正常,仿照Compiler和webpack插件的思路慢慢得理順插件的邏輯成功。
更多其餘Tabable方法

Plugin基礎

Webpack 經過 Plugin 機制讓其更加靈活,以適應各類應用場景。 在 Webpack 運行的生命週期中會廣播出許多事件,Plugin 能夠監聽這些事件,在合適的時機經過 Webpack 提供的 API 改變輸出結果。

一個最基礎的 Plugin 的代碼是這樣的:

class BasicPlugin{
  // 在構造函數中獲取用戶給該插件傳入的配置
  constructor(options){
  }

  // Webpack 會調用 BasicPlugin 實例的 apply 方法給插件實例傳入 compiler 對象
  apply(compiler){
    compiler.hooks.compilation.tap('BasicPlugin', compilation => {
     
    });
  }
}

// 導出 Plugin
module.exports = BasicPlugin;

在使用這個 Plugin 時,相關配置代碼以下:

const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[
    new BasicPlugin(options),
  ]
}

Compiler 和 Compilation
在開發 Plugin 時最經常使用的兩個對象就是 Compiler Compilation,它們是 Plugin Webpack 之間的橋樑。 CompilerCompilation 的含義以下:

  • Compiler 對象包含了 Webpack 環境全部的的配置信息,包含 options,loaders,plugins 這些信息,這個對象在 Webpack 啓動時候被實例化,它是全局惟一的,能夠簡單地把它理解爲 Webpack 實例;
  • Compilation 對象包含了當前的模塊資源、編譯生成資源、變化的文件等。當 Webpack 以開發模式運行時,每當檢測到一個文件變化,一次新的 Compilation 將被建立。Compilation 對象也提供了不少事件回調供插件作擴展。經過 Compilation 也能讀取到 Compiler 對象。

Compiler 和 Compilation 的區別在於:Compiler 表明了整個 Webpack 從啓動到關閉的生命週期,而 Compilation 只是表明了一次新的編譯。

經常使用 API

插件能夠用來修改輸出文件、增長輸出文件、甚至能夠提高 Webpack 性能、等等,總之插件經過調用 Webpack 提供的 API 能完成不少事情。 因爲 Webpack 提供的 API 很是多,有不少 API 不多用的上,又加上篇幅有限,下面來介紹一些經常使用的 API。

一、讀取輸出資源、代碼塊、模塊及其依賴

有些插件可能須要讀取 Webpack 的處理結果,例如輸出資源、代碼塊、模塊及其依賴,以便作下一步處理。

在 emit 事件發生時,表明源文件的轉換和組裝已經完成,在這裏能夠讀取到最終將輸出的資源、代碼塊、模塊及其依賴,而且能夠修改輸出資源的內容。 插件代碼以下:

class MyPlugin {
  apply(compiler) {

    compiler.hooks.emit.tabAsync('MyPlugin', (compilation, callback) => {
      // compilation.chunks 存放全部代碼塊,是一個數組
      compilation.chunks.forEach(function (chunk) {
        // chunk 表明一個代碼塊
        // 代碼塊由多個模塊組成,經過 chunk.forEachModule 能讀取組成代碼塊的每一個模塊
        chunk.forEachModule(function (module) {
          // module 表明一個模塊
          // module.fileDependencies 存放當前模塊的全部依賴的文件路徑,是一個數組
          module.fileDependencies.forEach(function (filepath) {
          });
        });

        // Webpack 會根據 Chunk 去生成輸出的文件資源,每一個 Chunk 都對應一個及其以上的輸出文件
        // 例如在 Chunk 中包含了 CSS 模塊而且使用了 ExtractTextPlugin 時,
        // 該 Chunk 就會生成 .js 和 .css 兩個文件
        chunk.files.forEach(function (filename) {
          // compilation.assets 存放當前全部即將輸出的資源
          // 調用一個輸出資源的 source() 方法能獲取到輸出資源的內容
          let source = compilation.assets[filename].source();
        });
      });

      // 這是一個異步事件,要記得調用 callback 通知 Webpack 本次事件監聽處理結束。
      // 若是忘記了調用 callback,Webpack 將一直卡在這裏而不會日後執行。
      callback();
    })

  }
}

二、監聽文件變化

Webpack 會從配置的入口模塊出發,依次找出全部的依賴模塊,當入口模塊或者其依賴的模塊發生變化時, 就會觸發一次新的 Compilation。

在開發插件時常常須要知道是哪一個文件發生變化致使了新的 Compilation,爲此可使用以下代碼:

// 當依賴的文件發生變化時會觸發 watch-run 事件
compiler.hooks.watchRun.tap('MyPlugin', (watching, callback) => {
  // 獲取發生變化的文件列表
  const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
  // changedFiles 格式爲鍵值對,鍵爲發生變化的文件路徑。
  if (changedFiles[filePath] !== undefined) {
    // filePath 對應的文件發生了變化
  }
  callback();
});

默認狀況下 Webpack 只會監視入口和其依賴的模塊是否發生變化,在有些狀況下項目可能須要引入新的文件,例如引入一個 HTML 文件。 因爲 JavaScript 文件不會去導入 HTML 文件,Webpack 就不會監聽 HTML 文件的變化,編輯 HTML 文件時就不會從新觸發新的 Compilation。 爲了監聽 HTML 文件的變化,咱們須要把 HTML 文件加入到依賴列表中,爲此可使用以下代碼:

compiler.hooks.afterCompile.tap('MyPlugin', (compilation, callback) => {
  // 把 HTML 文件添加到文件依賴列表,好讓 Webpack 去監聽 HTML 模塊文件,在 HTML 模版文件發生變化時從新啓動一次編譯
  compilation.fileDependencies.push(filePath);
  callback();
});

三、修改輸出資源
有些場景下插件須要修改、增長、刪除輸出的資源,要作到這點須要監聽 emit 事件,由於發生 emit 事件時全部模塊的轉換和代碼塊對應的文件已經生成好, 須要輸出的資源即將輸出,所以 emit 事件是修改 Webpack 輸出資源的最後時機。

全部須要輸出的資源會存放在 compilation.assets 中,compilation.assets 是一個鍵值對,鍵爲須要輸出的文件名稱,值爲文件對應的內容。

設置 compilation.assets 的代碼以下:

// 設置名稱爲 fileName 的輸出資源
  compilation.assets[fileName] = {
    // 返回文件內容
    source: () => {
      // fileContent 既能夠是表明文本文件的字符串,也能夠是表明二進制文件的 Buffer
      return fileContent;
      },
    // 返回文件大小
      size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();

讀取 compilation.assets 的代碼以下:

// 讀取名稱爲 fileName 的輸出資源
  const asset = compilation.assets[fileName];
  // 獲取輸出資源的內容
  asset.source();
  // 獲取輸出資源的文件大小
  asset.size();
  callback();

實戰!寫一個插件

怎麼寫一個插件?參照webpack官方教程Writing a Plugin。 一個webpack plugin由一下幾個步驟組成:

  • 一個JavaScript類函數。
  • 在函數原型 (prototype)中定義一個注入compiler對象的apply方法。
  • apply函數中經過compiler插入指定的事件鉤子,在鉤子回調中拿到compilation對象
  • 使用compilation操縱修改webapack內部實例數據。
  • 異步插件,數據處理完後使用callback回調

下面咱們舉一個實際的例子,帶你一步步去實現一個插件。
該插件的名稱取名叫 EndWebpackPlugin,做用是在 Webpack 即將退出時再附加一些額外的操做,例如在 Webpack 成功編譯和輸出了文件後執行發佈操做把輸出的文件上傳到服務器。 同時該插件還能區分 Webpack 構建是否執行成功。使用該插件時方法以下:

module.exports = {
  plugins:[
    // 在初始化 EndWebpackPlugin 時傳入了兩個參數,分別是在成功時的回調函數和失敗時的回調函數;
    new EndWebpackPlugin(() => {
      // Webpack 構建成功,而且文件輸出了後會執行到這裏,在這裏能夠作發佈文件操做
    }, (err) => {
      // Webpack 構建失敗,err 是致使錯誤的緣由
      console.error(err);        
    })
  ]
}

要實現該插件,須要藉助兩個事件:

  • done:在成功構建而且輸出了文件後,Webpack 即將退出時發生;
  • failed:在構建出現異常致使構建失敗,Webpack 即將退出時發生;

實現該插件很是簡單,完整代碼以下:

class EndWebpackPlugin {

  constructor(doneCallback, failCallback) {
    // 存下在構造函數中傳入的回調函數
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }

  apply(compiler) {
    compiler.hooks.done.tab('EndWebpackPlugin', (stats) => {
      // 在 done 事件中回調 doneCallback
      this.doneCallback(stats);
    });
    compiler.hooks.failed.tab('EndWebpackPlugin', (err) => {
      // 在 failed 事件中回調 failCallback
      this.failCallback(err);
    });
  }
}
// 導出插件
module.exports = EndWebpackPlugin;

從開發這個插件能夠看出,找到合適的事件點去完成功能在開發插件時顯得尤其重要。 在 工做原理歸納 中詳細介紹過 Webpack 在運行過程當中廣播出經常使用事件,你能夠從中找到你須要的事件。

參考
tapable
compiler-hooks
Compilation Hooks
writing-a-plugin
深刻淺出 Webpack
乾貨!擼一個webpack插件

相關文章
相關標籤/搜索