80行程式碼教你寫一個Webpack外掛併發布到npm

yiming發表於2021-05-23

1. 前言

最近在學習 Webpack 相關的原理,以前只知道 Webpack 的配置方法,但並不知道其內部流程,經過一輪的學習,感覺獲益良多,為了鞏固學習的內容,我決定嘗試自己動手寫一個外掛。

這個外掛實現的功能比較簡單:

  • 預設清除 js 程式碼中的 console.log 的列印輸出;
  • 可通過傳入配置,實現移除 console 的其它方法,如 console.warnconsole.error 等;

2. Webpack 的構建流程以及 plugin 的原理

2.1 Webpack 構建流程

Webpack 的主要構建流程,可以分為三個階段:

  • 初始化階段:啟動構建,讀取與合併配置引數,載入 Plugin,例項化 Compiler
  • 編譯階段:從 Entry 發出,針對每個 Module 序列呼叫對應的 Loader 去翻譯檔案內容,再找到該 Module 依賴的 Module,遞迴地進行編譯處理。
  • 生成階段:對編譯後的 Module 組合成 Chunk,把 Chunk 轉換成檔案,輸出到檔案系統。

如果 Webpack 打包生產環境檔案時,只會執行一次構建,以上階段會按順序執行一遍。但是在開啟監聽模式時,如開發環境,Webpack 會持續的進行構建。
image

2.2 plugin 原理

Webpack 外掛通常是一個帶有 apply 函式的類,其中 constructor 可以接收傳入的配置項。外掛被安裝時,apply 函式會被呼叫一次,並接收 Compiler 物件,然後我們可以在 Compiler 物件上監聽不同的事件鉤子,從而進行外掛功能的開發。

// 定義一個外掛
class MyPlugin {
  // 建構函式,接收外掛的配置項 options 
  constructor(options) {
    // 獲取配置項,初始化外掛
  }

  // 外掛安裝時會呼叫 apply,並傳入 compiler
  apply(compiler) {
    // 獲取 comolier 獨享,可以監聽事件鉤子
    // 功能開發 ... 
  }
}

2.3 compiler 和 compilation 物件

在開發 Plugin 過程中最常用的兩個物件就是 CompilerCompilation

  • Compiler 物件在 Webpack 啟動時被例項化,該物件包含了 Webpack 環境所有的配置資訊,包括 optionsloadersplugins 等。在整個 Webpack 構建過程中,Compiler 物件是全域性唯一的, 它提供了很多事件鉤子回撥供外掛使用。
  • Compilation 物件包含了當前的模組資源、編譯生成資源、變化的檔案等。Compilation 物件在 Webpack 構建過程中並不是唯一的,如果在開發模式下 Webpack 開啟了檔案檢測功能,每當檔案變化時,Webpack 會重新構建,此時會生成一個新的 Compilation 物件。Compilation 物件也提供了很多事件回撥供外掛做擴充套件。

3. 外掛開發

3.1 專案目錄

該外掛實現的功能比較簡單,檔案目錄也不復雜。首先新建一個空資料夾 remove-console-Webpack-plugin,並在該資料夾目錄下執行 npm init,根據提示來填寫 package.json 相關資訊。然後再新建一個 src 資料夾,外掛主要程式碼就放在 src/index.js 裡面。如果你需要把專案放到 github 上,最好也新增一下 .gitignoreREADME.md 等檔案。

// remove-console-Webpack-plugin
├─src
│  └─index.js  
├─.gitignore
├─package.json
└─README.md 

3.2 外掛程式碼

外掛程式碼邏輯也並不複雜,主要有幾點:

  1. 在建構函式中接收配置引數,並對引數進行合併,得到需要清除的 console 函式, 存放在 removed 陣列中;
  2. apply 函式中監聽 compiler.hook.compilation 鉤子,該鉤子觸發後,拿到 compilation 後進一步監聽它的鉤子,這裡 Webpack4Webpack5 的鉤子不一樣,需要做相容;
  3. 定義 assetsHandler 方法來處理 js 檔案,利用正規表示式清除 removed 中包括的 console 函式;
class RemoveConsoleWebpackPlugin {
  // 建構函式接受配置引數
  constructor(options) {
    let include = options && options.include;
    let removed = ['log']; // 預設清除的方法

    if (include) {
      if (!Array.isArray(include)) {
        console.error('options.include must be an Array.');
      } else if (include.includes('*')) {
        // 傳入 * 表示清除所有 console 的方法
        removed = Object.keys(console).filter(fn => {
          return typeof console[fn] === 'function';
        })
      } else {
        removed = include; // 根據傳入配置覆蓋
      }
    }

    this.removed = removed;
  }

  // Webpack 會呼叫外掛例項的 apply 方法,並傳入compiler 物件
  apply(compiler) {
    // js 資原始碼處理函式
    let assetsHandler = (assets, compilation) => {
      let removedStr = this.removed.reduce((a, b) => (a + '|' + b));

      let reDict = {
        1: [RegExp(`\\.console\\.(${removedStr})\\(\\)`, 'g'), ''],
        2: [RegExp(`\\.console\\.(${removedStr})\\(`, 'g'), ';('],
        3: [RegExp(`console\\.(${removedStr})\\(\\)`, 'g'), ''],
        4: [RegExp(`console\\.(${removedStr})\\(`, 'g'), '(']
      }

      Object.entries(assets).forEach(([filename, source]) => {
        // 匹配js檔案
        if (/\.js$/.test(filename)) {
          // 處理前檔案內容
          let outputContent = source.source();

          Object.keys(reDict).forEach(i => {
            let [re, s] = reDict[i];
            outputContent = outputContent.replace(re, s);
          })

          compilation.assets[filename] = {
            // 返回檔案內容
            source: () => {
              return outputContent
            },
            // 返回檔案大小
            size: () => {
              return Buffer.byteLength(outputContent, 'utf8')
            }
          }
        }
      })
    }

    /**
     * 通過 compiler.hooks.compilation.tap 監聽事件
     * 在回撥方法中獲取到 compilation 物件
     */
    compiler.hooks.compilation.tap('RemoveConsoleWebpackPlugin',
      compilation => {
        // Webpack 5
        if (compilation.hooks.processAssets) {
          compilation.hooks.processAssets.tap(
            { name: 'RemoveConsoleWebpackPlugin' },
            assets => assetsHandler(assets, compilation)
          );
        } else if (compilation.hooks.optimizeAssets) {
          // Webpack 4
          compilation.hooks.optimizeAssets.tap(
            'RemoveConsoleWebpackPlugin', 
            assets => assetsHandler(assets, compilation)
          );
        }
      })
  }
}

// export Plugin
module.exports = RemoveConsoleWebpackPlugin;

4. 釋出到npm

希望別人能使用到你的外掛,就需要把外掛釋出到 npm 上,釋出的主要流程:

  1. 首先在 npm 官網上註冊賬號,然後開啟命令列工具,在任意目錄下輸入 npm login 並按提示登入;
    image

  2. 登入後可用 npm whoami 檢視是否登入成功;
    image

  3. 釋出前檢查一下根目錄下的 package.json 檔案資訊是否填寫正確,主要欄位:
    name:決定使用者下載你的外掛時用的名稱,不可與 npm 上已有的第三方包重名,否則無法釋出;
    main:外掛主檔案入口,Webpack 引入外掛時,就從該目錄匯入;
    version:每次更新發布時,需要與上一版本的版本號不一樣,否則上傳不成功;
    repository:如果你的外掛程式碼放在 githubgitee 等網站,可以填一下;
    private:不能設定為 true,否則無法釋出;
    image

  4. 一切準備就緒後,切換到外掛所在的目錄下,執行 npm publish 即可上傳外掛;
    image

  5. 上傳成功後,到 npm 官網上搜尋,看看是否能搜到外掛;
    image

5. 結尾

本文是我學習了 Webpack 原理並開發了一個小外掛後的總結,由於 Webpack 的內容實在太多了,所以可能會有理解不到位的地方,還請大佬們多多指正。另外,如果這篇文章對你有幫助,可以給我點個贊,或者給我的外掛專案點個star,你的鼓勵是我最大的動力哈~

相關文章