自己寫插件控制 Webpack 的 Chunk 劃分,想怎麼分就怎麼分

想必大家都用過 webpack,也或多或少了解它的原理,但是不知道大家有沒有寫過 Webpack 的插件呢?

今天我們就一起來寫一個劃分 Chunk 的 webpack 插件吧,寫完後你會發現想怎麼分 Chunk 都可以!

首先我們簡單瞭解下 webpack 的原理:

webpack 的原理

webpack 是一個打包工具(bundler),它打包的是什麼呢?

模塊。

那模塊能再拆分麼?

不能了,模塊是 webpack 處理的基本單位了,只是對模塊做一些打包。

那怎麼對模塊打包呢?

首先要找到所有的模塊 (Module),從入口模塊開始,分析依賴,構成一個圖,叫做模塊依賴圖 (ModuleGraph)。

然後模塊要分成幾個包,要有一種中間結構來保存這種劃分,叫做 Chunk。把不同的模塊放到不同的 Chunk 裏就完成了分包。

但是 Chunk 只是一種中間結構,還要再變成可用的目標代碼。通過代碼模版把它打印成代碼就可以了。

這三步分別叫 make、seal、emit。

make 這一步就是構建模塊依賴圖 ModuleGraph 的,這個過程中會從入口模塊(EntryPoint)開始遞歸解析依賴,對解析出的每個模塊做處理,也就是調用註冊的 loader。

然後 Seal 也就是封裝的意思,把不同的 Module 分到不同的 Chunk 裏。

這一步會先做基礎的 Chunk 劃分,比如入口模塊 EntryPoint 肯定要單獨放到 Chunk 裏,動態引入的模塊肯定也要單獨放到 Chunk 裏。

完成了基礎的劃分之後,可以再對這些 Chunk 做進一步的優化劃分,比如根據 Chunk 大小等來劃分。

分完之後,ModuleGrapqh 就變成了 ChunkGraph。

最後 emit 階段就是通過模版打印代碼了。

這三步合起來就是一次編譯過程 Compilation。

編譯過程由 webpack 的 Compiler 調用。

整個過程中還會暴露出很多擴展點,也就是留給插件的 hook,不同階段的 hook 自然就可以拿到不同階段的資源。

這些插件都是保存在對象上的:

比如 compiler 的 hook:

compilation 的 hook:

那插件裏自然就是往不同對象的 hook 上添加回調函數:

而且 webpack 爲了控制 hook 的執行順序,封裝了一個 tappable 的包。可以指定 hook 是同步、異步,並行、串行執行。

比如這幾種 hook:

SynHook 就是同步順序執行。

AsyncSeriesHook 就是異步並行執行。

SyncBailHook 也是同步順序執行,但是如果中間的 hook 返回 false 就會停止後續 hook 的執行,也就是可以熔斷。

理解了 webpack 的編譯流程,hook 的運行機制,接下來我們就寫個插件來操作下 Chunk 吧:

操作 Chunk 的 webpack 插件

前面講過,webpack 會對 Module 根據是否是入口模塊、是否是異步引入的模塊做基礎的 Chunk 劃分。

之後會進一步做優化的 Chunk 劃分。

這些 chunk 相關的邏輯都是在 seal 那一步做的。

我們在源碼裏看到的也確實是這樣:

在 seal 裏做了 ChunkGraph 的創建,然後調用 optimizeChunks 的 hook 對 Chunks 做處理。

這裏爲啥是個死循環呢?

記得上面說過一種 hook 類型叫 SyncBailHook 麼?

也就是同步執行插件,但是可以插件可以返回 false 熔斷後面插件的執行。

這裏的 hook 就是同步熔斷 hook:

那我們就開始在這個 hook 裏寫一些邏輯吧:

class ChunkTestPlugin {
    constructor(options) {
        this.options = options || {};
    }

    apply(compiler) {
        const options = this.options;

 compiler.hooks.thisCompilation.tap("ChunkTestPlugin"compilation ={
            compilation.hooks.optimizeChunks.tap("ChunkTestPlugin"chunks ={

                return true;
            });
        });
    }
}

module.exports = ChunkTestPlugin;

把 options 掛到 this 上。

然後註冊一個 optimizeChunks 這個 hook 的回調。

爲啥外面還要加一層 compiler 的 hook 呢?

因爲你得在 compiler 剛開始編譯的時候去註冊 compilation 的 hook 呀!不然就晚了。

可以看到 thisCompilation 是在 newCompilation 這個方法調用的。

而 newCompilation 是在 make、seal、emit 的流程開始之前調用的:

也就是說在 thisCompilation 的 Compiler hook 裏註冊的 Compilation hook 就可以在這次編譯過程中生效。

有的同學說,那還有另一個 hook 是幹啥的呢?

這倆 hook 唯一的區別是當有 child compiler 的時候,compilation 的 hook 會生效,而 thisCompilation 不會。

而我們是想在這個 hook 裏註冊 Compilation 的 hook 的,全局只需要執行一次就行,所以用 thisCompilation 的 Compiler hook。

我們在項目裏用一下:

這個項目有三個入口模塊:

pageA:

require(["./common"]function (common) {
 common(require("./a"));
});

pageB:

require(["./common"]function(common) {
 common(require("./b"));
});

pageC:

require(["./a"]function(a) {
 console.log(a + require("./b"));
});

這三個模塊裏都通過 requrie() 或者 import() 的 webpack api 來動態引入了一些模塊。

動態引入的模塊分別是:

a:

module.exports = "a";

b:

module.exports = "b";

common:

module.exports = function (msg) {
    console.log(msg);
};

function hello() {
    return "guangguangguangguangguangguanggua"
  + "ngguangguangguangguangguangguangguangguangg"
  + "uangguangguangguangguangguangguangguangguangg"
  + "uangguangguangguangguangguangguangguangguang"
  + "guangguangguangguangguangguangguangguangguangguang"
  + "guangguangguangguangguangguangguangguangguangguangguang";
}

webpack 會從入口模塊開始構建 ModuleGraph,然後劃分 Chunk,構成 ChunkGraph。

大家覺得這幾個模塊會分幾個 Chunk 呢?

6 個。

因爲入口模塊要用單獨的 Chunk,而且異步引入的模塊也是單獨的 Chunk。

打個斷點看一下就知道了:

確實,到了 optimizeChunks 這一步,拿到的是 6 個 Chunk:

分別是 3 個入口,以及每個入口用到的異步模塊。

在這個 optimizeHook 的插件裏,我們就可以自己做一些 Chunk 拆分了。

chunkGroup 有一個 integrateChunks 的 api,把後面的 chunk 合併到前面的 chunk 裏:

我們調用 integrateChunks 進行 chunk 合併,然後把被合併的那個 chunk 刪掉即可。

那怎麼找到 a 和 b 兩個 chunk 呢?

兩層循環,分別找到兩個不想等的 chunk 進行合併即可:

我們只取第一組 chunk 進行合併,合併完如果還有就返回 true,繼續進行下次合併。

合併完之後記得 return false,因爲外面是一個 while 循環,不 return false,就一直死循環。

先試一下現在的效果:

不引入插件的時候是這樣的:

3 個入口 chunk,3 組入口 chunk 的異步引入的模塊。所以產生了 6 個文件。

入口 chunk 對應的文件裏引入異步模塊的方法變成了 webpack runtime 的 _webpack_require.e

而它引入的異步 chunk 裏就如前面分析的,包含了這個模塊的所有異步依賴:

分別是 a + common,b + common,a + b,也就是每個入口模塊依賴的所有異步模塊。

那優化之後呢?

都放到一個 chunk 裏了:

這倒是符合我們寫的邏輯,因爲兩兩合併,最後剩下的肯定只有一個。

但這樣顯然不大好,因爲每個頁面是獨立的,應該分開,但是異步的 chunk 倒是可以合併。

所以我們優化一下:

調用 chunk 的 isInitial 方法就可以判斷是否是入口的 chunk,是的話就跳過。

這樣就只合並了異步 chunk。

效果是這樣的:

3 個入口 chunk 的依賴也變成這個 chunk 了:

那如果我要根據 chunk 大小來優化呢?

那就可以判斷下 a、b 的 chunk 的大小和合並之後的 chunk 大小,如果合併之後比合並前小很多,就合併。

當然,不同的 chunk 合併效果是不一樣的,我們要把所有的合併效果下來:

通過 chunkGraph.getChunkSize 的 api 拿到 chunk 大小,通過 chunkGroup.getIntegratedChunkSize 的 api 拿到合併後的 chunk 大小。

記錄下合併的兩個 chunk 和合並的收益。

做個排序,把合併收益最大的兩個 chunk 合併。

返回 true 來繼續循環進行合併,直到收益小於 1.5,那就 return false 停止合併。

當然,這個 1.5 也可以通過 options 傳進來。

效果是這樣的:

兩個異步 chunk 分別爲:

a + b + common:

a + b:

也就是說只把之前的 a + common 和 b + common 合併了,因爲 common 模塊比較大,所以合併之後的收益是挺大的。

這樣就完成了 chunk 拆分的優化。

有的同學說,我平時也不用自己寫插件來拆分 chunk 呀,webpack 不是提供了 SplitChunksPlugin 的插件麼,還變成內置的了,配置下 optimization.splitChunks 就行。

沒錯,webpack 默認提供了拆分 chunk 的插件。

那這個插件是怎麼實現的呢?

SplitChunkPlugin 的實現原理就是我們剛纔說的這些,註冊了 optimizeChunks 的 hook,在裏面做了 chunk 拆分:

它可以根據配置來拆分 chunk,但是終究是有侷限性的。

如果某種 chunk 拆分方式它不支持呢?

我們就可以寫插件自己拆分了,會自己拆分 chunk 之後,還不是想怎麼分就怎麼分麼!

我們寫的這個 webpack 插件的全部代碼如下:

class ChunkTestPlugin {
 constructor(options) {
  this.options = options || {};
 }

 apply(compiler) {
  const options = this.options;
  const minSizeReduce = options.minSizeReduce || 1.5;

  compiler.hooks.compilation.tap("ChunkTestPlugin"compilation ={
   compilation.hooks.optimizeChunks.tap("ChunkTestPlugin"chunks ={
    const chunkGraph = compilation.chunkGraph;

    let combinations = [];
    for (const a of chunks) {
     if (a.canBeInitial()) continue;
     for (const b of chunks) {
      if (b.canBeInitial()) continue;
      if (b === a) break;

      const aSize = chunkGraph.getChunkSize(b, {
       chunkOverhead: 0
      });
      const bSize = chunkGraph.getChunkSize(a, {
       chunkOverhead: 0
      });
      const abSize = chunkGraph.getIntegratedChunksSize(b, a, {
       chunkOverhead: 0
      });
      const improvement = (aSize + bSize) / abSize;

      combinations.push({
       a,
       b,
       improvement
      });
     }
    }

    combinations.sort((a, b) ={
     return b.improvement - a.improvement;
    });

    const pair = combinations[0];

    if (!pair) return;
    if (pair.improvement < minSizeReduce) return;

    chunkGraph.integrateChunks(pair.b, pair.a);
    compilation.chunks.delete(pair.a);
    return true;
   });
  });
 }
}

module.exports = ChunkTestPlugin;

總結

webpack 的處理單位是模塊,它的編譯流程分爲 make、seal、emit:

這個編譯流程中有很多 hook,通過 tappable 的 api 組織,可以控制回調的同步、異步、串行、並行執行。

我們今天寫的 Chunk 拆分插件,就是一個 SyncBailHook,同步熔斷的串行 hook 類型,也就是前面回調返回 false 會終止後面的回調執行。

首先在 compiler 的 thisCompilation 的 hook 裏來註冊 compilation 的 optimizeChunks 的 hook。

在 optimizeChunks 的 hook 裏可以拿到所有的 chunk,調用 chunkGraph 的 api 可以進行合併。

我們排除掉了入口 chunk,然後把剩下的 chunk 根據大小進行合併,達到了優化 chunk 的目的。

webpack 內置了 SplitChunksPlugin,但是畢竟有侷限性,當不滿足需求的時候就可以自己寫插件來劃分 chunk 了。

自己來控制 Chunk 劃分,想怎麼分就怎麼分!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GP6EVdh9IYqMQ2CHYAnCWQ