1. 程式人生 > >如何編寫一個WebPack的外掛原理及實踐

如何編寫一個WebPack的外掛原理及實踐

閱讀目錄

  • 一:webpack外掛的基本原理
  • 二:理解 Compiler物件 和 Compilation 物件
  • 三:外掛中常用的API
  • 四:編寫外掛實戰
回到頂部

一:webpack外掛的基本原理

webpack構建工具大家應該不陌生了,那麼下面我們來簡單的瞭解下什麼是webpack的外掛。比如我現在寫了一個外掛叫 "kongzhi-plugin" 這個外掛。那麼這個外掛在處理webpack編譯過程中會處理一些特定的任務。

比如我們現在在webpack.config.js 中引入了一個如下外掛:

// 引入打包html檔案
const HtmlWebpackPlugin = require('html-webpack-plugin');

然後我們需要如下使用該外掛:

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html' // 模版檔案
    }),
  ]
};

如上就是一個 HtmlWebpackPlugin 外掛 及在webpack中使用的方式了。現在我們需要實現一個類似的webpack的外掛。

webpack打包是一種事件流的機制,它的原理是將各個外掛串聯起來。那麼實現這一切的核心就是tapable,要想深入瞭解 tapable的知識可以看我之前的一篇文章.

tapable它可以暴露出掛載plugin的方法。可以讓我們能將plugin控制在webpack事件流上執行。
tapable給我們暴露了很多鉤子類,能為我們的外掛提供掛載的鉤子。
如下程式碼所示:

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

如上各個鉤子的含義及使用方式,可以看我之前這篇文章的介紹。

下面我們來看個簡單的demo,我們會定義一個 KongZhiClass 類,在內部我們建立一個 hooks 這個物件,然後在該物件上分別建立同步鉤子kzSyncHook及非同步鉤子 kzAsyncHook。 然後分別執行,程式碼如下:

const { SyncHook, AsyncParallelHook } = require('tapable');

// 建立類 

class KongZhiClass {
  constructor() {
    this.hooks = {
      kzSyncHook: new SyncHook(['name', 'age']),
      kzAsyncHook: new AsyncParallelHook(['name', 'age'])
    }
  }
}

// 例項化
const myName = new KongZhiClass();

// 繫結同步鉤子
myName.hooks.kzSyncHook.tap("eventName1", (name, age) => {
  console.log(`同步事件eventName1: ${name} this year ${age} 週歲了, 可是還是單身`);
});

// 繫結一個非同步Promise鉤子
myName.hooks.kzAsyncHook.tapPromise('eventName2', (name, age) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`非同步事件eventName2: ${name} this year ${age}週歲了,可是還是單身`);
    }, 1000);
  });
});

// 執行同步鉤子
myName.hooks.kzSyncHook.call('空智', 31);

// 執行非同步鉤子
myName.hooks.kzAsyncHook.promise('空智', 31).then(() => {
  console.log('非同步事件執行完畢');
}, (err) => {
  console.log('非同步事件執行異常:' + err);
}) 

執行結果如下:

如上是我們使用的 tapable 的使用方式,現在我們需要使用tapable的demo來和我們的webpack的外掛相關聯起來,我們要如何做呢?

我們可以將上面的程式碼來拆分成兩個檔案:compiler.js、main.js. (main.js 是入口檔案)

假如我們的專案結構如下:

|--- tapable專案
| |--- node_modules  
| |--- public
| | |--- js
| | | |--- main.js
| | | |--- compiler.js
| |--- package.json
| |--- webpack.config.js

compiler.js 需要做的事情如下:

1. 定義一個 Compiler 類,接收一個options物件引數,該引數是從main.js中的MyPlugin類的實列物件。該物件下有 apply函式。

2. 在該類中我們定義了run方法,我們在main.js 中執行該run函式就可以自動執行對應的外掛了。

程式碼如下:

const { SyncHook, AsyncParallelHook } = require('tapable');

class Compiler {
  constructor(options) {
    this.hooks = {
      kzSyncHook: new SyncHook(['name', 'age']),
      kzAsyncHook: new AsyncParallelHook(['name', 'age'])
    };
    let plugins = options.plugins;
    if (plugins && plugins.length > 0) {
      plugins.forEach(plugin => plugin.apply(this));
    }
  }
  run() {
    console.log('開始執行了---------');
    this.kzSyncHook('我是空智', 31);
    this.kzAsyncHook('我是空智', 31);
  }
  kzSyncHook(name, age) {
    this.hooks.kzSyncHook.call(name, age);
  }
  kzAsyncHook(name, age) {
    this.hooks.kzAsyncHook.callAsync(name, age);
  }
}

module.exports = Compiler;

main.js 需要做的事情如下:

1. 引入 compiler.js 檔案。
2. 定義一個自己的外掛,比如叫 MyPlugin 類,該類下有 apply 函式。該函式有一個 compiler 引數,該引數就是我們的 compiler.js 中的實列物件。然後我們會使用 compiler 實列物件去呼叫 compiler.js 裡面的函式。因此就可以自動執行了。

程式碼如下所示:

const Compiler = require('./compiler');

class MyPlugin {
  constructor() {
    
  }
  apply(compiler) {
    compiler.hooks.kzSyncHook.tap("eventName1", (name, age) => {
      console.log(`同步事件eventName1: ${name} this year ${age} 週歲了, 可是還是單身`);
    });
    compiler.hooks.kzAsyncHook.tapAsync('eventName2', (name, age) => {
      setTimeout(() => {
        console.log(`非同步事件eventName2: ${name} this year ${age}週歲了,可是還是單身`);
      }, 1000)
    });
  }
}

const myPlugin = new MyPlugin();

const options = {
  plugins: [myPlugin]
};

const compiler = new Compiler(options);
compiler.run();

最後執行的效果如下所示:

如上就是我們仿照Compiler和webpack的外掛原理邏輯實現的一個簡單demo。也就是說在webpack原始碼裡面也是通過類似的方式來做的。

上面只是一個簡單實現的基本原理,但是在我們的webpack當中我們要如何實現一個外掛呢?
在我們的webpack官網中會介紹編寫一個外掛要滿足如下條件, 官網地址

從官網得知:編寫一個webpack外掛需要由以下組成:

1. 一個javascript命名函式。
2. 在外掛函式的prototype上定義一個 apply 方法。
3. 指定一個繫結到webpack自身的鉤子函式。
4. 處理webpack內部實列的特定資料。
5. 功能完成後呼叫webpack提供的回撥函式。

一個最基礎的外掛程式碼像如下這個樣子:

// 一個javascript命名函式
function MyExampleWebpackPlugin() {
  
};
// 在外掛函式的prototype上定義一個 apply 方法
MyExampleWebpackPlugin.prototype.apply = function(compiler) {
  // 指定一個掛載到webpack自身的事件鉤子。
  compiler.plugin('webpacksEventHook', function(compilation, callback) {
    console.log('這是一個外掛demo');

    // 功能完成後呼叫 webpack 提供的回撥
    callback();
  })
}

// 匯出plugin
module.exports = MyExampleWebpackPlugin;

在我們使用該plugin的時候,相關呼叫及配置程式碼如下:

const MyExampleWebpackPlugin = require('./MyExampleWebpackPlugin');
module.exports = {
  plugins: [
    new MyExampleWebpackPlugin(options)
  ]
};

webpack啟動後,在讀取配置的過程中會先執行 new MyExampleWebpackPlugin(options) 初始化MyExampleWebpackPlugin來獲得一個實列。然後我們會把該實列當做引數傳遞給我們的Compiler物件,然後會實列化 Compiler類(這個邏輯可以結合看我們上面實現了一個簡單的demo中 的main.js和compiler.js的程式碼結合起來理解)。在Compiler類中,我們會獲取到options的這個引數,該引數是一個物件,該物件下有一個 plugins 這個屬性。然後遍歷該屬性,然後依次執行 某項外掛中的apply方法,即:myExampleWebpackPlugin.apply(compiler); 給外掛傳遞compiler物件。外掛實列獲取該compiler物件後,就可以通過 compiler.plugin('事件名稱', '回撥函式'); 監聽到webpack廣播出來的事件.(這個地方我們可以看我們上面的main.js中的如下程式碼可以看到, 在我們的main.js程式碼中有這樣程式碼:compiler.hooks.kzSyncHook.tap("eventName1", (name, age) => {}));
如上就是一個簡單的Plugin的外掛原理(切記:結合上面的demo中main.js和compiller.js來理解效果會更好)。

回到頂部

二:理解 Compiler物件 和 Compilation 物件

在開發Plugin時我們最常用的兩個物件就是 Compiler 和 Compilation, 他們是Plugin和webpack之間的橋樑。

Compiler物件

Compiler 物件包含了Webpack環境所有的配置資訊,包含options,loaders, plugins這些項,這個物件在webpack啟動時候被例項化,它是全域性唯一的。我們可以把它理解為webpack的實列。

基本原始碼可以看如下:

// webpack/lib/webpack.js
const Compiler = require("./Compiler")

const webpack = (options, callback) => {
  ...
  // 初始化 webpack 各配置引數
  options = new WebpackOptionsDefaulter().process(options);

  // 初始化 compiler 物件,這裡 options.context 為 process.cwd()
  let compiler = new Compiler(options.context);

  compiler.options = options                               // 往 compiler 新增初始化引數

  new NodeEnvironmentPlugin().apply(compiler)              // 往 compiler 新增 Node 環境相關方法

  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  ...
}

原始碼可以點選這裡檢視.  官網可以看這裡。

如上我們可以看到,Compiler物件包含了所有的webpack可配置的內容。開發外掛時,我們可以從 compiler 物件中拿到所有和 webpack 主環境相關的內容。

compilation 物件

compilation 物件包含了當前的模組資源、編譯生成資源、檔案的變化等。當webpack在開發模式下執行時,每當檢測到一個檔案發生改變的時候,那麼一次新的 Compilation將會被建立。從而生成一組新的編譯資源。

Compiler物件 與 Compilation 物件 的區別是:Compiler代表了是整個webpack從啟動到關閉的生命週期。Compilation 物件只代表了一次新的編譯。
Compiler物件的事件鉤子,我們可以看官網. 或者我們也可以檢視它的原始碼也可以看得到,檢視原始碼

我們可以瞭解常見的事件鉤子:下面是一些比較常見的事件鉤子及作用:

鉤子               作用                     引數               型別
after-plugins     設定完一組初始化外掛之後    compiler          sync
after-resolvers   設定完 resolvers 之後     compiler          sync
run               在讀取記錄之前             compiler          async
compile           在建立新 compilation之前  compilationParams  sync
compilation       compilation 建立完成      compilation        sync
emit              在生成資源並輸出到目錄之前  compilation        async
after-emit        在生成資源並輸出到目錄之後  compilation        async
done              完成編譯                  stats              sync

理解webpack中的事件流

我們可以把webpack理解為一條生產線,需要經過一系列處理流程後才能將原始檔轉換成輸出結果。
這條生產線上的每個處理流程的職責都是單一的,多個流程之間會存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。

我們的外掛就像一個插入到生產線中的一個功能,在特定的時機對生產線上的資源會做處理。webpack它是通過 Tapable來組織這條複雜的生產線的。

webpack在執行的過程中會廣播事件,外掛只需要關心監聽它的事件,就能加入到這條生產線中。然後會執行相關的操作。
webpack的事件流機制它能保證了外掛的有序性,使整個系統的擴充套件性好。事件流機制使用了觀察者模式來實現的。比如如下程式碼:

/*
 * 廣播事件
 * myPlugin-name 為事件名稱
 * params 為附帶的引數
*/

compiler.apply('myPlugin-name', params);

/*
 * 監聽名稱為 'myPlugin-name' 的事件,當 myPlugin-name 事件發生時,函式就會執行。
*/

compiler.hooks.myPlugin-name.tap('myPlugin-name', function(params) {
  
});
回到頂部

三:外掛中常用的API

1. 讀取輸出資源、模組及依賴

在我們的emit鉤子事件發生時,表示的含義是:原始檔的轉換和組裝已經完成了,在這裡事件鉤子裡面我們可以讀取到最終將輸出的資源、程式碼塊、模組及對應的依賴檔案。並且我們還可以輸出資原始檔的內容。比如外掛程式碼如下:

class MyPlugin {
  apply(compiler) {
    compiler.plugin('emit', function(compilation, callback) {
      // compilation.chunks 是存放了所有的程式碼塊,是一個數組,我們需要遍歷
      compilation.chunks.forEach(function(chunk) {
        /*
         * chunk 代表一個程式碼塊,程式碼塊它是由多個模組組成的。
         * 我們可以通過 chunk.forEachModule 能讀取組成程式碼塊的每個模組
        */
        chunk.forEachModule(function(module) {
          // module 代表一個模組。
          // module.fileDependencies 存放當前模組的所有依賴的檔案路徑,它是一個數組
          module.fileDependencies.forEach(function(filepath) {
            console.log(filepath);
          });
        });
        /*
         webpack 會根據chunk去生成輸出的檔案資源,每個chunk都對應一個及以上的輸出檔案。
         比如在 Chunk中包含了css 模組並且使用了 ExtractTextPlugin 時,
         那麼該Chunk 就會生成 .js 和 .css 兩個檔案
        */
        chunk.files.forEach(function(filename) {
          // compilation.assets 是存放當前所有即將輸出的資源。
          // 呼叫一個輸出資源的 source() 方法能獲取到輸出資源的內容
          const source = compilation.assets[filename].source();
        });
      });
      /*
       該事件是非同步事件,因此要呼叫 callback 來通知本次的 webpack事件監聽結束。
       如果我們沒有呼叫callback(); 那麼webpack就會一直卡在這裡不會往後執行。
      */
      callback();
    })
  }
}

2. 監聽檔案變化

webpack讀取檔案的時候,它會從入口模組去讀取,然後依次找出所有的依賴模組。當入口模組或依賴的模組發生改變的時候,那麼就會觸發一次新的 Compilation。

在我們開發外掛的時候,我們需要知道是那個檔案發生改變,導致了新的Compilation, 我們可以新增如下程式碼進行監聽。

// 當依賴的檔案發生改變的時候 會觸發 watch-run 事件
class MyPlugin {
  apply(compiler) {
    compiler.plugin('watch-run', (watching, callback) => {
      // 獲取發生變換的檔案列表
      const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
      // changedFiles 格式為鍵值對的形式,當鍵為發生變化的檔案路徑
      if (changedFiles[filePath] !== undefined) {
        // 對應的檔案就發生了變化了
      }
      callback();
    });

    /*
     預設情況下Webpack只會監聽入口檔案或其依賴的模組是否發生變化,但是在有些情況下比如html檔案發生改變的時候,那麼webpack
     就會去監聽html檔案的變化。因此就不會重新觸發新的 Compilation。因此為了監聽html檔案的變化,我們需要把html檔案加入到
     依賴列表中。因此我們需要新增如下程式碼:
    */
    compiler.plugin('after-compile', (compilation, callback) => {
      /*
       如下的引數filePath是html檔案路徑,我們把HTML檔案新增到檔案依賴表中,然後我們的webpack會去監聽html模組檔案,
       html模板檔案發生改變的時候,會重新啟動下重新編譯一個新的 Compilation.
      */
      compilation.fileDependencies.push(filePath);
      callback();
    })
  }
}

3. 修改輸出資源

我們在第一點說過:在我們的emit鉤子事件發生時,表示的含義是:原始檔的轉換和組裝已經完成了,在這裡事件鉤子裡面我們可以讀取到最終將輸出的資源、程式碼塊、模組及對應的依賴檔案。因此如果我們現在要修改輸出資源的內容的話,我們可以在emit事件中去做修改。那麼所有輸出的資源會存放在 compilation.assets中,compilation.assets是一個鍵值對,鍵為需要輸出的檔名,值為檔案對應的內容。如下程式碼:

class MyPlugin {
  apply(compiler) {
    compiler.plugin('emit', (compilation, callback) => {
      // 設定名稱為 fileName 的輸出資源
      compilation.assets[fileName] = {
        // 返回檔案內容
        source: () => {
          // fileContent 即可以代表文字檔案的字串,也可以是代表二進位制檔案的buffer
          return fileContent;
        },
        // 返回檔案大小
        size: () => {
          return Buffer.byteLength(fileContent, 'utf8');
        }
      };
      callback();
    });
    // 讀取 compilation.assets 程式碼如下:
    compiler.plugin('emit', (compilation, callback) => {
      // 讀取名稱為 fileName 的輸出資源
      const asset = compilation.assets[fileName];
      // 獲取輸出資源的內容
      asset.source();
      // 獲取輸出資源的檔案大小
      asset.size();
      callback();
    });
  }
}

4. 判斷webpack使用了哪些外掛

在我們開發一個外掛的時候,我們需要根據當前配置是否使用了其他某個外掛,我們可以通過讀取webpack某個外掛配置的情況,比如來判斷我們當前是否使用了 HtmlWebpackPlugin 外掛。程式碼如下:

/*
 判斷當前配置使用了 HtmlWebpackPlugin 外掛。
 compiler引數即為 webpack 在 apply(compiler) 中傳入的引數
*/

function hasHtmlWebpackPlugin(compiler) {
  // 獲取當前配置下所有的外掛列表
  const plugins = compiler.options.plugins;
  // 去plugins中尋找有沒有 HtmlWebpackPlugin 的實列
  return plugins.find(plugin => plugin.__proto__.constructor === HtmlWebpackPlugin) !== null;
}
回到頂部

四:編寫外掛實戰

 假如現在我們的專案的目錄結構如下:

|--- webpack-plugin-demo
| |--- node_modules
| |--- js
| | |--- main.js               # js 的入口檔案
| |--- plugins
| | |--- logWebpackPlugin.js   # 編寫的webpack的外掛,主要作用是列印日誌功能
| |--- styles
| |--- index.html
| |--- package.json
| |--- webpack.config.js

1. 實現一個列印日誌的LogWebpackPlugin外掛

程式碼如下:

class LogWebpackPlugin {
  constructor(doneCallback, emitCallback) {
    this.emitCallback = emitCallback
    this.doneCallback = doneCallback
  }
  apply(compiler) {
    compiler.hooks.emit.tap('LogWebpackPlugin', () => {
      // 在 emit 事件中回撥 emitCallback
      this.emitCallback();
    });
    compiler.hooks.done.tap('LogWebpackPlugin', (err) => {
      // 在 done 事件中回撥 doneCallback
      this.doneCallback();
    });
    compiler.hooks.compilation.tap('LogWebpackPlugin', () => {
      // compilation('編譯器'對'編譯ing'這個事件的監聽)
      console.log("The compiler is starting a new compilation...")
    });
    compiler.hooks.compile.tap('LogWebpackPlugin', () => {
      // compile('編譯器'對'開始編譯'這個事件的監聽)
      console.log("The compiler is starting to compile...")
    });
  }
}

// 匯出外掛
module.exports = LogWebpackPlugin;

下面我們在webpack中引入該外掛;如下程式碼:

// 引入LogWebpackPlugin 外掛
const LogWebpackPlugin = require('./public/plugins/logWebpackPlugin');

module.exports = {
  plugins: [
    new LogWebpackPlugin(() => {
      // Webpack 模組完成轉換成功
      console.log('emit 事件發生啦,所有模組的轉換和程式碼塊對應的檔案已經生成好~')
    } , () => {
      // Webpack 構建成功,並且檔案輸出了後會執行到這裡,在這裡可以做釋出檔案操作
      console.log('done 事件發生啦,成功構建完成~')
    })
  ]
}

然後執行結果如下所示:

可以看到我們執行成功了,執行了對應的回撥函式。如上程式碼中的 compiler 這個我這邊就不講解了,上面已經講過了。那麼 compiler.hooks 代表的是對外 暴露了多少事件鉤子,具體那個鉤子是什麼含義,我們可以來看下官網

如上面程式碼,我們使用兩個鉤子事件,分別是 compiler.hooks.emit 和 compiler.hooks.done, compiler.hooks.emit 鉤子事件的含義是: 在生成資源並輸出到目錄之前。這個事件就會發生。 compiler.hooks.done 的含義是:編譯完成,該事件就會發生。因此上面截圖我們可以看到先觸發 emit事件,因此會列印 'done 事件發生啦,成功構建完成~', 然後會觸發 done事件,因此會列印 "emit 事件發生啦,所有模組的轉換和程式碼塊對應的檔案已經生成好~" 執行這個回撥函式。
github程式碼檢視

2. 編寫去除生成 bundle.js 中多餘的註釋的外掛

專案結構如下:

|--- webpack-plugin-demo
| |--- node_modules
| |--- public
| | |--- js
| | | |--- main.js                     # 入口檔案             
| | |--- plugins                       # 存放所有的webpack外掛
| | | |--- AsyncPlugin.js 
| | | |--- AutoExternalPlugin.js
| | | |--- DonePlugin.js
| | | |--- FileListPlugin.js
| | | |--- MyPlugin.js
| | | |--- OptimizePlugin.js
| | |--- styles                        # 存放css樣式檔案
| | |--- index.html                    # index.html模板
| |--- package.json        
| |--- webpack.config.js 

專案結構如上所示;上面在 public/plugins 中一共有6個外掛,我們分別來看下6個外掛的程式碼:

1. public/plugins/AsyncPlugin.js 程式碼如下:

class AsyncPlugin {
  constructor() {

  }
  apply(compiler) {
    // 監聽emit事件,編譯完成後,檔案內容輸出到硬碟上 觸發該事件
    compiler.hooks.emit.tapAsync('AsyncPlugin', (compilation, callback) => {
      setTimeout(() => {
        console.log('檔案將要被寫入到硬碟中');
        callback();
      }, 2000)
    })
  }
}

module.exports = AsyncPlugin;

如上該外掛程式碼沒有什麼實際作用,無非就是監聽 emit 非同步事件鉤子,emit事件鉤子我們從官網 

上可以看到具體的含義為:'在生成資源並輸出到目錄之前',會執行該事件鉤子中函式程式碼,這邊無非就是在控制檯中列印一些提示資訊的,沒有什麼實際作用的。
2. public/plugins/DonePlugin.js 程式碼如下:

class DonePlugin {
  constructor() {

  }
  apply(compiler) {
    compiler.hooks.done.tapAsync('DonePlugin', (name, callback) => {
      console.log('全部編譯完成');
      callback();
    })
  }
}

module.exports = DonePlugin;

如上程式碼也是一個意思,當編譯完成後,就會執行 done的事件鉤子的回撥函式,也是在命令中提示作用的。

3. public/plugins/OptimizePlugin.js 程式碼如下:

class OptimizePlugin {
  constructor() {

  }
  apply(compiler) {
    // 監聽 compilation 事件
    compiler.hooks.compilation.tap('OptimizePlugin', (compilation) => {
      compilation.hooks.optimize.tap('OptimizePlugin', () => {
        console.log('compilation 完成,正在優化,準備輸出');
      });
    });
  }
}

module.exports = OptimizePlugin;

也是一樣監聽 compilation 事件的,每當檢測到一個檔案發生改變的時候,那麼一次新的 Compilation將會被建立。從而生成一組新的編譯資源。

4. public/plugins/FileListPlugin.js 程式碼如下:

class FileListPlugin {
  constructor() {

  }
  apply(compiler) {
    compiler.hooks.compilation.tap('FileListPlugin', (compilation) => {
      compiler.hooks.emit.tap('FileListPlugin', () => {
        let content = '生成的檔案列表\r\n';
        content = Object.keys(compilation.assets).reduce((current, prev) => current + '- ' + prev + '\r\n', content);
        console.log(content);
        compilation.assets['README.md'] = {
          source() {
            return content;
          },
          size() {
            return content.length;
          }
        }
      })
    })
  }
}
module.exports = FileListPlugin;

生成檔案列表的時候,就會觸發該檔案的程式碼。

5. public/plugins/AutoExternalPlugin.js 程式碼如下:

const ExternalModules = require('webpack/lib/ExternalModule');

class AutoExternalPlugin {
  constructor(options) {
    this.options = options;
    this.externalModules = {};
  }
  apply(compiler) {
    compiler.hooks.normalModuleFactory.tap('AutoExternalPlugin', (normalModuleFactory) => {
      // parser 將程式碼轉換為語法書 判斷有無 import
      normalModuleFactory.hooks.parser.for('javascript/auto').tap('AutoExternalPlugin', (parser, parserOptions) => {
        parser.hooks.import.tap('AutoExternalPlugin', (statement, source) => {
          if (this.options[source]) {
            this.externalModules[source] = true;
          }
        })
      })
      // factory 是建立模組的方法
      // data 是建立模組的引數
      normalModuleFactory.hooks.factory.tap('AutoExternalPlugin', factory => (data, callback) => {
        const dependencies = data.dependencies;
        const value = dependencies[0].request; // jquery
        if (this.externalModules[value]) {
          const varName = this.options[value].varName;
          callback(null, new ExternalModules(varName, 'window'));
        } else {
          factory(data, callback);
        }
      })
    });
    compiler.hooks.compilation.tap('InlinePlugin', (compilation) => {
      compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync('AutoExternalPlugin', (htmlPluginData, callback) => {
        Object.keys(this.options).forEach(key => {
          this.externalModules[key] = this.options[key];
          htmlPluginData.body.unshift(this.processTags(compilation, htmlPluginData, this.options[key]))
        });
        callback(null, htmlPluginData); 
      });
    });
  }
  processTags(compilation, htmlPluginData, value) {
    var tag;
    return tag = {
      tagName: 'script',
      closeTag: true,
      attributes: {
        type: 'text/javascript',
        src: value.url
      }
    }
  }
}

module.exports = AutoExternalPlugin;

如上該外掛的程式碼的作用是可以解決外部的js引用,比如我在webpack中如下使用該外掛:

const AutoExternalPlugin = require('./public/plugins/AutoExternalPlugin');
module.exports = {
  plugins:[
    new AutoExternalPlugin({
      jquery:{
        varName:'jQuery',
        url: 'https://cdn.bootcss.com/jquery/3.1.0/jquery.js'
      }
    })
  ]
}

這樣我就可以在頁面中使用jquery外掛了;如下程式碼所示:

import $ from 'jquery';
console.log($);

然後在我們的頁面中引入的是 該 jquery庫檔案,它會把該庫檔案自動生成到 index.html 上去,如下index.html 程式碼變成如下了:

<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
  <link rel="manifest" href="/public/manifest.json" />
<link href="main.css" rel="stylesheet"></head>
<body>
  <div id="app">222226666</div>
<script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.1.0/jquery.js"></script><script type="text/javascript" src="bundle.js"></script></body>
</html>

我們可以來簡單的分析下 AutoExternalPlugin.js 的程式碼:

在apply方法內部會生成一個 compiler 實列,然後我們監聽 normalModuleFactory 事件,該事件的作用我們可以看下官網就知道了。

compiler.hooks.normalModuleFactory.tap('AutoExternalPlugin', (normalModuleFactory) => {
  // parser 將程式碼轉換為語法書 判斷有無 import
  normalModuleFactory.hooks.parser.for('javascript/auto').tap('AutoExternalPlugin', (parser, parserOptions) => {
    parser.hooks.import.tap('AutoExternalPlugin', (statement, source) => {
      if (this.options[source]) {
        this.externalModules[source] = true;
      }
    })
  })
}

如上 parser 例項,是用來解析由 webpack 處理過的每個模組。parser 也是擴充套件自 tapable 的 webpack 類,並且提供多種 tapable 鉤子,外掛作者可以使用它來自定義解析過程。官網解釋可以看這裡

如上程式碼,我們呼叫 parser.hooks.import 鉤子函式, 然後返回的 source 就是我們的在 我們的main.js 中呼叫外掛名。如main.js 程式碼如下:

import $ from 'jquery';

因此在我們的webpack.config.js 中會如下初始化外掛 

new AutoExternalPlugin({
  jquery:{
    varName:'jQuery',
    url: 'https://cdn.bootcss.com/jquery/3.1.0/jquery.js'
  }
});

因此 source 返回的值 就是 'jquery'; 其他的程式碼可以自己稍微看看就行了。這裡暫時先不講了,由於時間問題。

6. public/plugins/MyPlugin.js 程式碼如下:

class MyPlugin {
  constructor(options) {
    this.options = options;
    this.externalModules = {};
  }
  apply(compiler) {
    var reg = /("([^\\\"]*(\\.)?)*")|('([^\\\']*(\\.)?)*')|(\/{2,}.*?(\r|\n))|(\/\*(\n|.)*?\*\/)|(\/\*\*\*\*\*\*\/)/g;
    compiler.hooks.emit.tap('CodeBeautify', (compilation) => {
      Object.keys(compilation.assets).forEach((data) => {
        console.log(data);
        let content = compilation.assets[data].source(); // 獲取處理的文字
        content = content.replace(reg, function (word) { // 去除註釋後的文字
          return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word;
        });
        compilation.assets[data] = {
          source() {
            return content;
          },
          size() {
            return content.length;
          }
        }
      });
    });
  }
}
module.exports = MyPlugin;

這個js程式碼的真正的含義才是我們今天要講到的,這個外掛最主要作用是 去除註釋後的文字。

1. 第一步,我們使用 compiler.hooks.emit 鉤子函式。在生成資源並輸出到目錄之前觸發該函式,也就是說將編譯好的程式碼發射到指定的stream中就會觸發,然後我們從回撥函式返回的 compilation 物件上可以拿到編譯好的 stream.

2. 訪問compilation物件,compilation內部會返回很多內部物件,這邊先不列印了,因為列印的話直接會卡死掉,要等很長時間才會打印出來,你們自己可以試試;然後我們遍歷 assets.

Object.keys(compilation.assets).forEach((data) => {
  console.log(compilation.assets);
  console.log(8888)
  console.log(data);
});

如下圖所示:

1) assets 陣列物件中的key是資源名。在如上程式碼,我們通過 Object.key()方法拿到了。如下所示:

main.css
bundle.js
index.html

2) 然後我們呼叫 compilation.assets[data].source(); 可以獲取資源的內容。

3) 使用正則,去掉註釋,如下程式碼:

Object.keys(compilation.assets).forEach((data) => {
  let content = compilation.assets[data].source(); // 獲取處理的文字
  content = content.replace(reg, function (word) { // 去除註釋後的文字
    return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word;
  });
});

4) 更新 compilation.assets[data] 物件,如下程式碼:

compilation.assets[data] = {
  source() {
    return content;
  },
  size() {
    return content.length;
  }
}

然後我們就可以在webpack中引入該所有的外掛:

const DonePlugin = require('./public/plugins/DonePlugin');
const OptimizePlugin = require('./public/plugins/OptimizePlugin');
const AsyncPlugin = require('./public/plugins/AsyncPlugin');
const FileListPlugin = require('./public/plugins/FileListPlugin');
const AutoExternalPlugin = require('./public/plugins/AutoExternalPlugin');
const MyPlugin = require('./public/plugins/MyPlugin');

呼叫方式如下:

module.exports = {
  plugins:[
    new DonePlugin(),
    new OptimizePlugin(),
    new AsyncPlugin(),
    new FileListPlugin(),
    new MyPlugin(),
    new AutoExternalPlugin({
      jquery:{
        varName:'jQuery',
        url: 'https://cdn.bootcss.com/jquery/3.1.0/jquery.js'
      }
    })
  ]
}

然後我們進行打包執行效果如下所示:

github原始碼