webpack源碼學習系列之二:code-splitting(代碼切割)

PS:更多相關係列文章請移步我的博客。

前言

繼上一篇之後,我們今天來看看如何實現 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.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。

觀察上面的例子,得出以下結論:

  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。

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

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

拼接 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 的核心開發者 Sean Larkin 入駐 SegmentFault 了
那些激動人心的 React, Webpack, Babel 的新特性對於我們開發體驗帶來哪些提升
webpack如何全局引入jquery和插件?
webpack 之 代碼拆分
用webpack,輸出多個出口文件,每個出口文件對應一個頁面,重複引用jquery?

TAG:前端開發 | webpack |