iT邦幫忙

2024 iThome 鐵人賽

DAY 14
0
Modern Web

Rust 的戰國時代:探索網頁前端工具的前世今生系列 第 14

Day 14:來試著追一下 Vite 原始碼 (3) - pre-bundling 怎麼做套件分析

  • 分享至 

  • xImage
  •  

day14

前言

昨天追到 pre-bundling 的入口 createDepsOptimizer 裡的 init 有三個關鍵函式,今天繼續來往下追,在開始前先複習一下簡化過的入口邏輯結構:

// packages/vite/src/node/optimizer/optimizer.ts
export function createDepsOptimizer(...) {
  let inited = false

  async function init() {
    if (inited) return
    inited = true

    // 根據 metadata 載入之前 pre-bundle 過的快取資料
    const cachedMetadata = await loadCachedDepOptimizationMetadata(environment)

    // 快取資料不存在則執行
    if (!cachedMetadata) {
      // 在設定中開啟 optimizeDeps.noDiscovery
      if (noDiscovery) {
        // ... 略 ...
      } else {
        // 掃描專案中的依賴套件
        discover = discoverProjectDependencies(
          devToScanEnvironment(environment),
        )
        // 利用 esbuild 執行 pre-bundling
        optimizationResult = runOptimizeDeps(environment, knownDeps)
      }
    }
  }
}

這裡先將以上的邏輯整理成下面這張圖更清楚:

createDepsOptimizer

檢查 pre-bundle 過的快取資料

解析 loadCachedDepOptimizationMetadata 這個函式如程式碼中註解,這段基本上就是文件上關於檔案系統快取的實現,最後返回 pre-bundle 過的快取資料:

export async function loadCachedDepOptimizationMetadata(...) {
  // ... 略 ...

  // 取得 node_modules/.vite/deps 這個路徑
  const depsCacheDir = getDepsCacheDir(environment)

  // 是否有設定 optimizeDeps.force 或 --force
  // 有的話每次強制重新 pre-bundle
  if (!force) {
    let cachedMetadata
    try {
      // 取得並解析 _metadata.json 這個檔案
      const cachedMetadataPath = path.join(...)
      cachedMetadata = parseDepsOptimizerMetadata(
        await fsp.readFile(cachedMetadataPath, 'utf-8'),
        depsCacheDir,
      )
    } catch {}
    if (cachedMetadata) {
      // 比較 lockfile、config 中的 hash 引出對應訊息
    }
  } else {
    environment.logger.info('Forced re-optimization of dependencies')
  }

  // 刪除快取資料夾
  await fsp.rm(depsCacheDir, { recursive: true, force: true })
}

掃描專案中的依賴套件

當快取資料不存在時,會繼續進到 discoverProjectDependencies 這個函式來掃描專案中有用到哪些依賴套件:

export function discoverProjectDependencies(environment: ScanEnvironment): {
  cancel: () => Promise<void>
  result: Promise<Record<string, string>>
} {
  // 利用 esbuild 快速掃瞄找出需要做 pre-bundle 的依賴
  const { cancel, result } = scanImports(environment)

  return {
    cancel,
    result: result.then(({ deps, missing }) => {
      // 無法解析的套件會在這裡提供錯誤訊息
      const missingIds = Object.keys(missing)
      if (missingIds.length) {
        throw new Error(
          `The following dependencies are imported but could not be resolved:\n\n  ${missingIds...}\n\nAre they installed?`,
        )
      }

      return deps
    }),
  }
}

而其中看起來真正做事的會是 scanImports 這個函式,再往裡面追的話可以看到:

export function scanImports(environment: ScanEnvironment): {
  cancel: () => Promise<void>
  result: Promise<{...}>
} {
  // ... 初始化參數略 ...

  // 根據 rollupOptions.input 進入點去分析需掃描的套件
  const esbuildContext = computeEntries(
    environment,
  ).then((computedEntries) => {
    // ... 略 ...

    // 往這個函式追進去可以看到關於 esbuild 的初始化設定
    return prepareEsbuildScanner(...)
  })

  const result = esbuildContext
    .then((context) => {
      // 利用 esbuild 來解析套件的依賴關係
      return context
        .rebuild()
        .then(() => {...})
        .finally(() => {
          // 用來釋放執行 esbuild 的記憶體
          return disposeContext()
        })
    })
  // ... 略 ...
}

原本以為實際上用到 esbuild 來執行 pre-bundle 的地方應該會是在更後面的 runOptimizeDeps 裡,不是看很懂為什麼這邊會用 esbuild 來做 rebuild,是真的有打包出什麼內容嗎?

再繼續往 prepareEsbuildScanner 這個地方追進去會發現一個關鍵點:

async function prepareEsbuildScanner(...) {

  // 看起來好像有針對掃描檔案另外做許多邏輯處理的 plugin
  const plugin = esbuildScanPlugin(environment, deps, missing, entries)

  // ... 針對 tsconfig 的處理略 ...

  return await esbuild.context({
    absWorkingDir: process.cwd(),

    // 關鍵設定:會執行解析但不生成檔案
    write: false,
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    logLevel: 'silent',
    plugins: [...plugins, plugin],
    ...esbuildOptions,
    tsconfigRaw,
  })
}

看到這裡在初始化 esbuild 設定檔的地方應該是關鍵,於是好奇查了下 esbuild 的文件找到關於這個 write 的意思:

The build API call can either write to the file system directly or return the files that would have been written as in-memory buffers

翻成白話文的話應該是說這個 write 的設定會決定 build API 如何輸出打包後的內容,如果設定成 false 的話就會有點類似 dry run 的方式把結果寫進記憶體緩衝區中。所以可以整理一下這兩處使用 esbuild 情境的差異:

  • scanImports:利用 esbuild 去做 dry run,快速識別專案中的套件依賴關係
  • runOptimizeDeps:利用 esbuild 實際執行 pre-bundling

先劇透下如果往 runOptimizeDeps 裡面追進去會看到其中有一段 prepareEsbuildOptimizerRun 在準備 esbuild 設定 (ref),其中就沒有用到 write 這個屬性,因此會真的去打包出東西來。

小結

今天重新釐清了一下從 pre-bundling 進入點的綜觀邏輯,並一路追到其中在掃描整個專案中套件依賴的原理,比較有趣的是找到 esbuild 其中有一個 write 設定可以拿來控制類似你這次的行為是想要掃描檔案還是要做打包,算是跟 esbuild 文件有了第一次的交集,明天會繼續把剩下的 runOptimizeDeps 追完,再做個總整理。

另外也試著利用 Excalidraw 把目前追原始碼的地圖給整理了出來:

vite_map

實際用了這工具後,也發現裡面還有 beta 版 AI 功能,可以用 prompt 可能像是程式碼、條列筆記等,請它快速初始化流程圖草稿,不用真的從頭拉起方便許多,雖然免費版每天有次數限制就是了,也推薦給大家:
Excalidraw

文章同步發表於個人部落格


上一篇
Day 13:來試著追一下 Vite 原始碼 (2) - Chokidar、pre-bundling 初探
下一篇
Day 15:來做個實驗 - 壓測 Vite 的效能瓶頸
系列文
Rust 的戰國時代:探索網頁前端工具的前世今生30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言