先補充一個昨天的實驗的結果,後來重複測了幾遍後,發現影響冷啟動時間可能跟有沒有整個刪除 node_modules
有關。當有完整刪除後,就能穩定在第一次冷啟動時 pre-bundle 大約 4~6
秒。此時如果將在 LeafComponent.vue
中多個複雜的第三方依賴都拿掉,就會降低到大概 2
秒。
也就是當今天在一個 Vite 專案中套件越裝越多時,第一次執行 npm install
後去啟 dev server 時,這次的冷啟動確實是會比較慢的,跟一開始的想像是相符的。但這樣的成本也就只有第一次冷啟動會遇到,之後因為有已經 pre-bunde 完的快取資料,所以都會變快許多,幾乎是少於 1
秒。也算是印證了這個特性在開發體驗上的一個革命性的提升。
今天繼續來把剩下的 pre-bundling 程式碼中的 runOptimizeDeps
追完,來看看最後一哩路的 esbuild 做了什麼事,也順便研究一下 esbuild 怎麼用。
先附上這段程式碼的結構做個前情提要:
在實際看程式碼之前可以先看文字版的邏輯會更清晰:
.vite/deps_temp_0c36db7c
之類的,避免直接去修改原本建好的目錄 .vite/dep
)successfulResult
cancelledResult
prepareEsbuildOptimizerRun
中準備 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 快取目錄:
當今天實際用瀏覽器載入 local 頁面時,在網頁實際開始載入 pre-bundling 的內容,此時就會變回 .vite/deps
並建出 _metadata.json
:
但至於為什麼在實際載入後才會出現 metadata 檔案,我就沒再深追下去,這邊只是想試試看在 runOptimizeDeps
這個函式在執行過程中有做了什麼處理,有興趣的讀者可以再追追看。
這邊我對 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 設定做了什麼:
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 原始碼做個總整理,我們明天見!
文章同步發表於個人部落格