webpack原始碼學習系列之二:code-splitting(程式碼切割)

樑少峰發表於2017-03-03

前言

上一篇之後,我們今天來看看如何實現 webpack 的程式碼切割(code-splitting)功能,最後實現的程式碼版本請參考這裡。至於什麼是 code-splitting ,為什麼要使用它,請直接參考官方文件

目標

一般說來,code-splitting 有兩種含義:

  1. 將第三方類庫單獨打包成 vendor.js ,以提高快取命中率。(這一點我們不作考慮)
  2. 將專案本身的程式碼分成多個 js 檔案,分別進行載入。(我們只研究這一點)

換句話說,我們的目標是:將原先集中到一個 output.js 中的程式碼,切割成若干個 js 檔案,然後分別進行載入。 也就是說:原先只載入 output.js ,現在把程式碼分割到3個檔案中,先載入 output.js ,然後 output.js 又會自動載入 1.output.js 和 2.output.js 。

切割點的選擇

既然要將一份程式碼切割成若干份程式碼,總得有個切割點的標誌吧,從哪兒開始切呢?
答案:webpack 使用require.ensure作為切割點。

然而,我用 nodeJS 也挺長時間了,怎麼不知道還有require.ensure這種用法?而事實上 nodeJS 也是不支援的,這個問題我在CommonJS 的標準中找到了答案:雖然 CommonJS 通俗地講是一個同步模組載入規範,但是其中是包含非同步載入相關內容的。只不過這條內容只停留在 PROPOSAL (建議)階段,並未最終進入標準,所以 nodeJS 沒有實現它也就不奇怪了。只不過 webpack 恰好利用了這個作為程式碼的切割點。

ok,現在我們已經明白了為什麼要選擇require.ensure作為切割點了。接下來的問題是:如何根據切割點對程式碼進行切割? 下面舉個例子。

例子

// example.js
var a = require("a");
var b = require("b");
a();
require.ensure(["c"], function(require) {
    require("b")();
    var d = require("d");
    var c = require('c');
    c();
    d();
});

require.ensure(['e'], function (require) {
   require('f')();
});複製程式碼

假設這個 example.js 就是專案的主入口檔案,模組 a ~ f 是簡簡單單的模組(既沒有進一步的依賴,也不包含require.ensure)。那麼,這裡一共有2個切割點,這份程式碼將被切割為3部分。也就說,到時候會產生3個檔案:output.js ,1.output.js ,2.output.js

識別與處理切割點

程式如何識別require.ensure呢?答案自然是繼續使用強大的 esprima 。關鍵程式碼如下:

// parse.js
if (expression.callee && expression.callee.type === 'MemberExpression'
    && expression.callee.object.type === 'Identifier' && expression.callee.object.name === 'require'
    && expression.callee.property.type === 'Identifier' && expression.callee.property.name === 'ensure'
    && expression.arguments && expression.arguments.length >= 1) {

    // 處理require.ensure的依賴引數部分
    let param = parseStringArray(expression.arguments[0])
    let newModule = {
        requires: [],
        namesRange: expression.arguments[0].range
    };
    param.forEach(module => {
        newModule.requires.push({
            name: module
        });
    });

    module.asyncs = module.asyncs || [];
    module.asyncs.push(newModule);

    module = newModule;

    // 處理require.ensure的函式體部分
    if(expression.arguments.length > 1) {
        walkExpression(module, expression.arguments[1]);
    }
}複製程式碼

觀察上面的程式碼可以看出,識別出require.ensure之後,會將其儲存到 asyncs 陣列中,且繼續遍歷其中所包含的其他依賴。舉個例子,example.js 模組最終解析出來的資料結構如下圖所示:

webpack原始碼學習系列之二:code-splitting(程式碼切割)
image

module 與 chunk

我在剛剛使用 webpack 的時候,是分不清這兩個概念的。現在我可以說:“在上面的例子中,有3個 chunk,分別對應 output.js、1.output.js 、2.output.js;有7個 module,分別是 example 和 a ~ f。

所以,module 和 chunk 之間的關係是:1個 chunk 可以包含若干個 module。
觀察上面的例子,得出以下結論:

  1. chunk0(也就是主 chunk,也就是 output.js)應該包含 example 本身和 a、b 三個模組。
  2. chunk1(1.output.js)是從 chunk0 中切割出來的,所以 chunk0 是 chunk1 的 parent。
  3. 本來 chunk1 應該是包含模組 c、b 和 d 的,但是由於 c 已經被其 parent-chunk(也就是 chunk1)包含,所以,必須將 c 從 chunk1 中移除,這樣方能避免程式碼的冗餘。
  4. chunk2(2.output.js)是從 chunk0 中切割出來的,所以 chunk0 也是 chunk2 的 parent。
  5. chunk2 包含 e 和 f 兩個模組。

好了,下面進入重頭戲。

構建 chunks

在對各個模組進行解析之後,我們能大概得到以下這樣結構的 depTree。

webpack原始碼學習系列之二:code-splitting(程式碼切割)
image

下面我們要做的就是:如何從8個 module 中構建出3個 chunk 出來。 這裡的程式碼較長,我就不貼出來了,想看的到這裡的 buildDep.js

其中要重點注意是:前文說到,為了避免程式碼的冗餘,需要將模組 c 從 chunk1 中移除,具體發揮作用的就是函式removeParentsModules,本質上無非就是改變一下標誌位。最終生成的chunks的結構如下:

webpack原始碼學習系列之二:code-splitting(程式碼切割)
image

拼接 output.js

經歷重重難關,我們終於來到了最後一步:如何根據構建出來的 chunks 拼接出若干個 output.js 呢?
此處的拼接與上一篇最後提到的拼接大同小異,主要不同點有以下2個:

  1. 模板的不同。原先是一個 output.js 的時候,用的模板是 templateSingle 。現在是多個 chunks 了,所以要使用模板 templateAsync。其中不同點主要是 templateAsync 會發起 jsonp 的請求,以載入後續的 x.output.js,此處就不加多闡述了。仔細 debug 生成的 output.js 應該就能看懂這一點。
  2. 模組名字替換為模組 id 的演算法有所改進。原先我直接使用正則進行匹配替換,但是如果存在重複的模組名的話,比如此例子中 example.js 出現了2次模組 b,那麼簡單的匹配就會出現錯亂。因為 repalces 是從後往前匹配,而正則本身是從前往後匹配的。webpack 原作者提供了一種非常巧妙的方式,具體的程式碼可以參考這裡

後話

其實關於 webpack 的程式碼切割還有很多值得研究的地方。比如本文我們實現的例子僅僅是將1個檔案切割成3個,並未就其載入時機進行控制。比如說,如何支援在單頁面應用切換 router 的時候再載入特定的 x.output.js?

注:更多系列文章請移步我的部落格

-------- EOF -----------


本文對你有幫助?歡迎掃碼加入前端學習小組微信群:

webpack原始碼學習系列之二:code-splitting(程式碼切割)

相關文章