avatar
threadsinstagram

Vite 原始碼 (4) - pre-bundling 最終章

Table of Contents

前言

先補充一個昨天的實驗的結果,後來重複測了幾遍後,發現影響冷啟動時間可能跟有沒有整個刪除 node_modules 有關。當有完整刪除後,就能穩定在第一次冷啟動時 pre-bundle 大約 4~6 秒。此時如果將在 LeafComponent.vue 中多個複雜的第三方依賴都拿掉,就會降低到大概 2 秒。

也就是當今天在一個 Vite 專案中套件越裝越多時,第一次執行 npm install 後去啟 dev server 時,這次的冷啟動確實是會比較慢的,跟一開始的想像是相符的。但這樣的成本也就只有第一次冷啟動會遇到,之後因為有已經 pre-bunde 完的快取資料,所以都會變快許多,幾乎是少於 1 秒。也算是印證了這個特性在開發體驗上的一個革命性的提升。

今天繼續來把剩下的 pre-bundling 程式碼中的 runOptimizeDeps 追完,來看看最後一哩路的 esbuild 做了什麼事,也順便研究一下 esbuild 怎麼用。

先附上這段程式碼的結構做個前情提要:

prebundle map

實際利用 esbuild 進行 pre-bundle

在實際看程式碼之前可以先看文字版的邏輯會更清晰:

  • 建立一個暫時用的快取目錄 (像是 .vite/deps_temp_0c36db7c 之類的,避免直接去修改原本建好的目錄 .vite/dep)
  • 建立新版的 metadata 檔案,裡面會放每個 pre-bundle 套件的快取 hash 值的對應
  • 準備 esbuild 執行 pre-bundle 成功或錯誤時的能執行的 callback 物件:
    • successfulResult
      1. 將新的內容寫入 metadata
      2. 將暫時的快取目錄的打包後的內容搬到原本的快取目錄
      3. 將原本的快取目錄改名帶上新的 hash
      4. 刪除暫時的快取目錄
    • cancelledResult
      1. 刪除暫時的快取目錄
  • prepareEsbuildOptimizerRun 中準備 esbuild 執行 pre-bundling 的設定與實例
  • 實際用 esbuild 來執行 pre-bundling
  • 回傳執行結果

有了上面的概念之後,看到 runOptimizeDeps 這個函式,原始碼有兩三百行,以下為簡化過並附上追原始碼過程中的註解,為了方便閱讀切成多段:

// - 建立暫時用快取目錄
// - 建立新版的 metadata 檔案
export function runOptimizeDeps(
  environment: Environment,
  depsInfo: Record<string, OptimizedDepInfo>,
): {
  cancel: () => Promise<void>
  result: Promise<DepOptimizationResult>
} {
  // 取得原本的快取目錄 `.vite/deps`
  const depsCacheDir = getDepsCacheDir(environment)
 
  // 為了避免直接去修改原本的快取目錄
  // 另外建立一個暫時的快取目錄類似 `.vite/deps_temp_0c36db7c`
  const processingCacheDir = getProcessingDepsCacheDir(environment)
  fs.mkdirSync(processingCacheDir, { recursive: true })
 
  // 建立新的 metadata 資訊與 hash
  const metadata = initDepsOptimizerMetadata(environment)
 
  metadata.browserHash = getOptimizedBrowserHash(
    metadata.hash,
    depsFromOptimizedDepInfo(depsInfo),
  )
 
  // ... 下續 ...
}
// - 準備 esbuild 執行 pre-bundle 成功或錯誤時的能執行的 callback 物件
 
// 在打包完或遇到錯誤後,需要清掉暫時建立的快取目錄
const cleanUp = () => {...}
 
// pre-bundle 執行成功後的處理
// 在其中的 commit 中會做一些事情:
// 1. 將新的內容寫入 metadata
// 2. 將暫時的快取目錄的打包後的內容搬到原本的快取目錄
// 3. 將原本的快取目錄改名帶上新的 hash
// 4. 刪除暫時的快取目錄
const successfulResult: DepOptimizationResult = {
  metadata,
  cancel: cleanUp,
  commit: async () => {/* ... 如上述 ... */},
}
 
// 遇到錯誤或觸發了取消 pre-bundle 的處理
const cancelledResult: DepOptimizationResult = {
  metadata,
  commit: async () => cleanUp(),
  cancel: cleanUp,
}
// 紀錄 pre-bundle 開始的時間供後面印出執行時間
const start = performance.now()
 
// 準備 esbuild 打包的設定與實例
const preparedRun = prepareEsbuildOptimizerRun(...)
// 實際用 esbuild 來執行 pre-bundling
const runResult = preparedRun.then(({ context, idToExports }) => {
  // 釋放 esbuild 的記憶體
  function disposeContext() {...}
 
  // 如果 esbuild 實例不存在或觸發取消 pre-bundle 的處理
  if (!context || optimizerContext.cancelled) {
    disposeContext()
    return cancelledResult
  }
 
  // 執行 pre-bundle 打包
  return context
    .rebuild()
    .then((result) => {
      const meta = result.metafile!
 
      // 將每個套件打包後的內容寫入 metadata
      for (const id in depsInfo) {
        const output = esbuildOutputFromId(
          meta.outputs,
          id,
          processingCacheDir,
        )
 
        const { exportsData, ...info } = depsInfo[id]
        addOptimizedDepInfo(metadata, 'optimized', {
          ...info,
          fileHash: getHash(...),
          browserHash: metadata.browserHash,
          ...
        })
      }
 
      // 印出 pre-bundle 的執行時間
      debug?.(
        `Dependencies bundled in ${(performance.now() - start)ms}`,
      )
 
      // 回傳打包成功的結果
      return successfulResult
    })
 
    .catch((e) => {...})
    .finally(() => {
      // 釋放 esbuild 執行的記憶體
      return disposeContext()
    })
})
// 回傳 pre-bundle 的結果
return {
  async cancel() {
    optimizerContext.cancelled = true
    const { context } = await preparedRun
    await context?.cancel()
    cleanUp()
  },
  result: runResult,
}

光看程式碼可能比較無感,如果實際上拿昨天這個實驗專案去重做 pre-bundle,當執行了 pnpm run dev 後可以看到結果像是下圖這樣,此時會建出暫時的 pre-bundling 快取目錄:

demo-1

當今天實際用瀏覽器載入 local 頁面時,在網頁實際開始載入 pre-bundling 的內容,此時就會變回 .vite/deps 並建出 _metadata.json

demo-2

但至於為什麼在實際載入後才會出現 metadata 檔案,我就沒再深追下去,這邊只是想試試看在 runOptimizeDeps 這個函式在執行過程中有做了什麼處理,有興趣的讀者可以再追追看。

再稍微看一下 esbuild 怎麼被設定的

套件路徑扁平化處理

這邊我對 esbuild 的處理比較好奇,所以再往 prepareEsbuildOptimizerRun 追進去看在做什麼:

async function prepareEsbuildOptimizerRun(...): Promise<{
  context?: BuildContext
  idToExports: Record<string, ExportsData>
}> {
  // 將套件路徑打平的註解,見下面說明
  const flatIdDeps: Record<string, string> = {}
  const idToExports: Record<string, ExportsData> = {}
 
  // 處理客製化 esbuild 的設定與 plugins
  const { optimizeDeps } = environment.config.dev
  const { plugins: pluginsFromConfig = [], ...esbuildOptions } =
    optimizeDeps?.esbuildOptions ?? {}
 
  // 處理所有套件的路徑
  await Promise.all(
    Object.keys(depsInfo).map(async (id) => {
      // ... 略 ...
 
      // 將每個套件的巢狀路徑打平
      const flatId = flattenId(id)
      flatIdDeps[flatId] = src
      idToExports[id] = exportsData
    }),
  )
 
  // ... 略 ...
}

這邊先看一下最開頭的這段,除了能在這裡處理客製化設定外,有一段關於將套件路徑做處理壓平的邏輯。舉個實際的例子,假如今天在一個 Vite 專案中去導入這樣的套件:

import has from 'lodash-es/has.js';
import Button from 'primevue/button';

這裡在經過執行 pre-bundle 中的 flattenId 這個函式後,會將這個路徑解析成這樣子:

lodash-es_has__js.js
primevue_button.js

至於為什麼要做這樣的處理,在原始碼中的註解有提到,原本 esbuild 在打包時會根據套件路徑去生成巢狀結構的目錄 (ref)。舉例像是原本有這樣的結構:

someProject/
└── node_modules/
    ├── lodash-es/
   └── has.js
    └── primevue/
        └── button/
            └── index.js

經過 esbuild 打包後,它預設會找到共同的 root 目錄做保留並輸出:

dist/
├── node_modules/
   ├── lodash-es/
   └── has.js
   └── primevue/
       └── button/
           └── index.js

但這樣的結構可能會導致在解析套件時效率比較差,所以 Vite 選擇在這裡去打平路徑結構變成這樣,更容易進行依賴分析和優化:

dist/
├── lodash-es_has__js.js
└── primevue_button.js

esbuild 設定

最後再來看一下其中的 esbuild 設定做了什麼:

const context = await esbuild.context({
  absWorkingDir: process.cwd(),
 
  // 套用剛剛壓平的套件路徑們做為進入點
  entryPoints: Object.keys(flatIdDeps),
 
  // 是否要將整個套件打包在一起
  bundle: true,
 
  // 打包內容預計被用在 Node 或是瀏覽器
  platform: environment.config.webCompatible ? 'browser' : 'node',
  define,
 
  // pre-bundle 成 ESM 模組
  format: 'esm',
  target: ESBUILD_MODULES_TARGET,
 
  // 對應到 Vite 設定中的 optimizeDeps.exclude
  external,
  logLevel: 'error',
 
  // 開啟 code splitting 功能
  splitting: true,
  sourcemap: true,
 
  // 輸出到暫時的快取目錄
  outdir: processingCacheDir,
  ignoreAnnotations: true,
  metafile: true,
  plugins,
  charset: 'utf8',
 
  // Vite 設定中的 optimizeDeps.esbuildOptions
  ...esbuildOptions,
  supported: {
    ...defaultEsbuildSupported,
    ...esbuildOptions.supported,
  },
})

如果有做過一些 Webpack、Rollup 等 bundler 的設定,對上面這段應該會感覺蠻熟悉的。就像最前面章節中在簡介 Webpack 的範例一樣,esbuild 也是一個 bundler,會需要設定打包的進入點 entryPoints,與輸出位置 outdir

而其他也有提供一些 options 像是轉譯成什麼樣的 JS 模組標準的 format、預計使用環境的 platform 等等,都可以在 esbuild 文件上查到相關定義。

而這裡我比較感興趣的是關於 splitting 這個設定,看到文件中有寫到不完全支援的問題,明天再來繼續了解一下怎麼回事。

小結

今天看完了整個 pre-bundling 的原始碼邏輯,明天會再來把 esbuild 中的 code splitting 問題做個實驗,會再來對這一系列嘗試追 Vite 原始碼做個總整理,我們明天見!

延伸閱讀