淺談大型 React 專案的 Code Splitting

如何透過快取和動態載入加速你的網路應用

Chia-Wei Li
Dec 16, 2017 · 8 min read

開發大型專案時經常會遇到幾個問題:

  • 如何讓程式碼容易維護與重覆使用
  • 如何確保系統資料的一致性
  • 如何保持流暢的使用者體驗

談大型 React App 檔案架構」一文中介紹了我們以模組化概念設計的專案架構,藉此簡化維護與重覆使用程式碼時可能遇到的問題。不過 app 的體積會隨著功能增加變得越來越大,進而導致戴入時間過長使得使用者體驗不佳。這時候我們可以透過 code splitting 調整使用程式碼的方式,減少開啟 app 所需的等待時間。


Code Splitting 的概念

我們主要透過兩種 code splitting 的方式改善 app 載入的效率:

  1. 獨立打包不常變動的程式碼,讓瀏覽器在載入時儘可能地使用快取,減少每次 app 更新後需要下載的內容。舉例來說 reactreact-dom 套件合計約為 130 KB,如果把它們獨立打包在每次 app 更新時就可以省下 130 KB 的傳輸。對於不常更新的第三方套件來說,這種處理方式實作上簡單也能達到不錯的效果。
  2. 依功能模組打包程式碼,並在需要特定功能時才載入對應的模組,減少初始化 app 時需要下載的內容。假設原本完整的 app.js 約為 280 KB,我們把首頁、個人專區等功能分別打包為 home.jsprofile.js 後將 app.js 精簡到約 150 KB。修改後直接進到首頁只需要下載 150 KBapp.js20 KBhome.js 就可以開始操作,切換到個人專區時也只要再下載 102 KBprofile.js 即可。這個處理方式實作上比較複雜,但對個別功能完整且獨立的 app 相當有幫助,畢竟使用者不是一開始就會用到所有的功能。而在功能模組各自打包的情況下,更新 app 時又能透過快取節省更多的傳輸。

第一種 Code Splitting:抽離第三方套件

CommonsChunkPlugin 是 webpack 用來將重覆引用的模組打包為獨立檔案的功能,同時也有許多調整打包行為的選項可供設定。這篇文章先介紹兩種常見的使用方式,後續有機會再專文探討更多進階的功能。

將所有第三方套件打包為單一檔案

  • 優點:邏輯簡單明瞭。
  • 缺點:更新任何第三方套件都會使快取失效。
CommonsChunkPlugin 的 webpack 設定

首先我們在 output.filename 加上 [chunkhash]確保每次檔案修改後使用不同名稱,避免瀏覽器因為 URL 相同而繼續使用快取。由於 webpack 在計算 [chunkhash] 時會把模組 ID 的資訊納入其中,而預設的模組 ID 是以模組在 app 中引用的順序決定,一旦新增或移除模組都可能導致 [chunkhash] 變動使得快取失效。因此我們在 plugins 中加入 NamedModulePlugin 讓 webpack 使用檔案路徑作為模組 ID,確保輸出名稱不受模組增減的影響。

接著在第一個 CommonsChunkPlugin 設定中我們用 minChunks 判斷模組是否來自 node_modules,如果是的話就把它放進 vendor 中。這樣一來所有的第三方套件就會被從 app 抽離,後續更新時瀏覽器就會儘可能地使用 vendor 的快取。

由於每次 build 時 webpack 會額外帶進一些初始化的程式碼,因此我們需要第二個 CommonsChunkPlugin 設定把這些內容打包,否則即使在沒有修改的情況下重覆 build 都會產生不同的輸出。這個 CommonsChunkPlugin 設定的 name 必需跟 webpack 設定中所有的 entry 名稱都不同,而 minChunks 設定為 Infinity 可以確保不會有其他內容被放進這個檔案中。

將所有第三方套件打包為單一檔案後的首次使用
將所有第三方套件打包為單一檔案後的 app 更新

從上面兩張圖中我們可以觀察到 vendor 的檔案大小佔了完整 app 的六成左右,在往後的每次 app 更新都可以省下不少傳輸時間。對於第三方套件佔整體比例較高的中小型 app,這個方法可以對載入時間帶來非常明顯的改善。

將第三方套件打包為多個檔案

  • 優點:可以根據套件關聯性打包,減少套件更新時造成的延遲。
  • 缺點:需要人工處理相關邏輯。

首先我們可以藉由 WebpackVisualizerPlugin 觀察 app 的模組分布:

$ npm install --save-dev webpack-visualizer-plugin
WebpackVisualizerPlugin 的 webpack 設定

在 webpack 設定的 plugins 中加入 WebpackVisualizerPlugin 後 build 會額外產生一個 stats.html,打開就可以看到 Webpack Visualizer 的分析結果。

Webpack Visualizer 的分析結果

接著就可以根據分析結果找出檔案較大的第三方套件,並依照關聯使用 CommonsChunkPlugin 分別打包成不同的檔案。

根據套件關聯性在加入對應的 entry 後,我們可以在 CommonsChunkPlugin 設定中用 names 指定想要個別打包的 entry 名稱,就可以產生個別的模組檔案。

將第三方套件打包為多個檔案後的首次使用
將第三方套件打包為多個檔案後的 app 更新

從上面兩張圖可以看得出來和打包為單一檔案的效果類似,不過這個方式能讓套件更新時更有彈性,相對的也要花比較多心思處理設定。


第二種 Code Splitting:動態載入功能模組

ES2015 中定義了 import() 供動態載入模組時使用,而 webpack 會參考 import() 作為 code splitting 的依據,將動態載入的模組打包為獨立的檔案。由於我們的專案架構中功能模組和 app 之間是透過模組的 route 連結,只要在組裝 route 時透過 import() 處理就可以達成動態載入功能模組的目的。

產生非同步 route 的函數

上面的 asyncRoute() 程式碼看起來有點長,不過主要的概念在於利用 React 元件的生命週期讓 routereducer 在即將被使用( componentWillMount() 被呼叫)時才動態載入。搭配 HMR 開發必需在 componentWillUpdate() 時再重新動態載入才能確保 hot reload 正常運作,不然即使 HMR 正常運作畫面也不會有任何反應。

./src/js/home/route/route.js
./src/js/home/route/index.js

以首頁模組為例,定義 route 結構的部份還是使用原本的方法,只是在 export 時透過 asyncRoute() 封裝動態載入的功能。註解中的 webpackChunkName 是 webpack 為了 code splitting 特別加上的設計,用來指定動態載入的模組打包後的名稱。

支援動態載入後的首次使用
支援動態載入後的功能切換操作

從上面兩張圖可以觀察到 app.js 的檔案大小比起只有抽離第三方套件時從 306 KB 縮減到 176 KB,而各功能模組如 home、profile 也只在使用者選取時才被載入。再加上每個功能模組個別打包後可以更有效地使用快取,在 app 的載入速度上已經改善許多。


總結

透過抽離第三方套件與動態載入功能模組的設計,我們可以有效地利用快取加速我們的網路應用。以手邊一個中型網站為例:

第一次使用時載入檔案的大小與時間
不使用 code splitting:總共 658 KB,花費 4.25 秒
抽離第三方套件:總共 700 KB,花費 4.49 秒
抽離第三方套件 + 動態載入功能:總共 502 KB,花費 3.73 秒

更新功能後載入檔案的大小與時間
不使用 code splitting:總共 658 KB,花費 4.16 秒
抽離第三方套件:總共 306 KB,花費 2.28 秒
抽離第三方套件 + 動態載入功能:總共 17.8 KB,花費 0.68 秒

希望這篇文章能夠幫助大家理解 code splitting 的運作方式並應用於自己的專案中,如果有什麼疑問或是其他與主題相關的想法也歡迎大家留言互相交流。

Frochu

Frochu — Frontend Ochu ,程式碼的黑手,親手實作的前端知識推動者

Thanks to Amdis Liu.

Chia-Wei Li

Written by

Full stack and mobile developer

Frochu

Frochu

Frochu — Frontend Ochu ,程式碼的黑手,親手實作的前端知識推動者