webpack源碼學習系列之二:code-splitting(代碼切割)
PS:更多相關係列文章請移步我的博客。
前言
繼上一篇之後,我們今天來看看如何實現 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.jsvar 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.jsif (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。
其中要重點注意是:前文說到,為了避免代碼的冗餘,需要將模塊 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 -----------
推薦閱讀:
※Webpack 的核心開發者 Sean Larkin 入駐 SegmentFault 了
※那些激動人心的 React, Webpack, Babel 的新特性對於我們開發體驗帶來哪些提升
※webpack如何全局引入jquery和插件?
※webpack 之 代碼拆分
※用webpack,輸出多個出口文件,每個出口文件對應一個頁面,重複引用jquery?