iT邦幫忙

2021 iThome 鐵人賽

DAY 14
0
Modern Web

今晚,我想來點 Web 前端效能優化大補帖!系列 第 14

Day014 X Code Splitting & Dynamic Import

Code Splitting 是一個非常重要的觀念,現代網頁程式漸漸走向使用框架以模組化方式來開發,通常會透過如 webpack 等 bundler 來 uglify、minimize、打包程式碼,如果沒有另外為程式碼做一些設置,那產生出來的檔案通常會是一個 bundle.js 或其他名字的 JavaScript 檔案(開發者可以自己設定),當專案成長到一定程度時,程式的 bundle size 會變得過於肥大,導致 client side 的網頁載入時間變長,嚴重影響使用者體驗。

首先來看看 Code Splitting 的介紹:

Code Splitting 就是為了要解決單一 JS Bundle 過於肥大的問題,將原本單一的 bundle 切分成數個小 chunk,可以搭配平行載入,或者是有需要時才載入某些特定的 chink,又或是對一些不常變動的 chunk 個別做快取,來達到載入效能的優化。

Wait a minute...「有需要時才載入」,這句話怎麼好像在哪裡聽過...?啊!想起來了,就是在 Day 11 介紹 Lazy Loading 概念的時候提過的嘛!

的確,今天要介紹的技術也是 Lazy Loading 的一種實現, code splitting 就是為了解決 compile 後的 bundle size 過於肥大的解決方案。以現代元件化開發來說,當使用者訪問 A 頁面時,理應要載入 A 頁面會用到的 component bundle 給他,但他未來可能不會造訪 B 頁面與 C 頁面,所以其實沒有必要在他造訪 A 頁面時就同時也載入 B 頁面與 C 頁面的 bundle。

所以說我們希望透過 Lazy Loading 的技術,等到使用者真的要造訪某個頁面了,再載入該頁面的對應資源,然而前面也提到,bundler 預設會把所有的程式碼都打包成一份 JavaScript 檔案,那該怎麼做到「使用到再載入」這件事呢?這時候就得靠 Code Splitting 把各個區塊的程式碼在打包的時候切成獨立的 JavaScript 檔案,就可以做到的程式碼的 lazy loading 了!

今天會介紹兩種常見的 Code Splitting 技巧:

  • 抽離第三方套件
  • 動態載入功能模組 Dynamic Import

並在最後附上一個自己覺得蠻有趣的 demo 來實際體驗看看 code splitting 的神奇魔力。

(今天的範例與 demo 都會使用 webpack 這個 bundler,雖然不同 bundler 操作方式會有些許不同,但概念上都是一樣的)

Webpack Bundle Analyzer

在開始之前先介紹一下 Webpack Bundle Analyzer 這個 plugin,有了它,我們可以透過視覺化分析專案有哪些 bundle chunk,各個 bundle chunk 的組成又為何,再針對可以改進的 bundle 進行優化,在等等的 demo 也會透過這個 plugins 來觀察 bundle 的狀況。

抽離第三方套件

抽離第三方套件又可以細分兩種方式:

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

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

關於 webpack 的 bundle,可以先做一個最大的拆分:

  • Application Bundle:UI 與商業邏輯,跟我們寫的程式有關,是經常變動的部分。
  • Vendor Bundle:第三⽅套件 / node_modules,不太會變動。

拆分出 Vendor Bundle 是有好處的,主要是因為通常它變動的頻率相對較低,因此比較適合被 cache,而在 Vendor Bundle 被 cache 的狀況下由於減少了 Application Bundle 的⼤⼩,因此加快了再訪者的載入速度。採用這樣的方式的優點為邏輯簡單,缺點為更新任何第三方套件都會使快取失效。
(關於瀏覽器快取,會在日後的篇章介紹)

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

採用這種方式的優點是可以根據套件關聯性打包,減少套件更新時造成的延遲。缺點則是相較前面打包成單一檔案的方式,這種方式需要處理的邏輯複雜許多。

透過 webpack 的 CommonsChunkPlugin 實作類似下圖的 config 可以達成這個效果

動態載入功能模組 Dynamic Import

大多數狀態下我們會在檔案的開頭引入需要用到的模組,這些模組通常在網頁載入時就被引入進來,這種方式被稱為 static import,然而當有以下兩種狀況的需求時,static import 卻不能滿足我們:

  • 模組名稱為動態變數時
  • 需依照特定邏輯或特定時機引入時

這時候可以運用與之相對的技術: Dynamic Import。所謂 Dynamic Import 代表的即是

需要用到某段程式碼時才透過網路載入 JS bundle

要實現 Dynamic Import 需要靠 ESM import 語法:

例如上圖我們在 getComponent 這個函式中 import lodash 這個 package,只有當 getComponent 被呼叫時 lodash 才會被當成另外一個 chunk 載入。

目前瀏覽器的支援度也還算不錯,我們也可以透過 webpack 等打包工具來幫助我們實現 Dynamic Import。

了解了 Dynamic Import 的概念,接下來來談談 Dynamic Import 的使用情境,今天主要會介紹兩種情境:

  • 根據路徑做 Dynamic Import
  • 針對肥大套件做 Dynamic Import

根據路徑做 Dynamic Import

根據 GA 等分析工具長期分析後的數據指出,大部分的使用者只會停留在網站中的幾個熱門頁面,如果採用 Client-Side-Rendering 的方式建置網站的話,在沒有對 bundle 做額外處理的狀況下會在一開始載入 JS bundle 時就載入許多頁面的資源,這樣會導致許多不太會被使用者瀏覽的頁面是很有機會被載入卻又沒被使用的。這時候我們可以選擇針對路徑做 Dynamic Import,當切換到特定路徑時再載入該路徑會用到的資源。因為筆者擅長 React,所以就以 React 中的著名路由套件 react-router 搭配 React.lazy 來舉例:

造訪 / 時,Home component 將會被載入,而造訪 /about 頁面時則是 About Component 會被載入,這也就是基於 Route 的 Code-Splitting。

(當然 Component 的 code splitting 也不一定只能做 route based 的,開發者可以自己視情況對 component 做 Dynamic Import,例如 React.Lazy 就是為此而存在,不過 React.Lazy 目前還無法在 SSR 使用,如果使用 SSR 建構專案的讀者可以參考 Loadable Components

針對肥大套件做 Dynamic Import

除了針對路徑做 code splitting 以外,另一種常見的方式就是針對「肥大卻又不會馬上用到」的模組做 Dynamic Import,這時前面介紹過的 Webpack Bundle Analyzer 就展現價值了,有了視覺化的報表,開發者可以依據圖表判斷是不是有過於肥大的套件適合做 Dynamic Import。

之前工作上遇到一個例子是為了支援 Live Streaming 的 feature,必須使用 hls.js 這個 module,不過從圖片中可以看到它真的很肥很肥,剛好他是在一個特定頁面才會需要使用,所以就做了 dynamic import,在訪問該特定頁面時才會去載入這個 module 的 bundle,大幅減少了一開始載入的 bundle size。

如果用 react 的話大概長這個樣子:

// 某個需要影音串流功能的特定頁面
useEffect(() => {
  async loadHls() {
    await import('hls.js');
  }

  loadHls();
}, [])

有趣的 Demo Time !

我自己覺得今天的 Demo 還蠻有趣的(可能只有我自己這樣覺得QQ)
今天講了那麼多理論,不實際操作一下有點沒辦法對讀者交代呀!那我們就馬上開始吧!
(恩對,我又選擇了用 react + webpack 來做示範,但我想今天的觀念應該挺好懂的,就算是習慣其他框架的讀者也請安心服用)

身為一個從國小就開始看球,到現在也還是把看球賽當作主要興趣的資深 NBA 球迷,連要準備 demo 的時候也想到用 NBA 球員來當作主題應該是合情合理的吧 ?

每一個球員都有自己擅長的籃球技巧

LBJ: TwoPointShotting Passing Dunk

Curry: TwoPointShotting ThreePointShotting

KD: TwoPointShotting Dunk

今天要做一個簡單的網站,展示 Stephen Curry、Lebron James、Kevin Durant 三名籃球巨星分別擅長哪些籃球技能,成果如下:

可以看到 URL 的變化,每一個球員有自己的一個 path。咦!?這樣是不是可以實作上面提到的「根據路徑的 Dynamic Import」?沒有錯!讓我們先簡單看一下程式架構。

路由方面了使用 React Router

除了每一個球員獨立成一個 component 以外,每一個 skill 也獨立成了一個 component

球員的頁面只會載入擅長的技巧,例如說 Lebron James 擅長投兩分球、傳球跟灌籃,那麼 Lbj.js 這個 component 就會長這個樣子

目前還沒有做任何優化,webpack 會把 source code 全部打包成一個檔案,此時的 webpack-bundler-analyzer 看起來是這個樣子

Step 1. 抽離第三方套件

剛剛有提過關於抽離第三方套件有「將所有第三方套件打包為單一檔案」與「拆分為多個檔案」兩種方式,因為這個 demo 非常簡易單純,於是選擇打包為單一檔案就可以了。

既然幫忙打包的是 webpack,想當然需要去提整一些 webpack 的設定,打開 webpack.config.js 然後加入以下的 config:

optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
        },
      },
    },
  },

webpack 會將 node_modules 路徑下的檔案額外打包成名叫 vendors.js 的 bundle。

回到 webpack-bundle-analyzer 就會看到總共產生兩個 bundle,vendor.js 是第三方套件的 module(vendor bundle),main.js 則是關於我們撰寫的程式碼的 bundle (application bundle)。

從上圖左側可以看到 vendor bundle 相比 application bundle 肥蠻多的,相較於 application bundle,這些第三方套件的 bundle 是比較不會經常變動的,獨立出單獨的 bundle 後可以被瀏覽器 cache,提升再次訪問頁面的速度。

Step 2. 根據路徑做 dynamic import

既然現在有三個頁面:

  • /lbj
  • /curry
  • /kd

那麼就可以針對這三個頁面來做 dynamic import
(其實現實上不用針對所有頁面做 dynamic import,如果有些頁面是大部分使用者高機率會造訪的,就不必做 dynamic import。今天是為了方便 demo,實際在開發時要考慮到專案本身的狀況喔!)

在 Route 外額外包一層 React.Suspense component,讓 component 在動態載入時有 fallback 的畫面,不至於讓使用者看到一片空白。

回到 webpack bundle analyzer 看看現在 bundle 的狀況

可以注意到 application bundle 的 size 變小了,然後多出了 LBJ.js、Curry.js、KD.js 三個針對頁面的 bundle。

回到頁面看看是不是真的造訪特定頁面時才會載入該頁面拆出來的 bundle。

成功啦!

Step.3 拆分出共用的 chunk

我覺得,我們的 demo 還可以再變得更好!

身為聯盟的巨星,又不是常駐禁區的中鋒,LBJ、Curry、KD 肯定都要會投中距離兩分球的,所以從上面的 analyzer 看到 LBJ、Curry、Kd 三個 bundle 都有 TwoPointsShotting 這個 component,明明三個頁面都會用到,卻在每次載入特定頁面 bundle 時都會重新載入,有點浪費啊!

我們可以將在許多 bundle 中共用的 chunk 再獨立出一個 bundle,回到 webpack 的設定

optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
        },
        // 加上這些
        default: {
          name: 'default',
          minChunks: 2,
          reuseExistingChunk: true,
          enforce: true,
          priority: -20,
        },
      },
    },
  },

在 config 中我們指定只要是至少在 2 個 bundle 中會用到的 chunk 就把它們另外打包成叫做 default.js 的 bundle。

再看看 bundle analyzer

看起來好多了,不過聰明的你應該會注意到一個問題:

因為 LBJ 跟 KD 都會 Dunk,所以 Dunk 被包進了 default common bundle 裡,但是 Curry 並不會 Dunk 啊,如果使用者只造訪了 /curry 頁面,會需要載入根本用不到的 Dunk component。

那怎麼解決呢?我們可以把在 LBJ 與 KD component 用到的 Dunk component 改成 dynamic import 的形式(在 dynamic import 的 component 再 dynamic import 其他 component)

Dunk 就被獨立成一個 chunk 了

可以看到在造訪 /curry 頁面時就不會載入 Dunk.js 的 chunk。

最後附上今天的 Demo 的 Source Code:https://github.com/kylemocode/it-ironman-2021/tree/master/code-splitting-demo

雖然今天這個簡單的 demo 可能看不出來 code splitting 對效能有什麼影響,因為每個 component 都只由文字組成,檔案都很小。不過當開發一個複雜且龐大的專案時,code splitting 可以帶來不少效能的增長喔!

希望這個 demo 對你來說也是有趣又好懂(NBA 球迷請不要跟我反應說哪個球員明明還會什麼技能,只是個 demo 別太認真 ?)

本日小結

Code Splitting 與 Dynamic Import 是開發一個大型專案不可或缺的技術,現實中應該很難看到一個龐大的專案還是暴力的 bundle 成單一檔案的,就算真的有,我想它的頁面載入速度應該是慢的很可怕的...。

明天終於要進行到鐵人賽的一半啦!說實在我真的快要累死了QQ 如果我的系列文對你有幫助,可以留言給我一點回饋,也許我會更有寫下去的動力 ?

按照慣例預告一下,明天將介紹 Tree Shaking 這個優化技巧,See You Tomorrow !

References

https://github.com/rwieruch/minimal-react-webpack-babel-setup
https://webpack.js.org/guides/code-splitting/
https://medium.com/frochu/%E6%B7%BA%E8%AB%87%E5%A4%A7%E5%9E%8B-react-%E5%B0%88%E6%A1%88%E7%9A%84-code-splitting-8a258a13ac67


上一篇
Day13 X CSS GPU Acceleration
下一篇
Day15 X Tree Shaking
系列文
今晚,我想來點 Web 前端效能優化大補帖!30

尚未有邦友留言

立即登入留言