前言
繼上一篇之後,我們今天來看看如何實現 webpack 的程式碼切割(code-splitting)功能,最後實現的程式碼版本請參考這裡。至於什麼是 code-splitting ,為什麼要使用它,請直接參考官方文件。
目標
一般說來,code-splitting 有兩種含義:
- 將第三方類庫單獨打包成 vendor.js ,以提高快取命中率。(這一點我們不作考慮)
- 將專案本身的程式碼分成多個 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 模組最終解析出來的資料結構如下圖所示:
module 與 chunk
我在剛剛使用 webpack 的時候,是分不清這兩個概念的。現在我可以說:“在上面的例子中,有3個 chunk,分別對應 output.js、1.output.js 、2.output.js;有7個 module,分別是 example 和 a ~ f。
所以,module 和 chunk 之間的關係是:1個 chunk 可以包含若干個 module。
觀察上面的例子,得出以下結論:
- chunk0(也就是主 chunk,也就是 output.js)應該包含 example 本身和 a、b 三個模組。
- chunk1(1.output.js)是從 chunk0 中切割出來的,所以 chunk0 是 chunk1 的 parent。
- 本來 chunk1 應該是包含模組 c、b 和 d 的,但是由於 c 已經被其 parent-chunk(也就是 chunk1)包含,所以,必須將 c 從 chunk1 中移除,這樣方能避免程式碼的冗餘。
- chunk2(2.output.js)是從 chunk0 中切割出來的,所以 chunk0 也是 chunk2 的 parent。
- chunk2 包含 e 和 f 兩個模組。
好了,下面進入重頭戲。
構建 chunks
在對各個模組進行解析之後,我們能大概得到以下這樣結構的 depTree。
下面我們要做的就是:如何從8個 module 中構建出3個 chunk 出來。 這裡的程式碼較長,我就不貼出來了,想看的到這裡的 buildDep.js 。
其中要重點注意是:前文說到,為了避免程式碼的冗餘,需要將模組 c 從 chunk1 中移除,具體發揮作用的就是函式removeParentsModules
,本質上無非就是改變一下標誌位。最終生成的chunks的結構如下:
拼接 output.js
經歷重重難關,我們終於來到了最後一步:如何根據構建出來的 chunks 拼接出若干個 output.js 呢?
此處的拼接與上一篇最後提到的拼接大同小異,主要不同點有以下2個:
- 模板的不同。原先是一個 output.js 的時候,用的模板是 templateSingle 。現在是多個 chunks 了,所以要使用模板 templateAsync。其中不同點主要是 templateAsync 會發起 jsonp 的請求,以載入後續的 x.output.js,此處就不加多闡述了。仔細 debug 生成的 output.js 應該就能看懂這一點。
- 模組名字替換為模組 id 的演算法有所改進。原先我直接使用正則進行匹配替換,但是如果存在重複的模組名的話,比如此例子中 example.js 出現了2次模組 b,那麼簡單的匹配就會出現錯亂。因為 repalces 是從後往前匹配,而正則本身是從前往後匹配的。webpack 原作者提供了一種非常巧妙的方式,具體的程式碼可以參考這裡。
後話
其實關於 webpack 的程式碼切割還有很多值得研究的地方。比如本文我們實現的例子僅僅是將1個檔案切割成3個,並未就其載入時機進行控制。比如說,如何支援在單頁面應用切換 router 的時候再載入特定的 x.output.js?
注:更多系列文章請移步我的部落格
-------- EOF -----------
本文對你有幫助?歡迎掃碼加入前端學習小組微信群: