先補充一個昨天的實驗 的結果,後來重複測了幾遍後,發現影響冷啟動時間可能跟有沒有整個刪除 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
) 建立新版的 metadata 檔案,裡面會放每個 pre-bundle 套件的快取 hash 值的對應 準備 esbuild 執行 pre-bundle 成功或錯誤時的能執行的 callback 物件:successfulResult
將新的內容寫入 metadata 將暫時的快取目錄的打包後的內容搬到原本的快取目錄 將原本的快取目錄改名帶上新的 hash 刪除暫時的快取目錄 cancelledResult
刪除暫時的快取目錄 在 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 快取目錄:
當今天實際用瀏覽器載入 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 原始碼做個總整理,我們明天見!