avatar
threadsinstagram

用實驗來理解 Vite 的 dependency pre-bundling 是什麼

Table of Contents

前言

昨天在探討為什麼 Vite 在 dev server 的冷啟動這麼快時,目前我們只知道是因為有用上了瀏覽器的 native ESM 去省略打包的時間,但要做到這件事其實會需要處理蠻多問題,整個重點整理的話是利用以下幾點來實現:

  • 依賴模組的預構建 (dependency pre-bundling)
    • 模組化規範的兼容
    • 效能優化
  • 複寫模組路徑
  • 改使用 esbuild 做語法轉譯
  • 快取機制
    • 檔案系統快取
    • 瀏覽器快取

光看文字可能沒那麼好懂,或許學習最快的方式就是動手做,千萬不要像我一樣前面講了八天的歷史故事光說不練。今天就實際來做實驗吧!

先附上今天做實驗的程式碼初始模板供參考。

實驗 1. 載入第三方 ESM 套件

快速啟一個實驗專案:

$ mkdir esm-demo
$ cd esm-demo
$ pnpm init
$ pnpm add lodash-es

接著在根目錄上新增以下兩個檔案:

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ESM Demo</title>
  </head>
 
  <body>
    <script type="module" src="main.js"></script>
  </body>
</html>

main.js

import { has } from 'lodash-es';
 
console.log(has({ a: 1 }, 'a'));

啟用 live server

此時如果你是 VS Code 的使用者,你可以安裝 Live Server 這個擴充套件,這是方便我們在本機直接 host 我們的網頁起來的工具,安裝後你可以對剛剛建立好的 index.html 去點右鍵並選擇「Open with Live Server」:

live server setup

應該能自動打開瀏覽器畫面:

lab 1 result

當你打開 dev tool 時,會看到 console 上印出這個錯誤:

Uncaught TypeError: Failed to resolve module specifier "lodash-es". Relative references must start with either "/", "./", or "../".

解法

這是為什麼呢?這個錯誤訊息的意思是說無法解析到 lodash-es 這個檔案。當你想使用 native ESM 的寫法用 <script type=”module”> 的方式來載入你的 ESM 模組時,在載入模組時,瀏覽器需要絕對或相對路徑才能識別檔案,因此當你今天將上面 main.js 的引入路徑改成這樣就會正常了:

import { has } from './node_modules/lodash-es/lodash.js';

我們先記住這個結果,再來看另一個實驗。

實驗 2. 載入有大型依賴關係的第三方套件

觀察 request 數量

光看文字可能很難懂,延續上一個實驗,其實基本上已經做完了,當你上一步最後的 main.js 的內容是這個樣子的時候:

import { has } from './node_modules/lodash-es/lodash.js';
 
console.log(has({ a: 1 }, 'a'));

我們這裡試著用 named import 的方式引入了 has 這個模組,當你這時用 Live Server 去將網頁啟起來後,打開 dev tool 後重整網頁,去觀察 network 中的 JS 載入數量:

request waterfall

你會看到一大串的 JS request 如瀑布一樣載進來,總共載了 600 多個,載入完成後 console 才印出 true 這個結果。

觀察引入的模組原始碼

這是為什麼呢?如果你去看 node_modules/lodash-es/lodash.js 的這個檔案,會看到它是負責 export 其他子模組的主要入口檔。再追進去 has.js 這支檔案中,會看到裡面還引入了 _hasPath,而其中又會如下圖一樣層層引入其他模組:

hasPath

透過這個實驗可以發現,當今天是用 native ESM 的方式在瀏覽器上載入模組時,可能會因為用到的這個模組背後有上百、上千個依賴小模組而觸發過多的 request,造成頁面載入的卡頓,這反而會成為開發體驗上的另一個問題。而這也正是為何要做 dependency pre-bundling 的一個主因,一樣先記住這個結果,明天會再實際來看看 Vite 怎麼處理。

一個小發現

另外這邊有個有趣的發現,當你今天將 main.js 改成用 default import 直接載入 has 的話:

import has from './node_modules/lodash-es/has.js';

再去實驗看看 JS 模組的 request 數量,會從 600 多個降到剩下 60 多個,才知道原來原生的行為就算執行的檔案只有對其中的某一個模組做 named import,實際還是會去分析所有的模組依賴,看來 dev server 真的幫忙做了很多優化。

註:第二個實驗範例是參考高見龍在這個影片 demo 的靈感,選擇用 has 這個模組看起來就是為了感受一下大型依賴的感覺。

實驗 3. 載入第三方非 ESM 套件

安裝 lodash 套件

延續昨天的專案,今天我們來試著安裝一個輸出為 CommonJS 的套件:

$ pnpm add lodash

💡 你可能會問 lodashlodash-es 有什麼不同,前者模組輸出的方式為 CommonJS,而後者是 ESM。兩種都放在同一個 repo 裡用 branch 去區分不同的模組化標準輸出到 NPM 上,甚至其他 branch 中還有 UMD 與 AMD 版本。

如果今天考慮想去減小專案的 bundle size 時,通常都會談到需要使用 ESM 版本,這是因為 ESM 可以方便 bundler 在進行 tree-shaking 時做靜態分析去決定哪些用不到的部份可以被移掉,有興趣可以參考我之前寫過的這個系列文章

改寫並重啟 server

將原本的 main.js 引入的地方改成這樣:

// main.js
import has from './node_modules/lodash/has.js';

這時如果你重新啟動 Live Server 去看 console 後應該會看到這樣的錯誤:

Uncaught SyntaxError: The requested module './node_modules/lodash/has.js' does not provide an export named 'default'

錯誤原因

儘管今天相對路徑寫對了,從錯誤訊息你可以看到它說在這個模組中並沒有去輸出 has 這個方法,如果你到 node_modules/lodash/has.js 這個路徑下找到這支檔案,會看到它是 CommonJS 的輸出方式:

...
function has(object, path) {
  return object != null && hasPath(object, path, baseHas);
}
 
module.exports = has;

因此我們可以得到另一個問題是,當你想讓瀏覽器利用原生 ESM 去載入檔案時,這時如果有第三方套件是不同的模組化標準(CommonJS、UMD、AMD 等),就會需要做一些處理才能使用。

用 Vite 來確認前面幾個問題

啟一個 Vite 專案

看完了以上 3 個實驗中的問題,接下來我們實際來試試用 Vite 的效果如何。

先建立一個 vanilla 的 Vite 專案,可以直接使用這個 GitHub 初始模板,或在前面章節中有建立過的話是同一個步驟:

$ pnpm create vite hello-vite --template vanilla
$ cd hello-vite
$ pnpm install

接著我們來安裝剛剛的兩種不同模組的 lodash 的套件後,就可以來確認以下的幾個部份:

$ pnpm add lodash lodash-es

實驗一:套件路徑

前面我們提到純手工的做法時,會需要用相對路徑的方式來載入套件瀏覽器才能正確解析:

// main.js
import { has } from './node_modules/lodash-es/lodash.js';

但這會造成開發上的不方便,今天我們嘗試在 Vite 專案中這樣改寫:

// main.js
import { has } from 'lodash-es';
 
console.log(has({ a: 1 }, 'a'));

pnpm run dev 將 server 啟起來,打開 dev tool 觀察前面實驗的問題,會發現 console 上能正確印出 true,就代表 lodash/has 有正確被載入進來了。這樣我們認為很基本的開發需求,其實也是需要由 Vite 在背後做處理。

至於實際載進來的內容是什麼呢,這會跟下一個實驗有關。

實驗二:大型依賴中的 request 問題

在前面實驗二中發現當我們去載入 lodash/has 時,會因為背後有許多依賴的小模組造成 request 過多的問題。這邊我們實際打開 dev tool 的 network 清掉 log 後,重整來確認狀況:

request waterfall issue check

可以發現 request 從昨天的 60 多個減少到剩下 4 個,這是怎麼做到的呢?點開 main.js 來確認一下載進來的是什麼:

solve request waterfall issue

會發現這邊實際載入的內容是這樣的一個路徑:

import {has} from "/node_modules/.vite/deps/lodash-es.js?v=671bbcf6";

你會發現這並非預期中的 node_modules/lodash-es/lodash.js,那這個 .vite 底下的內容是什麼呢?

這正是我們的主角 —— dependency pre-bundle。但在詳細說明前,我們先看完最後一個實驗結果。

實驗三:載入 CommonJS 套件

將 Vite 專案中原本的 lodash-es 改成 lodash

// main.js
import { has } from 'lodash';
 
console.log(has({ a: 1 }, 'a'));

此時能正確在 console 中印出 true,代表 CommonJS 版本的第三方套件也能正常使用。再回到 dev tool 上觀察時,會看到原本的 CommonJS 版本的 lodash 被 pre-bundle 成這樣:

lodash pre-bundle

這裡會看到一個有趣的東西,其中的這個 chunk-BUSYA2B4.js,簡單理解的話就是個拿來在 ESM 環境中模擬 CommonJS 中 require 機制的模組:

var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) => function __require() {
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
 
export {
  __commonJS
};

從這裡我們可以得到另一個結論,就是在做 pre-bundle 過程中也會一併處理模組標準化為 ESM 的部分。

所以什麼是 pre-bundling?

參考文件,Vite 為了解決這些問題:

  • 第三方套件在做 ESM 載入時太多 request 的問題
  • 不同模組標準須統一為 ESM

會在啟用 dev server 前,將同一個套件的內容 pre-bundle 成單一個 ESM 模組,讓你在載入時只需要一個 request。而這個過程會交由打包速度更快的 esbuild 來處理,所以幾乎感覺不到冷啟動時的延遲。

如果你回到 VS Code 確認 node_modules/.vite/deps/lodash-es.js 這支檔案,可以看到原本的 lodash-es 中的所有模組會被包在這裡面,而 has 也在其中:

pre-bundle demo

快取策略

再多觀察一些,在載入 lodash-es.js 這個 pre-bundle 好的模組時,後面有帶上一個 v=671bbcf6 這樣的 hash,看起來是為了瀏覽器快取做的。我們可以試著將 dev tool 上「Disable cache」的選項勾掉後重整,此時再觀察一次會發現瀏覽器能自動抓取 cache 中的內容:

cache strategy

這是 Vite 做的一個快取的優化 (ref)。因為一般來說在開發時不會頻繁地更動套件的版本,所以帶上這樣的 hash 能更方便瀏覽器去決定是否觸發快取失效。

再深入了解一些,文件中提到 Vite 對於這些 pre-bundle 的 response header 是用 max-age=31536000,immutable 這樣的快取策略,這是什麼意思呢?這裡也筆記一下:

  • max-age=31536000:指的是這個 cache 保存期限為一年
  • immutable:當資源被標記為 immutable (不可變) 時,代表保證在保存期限內都不會更動,以此來減少瀏覽器對 dev server 不必要的 request
  • 另外參考 MDN 說明,對於靜態資源,最佳實踐是採用一種叫做 cache-busting pattern。簡單理解的話就是因為前面用 immutable 告訴瀏覽器這檔案效期內永久有效,但如果在一年內反悔了想更改,可以用換 hash 的方式來觸發原本的快取失效。

而文件中也提到,如果今天想要強制觸發 pre-bundle,有兩種方式:

  • 砍掉 node_modules/.vite
  • 在啟 dev server 的指令這樣改寫:"dev": "vite --force"

小結

這篇從三個實驗搭配實際使用 Vite 的效果,我們能由淺入深理解為什麼 Vite 會需要做 dependency pre-bundling,整理一下重點:

  • 複寫模組路徑
  • request 效能優化
  • 模組化規範的兼容

而除了解決以上問題之外,Vite 還幫忙做了像是快取優化、使用更快的打包工具 esbuild 來協助 pre-bundle,來加速冷啟動的效率。

第一次學習這部份如果有什麼看不懂或是講錯的部份也歡迎留言討論,感謝您的閱讀!

參考資源