前段時間,Vite 作了一個優化依賴預編譯(Dependency Pre-Bundling)。簡而言之,它指的是 Vite 會在 DevServer 啓動前對須要預編譯的依賴進行編譯,而後在分析模塊的導入(import)時會動態地應用編譯過的依賴。javascript
這麼一說,我想你們可能立馬會拋出一個疑問:Vite 不是 No Bundle 嗎?確實 Vite 是 No Bundle,可是依賴預編譯並非意味着 Vite 要走向 Bundle,咱們不要急着下定義,由於它的存在必然是有着其實際的價值。前端
那麼,今天本文將會圍繞如下 3 點來和你們一塊兒從疑問點出發,深刻淺出一番 Vite 的依賴預編譯過程:vue
當你在項目中引用了 vue
和 lodash-es
,那麼你在啓動 Vite 的時候,你會在終端看到這樣的輸出內容:java
而這表示 Vite 將你在項目中引入的 vue
和 lodash-es
進行了依賴預編譯!這裏,咱們經過大白話認識一下 Vite 的依賴預編譯:node
dependencies
的部分啓用依賴預編譯,即會先對該依賴進行編譯,而後將編譯後的文件緩存在內存中(node_modules/.vite 文件下),在啓動 DevServer 時直接請求該緩存內容。optimizeDeps
選項能夠選擇須要或不須要進行預編譯的依賴的名稱,Vite 則會根據該選項來肯定是否對該依賴進行預編譯。--force
options,能夠用來強制從新進行依賴預編譯。須要注意,強制從新依賴預編譯指的是忽略以前已編譯的文件,直接從新編譯。
因此,回到文章開始所說的疑問,這裏咱們能夠這樣理解依賴預編譯,它的出現是一種優化,即沒有它其實 No Bundle 也能夠,有它更好(xiang)! 並且,依賴預編譯並不是無米之炊,Vite 也是受 Snowpack 的啓發才提出的。react
那麼,下面咱們就來了解一下依賴預編譯的做用是什麼,即優化的意義~git
對於依賴預編譯的做用,Vite 官方也作了詳細的介紹。那麼,這裏咱們經過結合圖例的方式來認識一下,具體會是兩點:github
1. 兼容 CommonJS 和 AMD 模塊的依賴面試
由於 Vite 的 DevServer 是基於瀏覽器的 Natvie ES Module 實現的,因此對於使用的依賴若是是 CommonJS 或 AMD 的模塊,則須要進行模塊類型的轉化(ES Module)。json
2. 減小模塊間依賴引用致使過多的請求次數
一般咱們引入的一些依賴,它本身又會一些其餘依賴。官方文檔中舉了一個很經典的例子,當咱們在項目中使用 lodash-es
的時候:
import { debounce } from "lodash-es"
若是在沒用依賴預編譯的狀況下,咱們打開頁面的 Dev Tool 的 Network 面板:
能夠看到此時大概有 600+ 和 lodash-es
相關的請求,而且全部請求加載花了 1.11 s,彷佛還好?如今,咱們來看一下使用依賴預編譯的狀況:
此時,只有 1 個和 lodash-es
相關的請求(通過預編譯),而且全部請求加載才花了 142 ms,縮短了足足 7 倍多的時間! 而這裏節省的時間,就是咱們常說的冷啓動時間。
那麼,到這裏咱們就已經瞭解了 Vite 依賴預編譯概念和做用。我想你們都會好奇這個過程又是怎麼實現的?下面,咱們就深刻 Vite 源碼來更進一步地認識依賴預編譯過程!
在 Vite 源碼中,默認的依賴預編譯過程會在 DevServer 開啓以前進行。這裏,咱們仍然以在項目中引入了 vue
和 lodash-es
依賴爲例。
須要注意的是如下和源碼相關的函數都是取的 核心邏輯講解(僞代碼)。
首先,Vite 會建立一個 DevServer,也就是咱們日常使用的本地開發服務器,這個過程是由 createServer
函數完成:
// packages/vite/src/node/server/index.ts async function createServer( inlineConfig: InlineConfig = {} ): Promise<ViteDevServer> { ... // 一般狀況下咱們會命中這個邏輯 if (!middlewareMode && httpServer) { // 重寫 DevServer 的 listen,保證在 DevServer 啓動前進行依賴預編譯 const listen = httpServer.listen.bind(httpServer) httpServer.listen = (async (port: number, ...args: any[]) => { try { ... // 依賴預編譯相關 await runOptimize() } ... }) as any ... } else { await runOptimize() } ... }
能夠看到在 DevServer 真正啓動以前,它會先調用 runOptimize
函數,進行依賴預編譯相關的處理(用 bind
進行簡單的重寫)。
runOptimize
函數:
// packages/vite/src/node/server/index.ts const runOptimize = async () => { // config.optimzizeCacheDir 指的是 node_modules/.vite if (config.optimizeCacheDir) { .. try { server._optimizeDepsMetadata = await optimizeDeps(config) } .. server._registerMissingImport = createMissingImpoterRegisterFn(server) } }
runOptimize
函數負責的是調用和註冊處理依賴預編譯相關的 optimizeDeps
函數,具體來講會是兩件事:
1. 進行依賴預編譯
optimizeDeps
函數是 Vite 實現依賴預編譯的核心函數,它會根據配置 vite.config.js 的 optimizeDeps
選項和 package.json 的 dependencies
的參數進行第一次預編譯。它會返回解析 node_moduels/.vite/_metadata.json 文件後生成的對象(包含預編譯後的依賴所在的文件位置、原文件所處的文件位置等)。
_metadata.json 文件:
{ "hash": "bade5e5e", "browserHash": "830194d7", "optimized": { "vue": { "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/vue.js", "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js", "needsInterop": false }, "lodash-es": { "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/lodash-es.js", "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js", "needsInterop": false } } }
這裏,咱們來分別認識一下這 4 個屬性的含義:
hash
由須要進行預編譯的文件內容生成的,用於防止 DevServer 啓動時重複編譯相同的依賴,即依賴並無發生變化,不須要從新編譯。browserHash
由 hash
和在運行時發現的額外的依賴生成的,用於讓預編譯的依賴的瀏覽器請求無效。optimized
包含每一個進行過預編譯的依賴,其對應的屬性會描述依賴源文件路徑 src
和編譯後所在路徑 file
。needsInterop
主要用於在 Vite 進行依賴性導入分析,這是由 importAnalysisPlugin
插件中的 transformCjsImport
函數負責的,它會對須要預編譯且爲 CommonJS 的依賴導入代碼進行重寫。舉個例子,當咱們在 Vite 項目中使用 react
時:import React, { useState, createContext } from 'react'
此時 react
它是屬於 needsInterop
爲 true
的範疇,因此 importAnalysisPlugin
插件的會對導入 react
的代碼進行重寫:
import $viteCjsImport1_react from "/@modules/react.js"; const React = $viteCjsImport1_react; const useState = $viteCjsImport1_react["useState"]; const createContext = $viteCjsImport1_react["createContext"];
之因此要進行重寫的原因是由於 CommonJS 的模塊並不支持命名方式的導出。因此,若是不通過插件的轉化,則會看到這樣的異常:
Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'
有興趣繼續往這方面瞭解的同窗能夠查看這個 PR https://github.com/vitejs/vit...,這裏就不作過於詳細的介紹了~
2. 註冊依賴預編譯相關函數
調用 createMissingImpoterRegisterFn
函數,它會返回一個函數,其仍然內部會調用 optimizeDeps
函數進行預編譯,只是不一樣於第一次預編譯過程,此時會傳人一個 newDeps
,即新的須要進行預編譯的依賴。
那麼,顯然不管是第一次預編譯,仍是後續的預編譯,它們二者的實現都是調用的 optimizeDeps
函數。因此,下面咱們來看一下 optimizeDeps
函數~
optimizeDeps
函數被定義在 packages/vite/node/optimizer/index.ts 中,它負責對依賴進行預編譯過程:
// packages/vite/node/optimizer/index.ts export async function optimizeDeps( config: ResolvedConfig, force = config.server.force, asCommand = false, newDeps?: Record<string, string> ): Promise<DepOptimizationMetadata | null> { ... }
因爲 optimizeDeps
內部邏輯較爲繁多,這裏咱們拆分爲 5 個步驟講解:
1. 讀取該依賴此時的文件信息
既然是編譯依賴,很顯然的是每次編譯都須要知道此時文件內容對應的 Hash 值,以便於依賴發生變化時能夠從新進行依賴編譯,從而應用最新的依賴內容。
因此,這裏會先調用 getDepHash
函數獲取依賴的 Hash 值:
// 獲取該文件此時的 hash const mainHash = getDepHash(root, config) const data: DepOptimizationMetadata = { hash: mainHash, browserHash: mainHash, optimized: {} }
而對於
data
中的這三個屬性,咱們在上面已經介紹過了,這裏就不重複論述了~
2. 對比緩存文件的 Hash
前面,咱們也說起了若是啓動 Vite 時使用了 --force
Option,則會強制從新進行依賴預編譯。因此,當不是 --force
場景時,則會進行比較新舊依賴的 Hash 值的過程:
// 默認爲 false if (!force) { let prevData try { // 獲取到此時緩存(本地磁盤)中編譯的文件信息 prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8')) } catch (e) {} // 對比此時的 if (prevData && prevData.hash === data.hash) { log('Hash is consistent. Skipping. Use --force to override.') return prevData } }
能夠看到若是新舊依賴的 Hash 值相等的時候,則會直接返回舊的依賴內容。
3. 緩存失效或未緩存
若是上面的 Hash 不等,則表示緩存失效,因此會刪除 cacheDir
文件夾,又或者此時未進行緩存,即第一次依賴預編譯邏輯( cacheDir
文件夾不存在),則建立 cacheDir
文件夾:
if (fs.existsSync(cacheDir)) { emptyDir(cacheDir) } else { fs.mkdirSync(cacheDir, { recursive: true }) }
須要注意的是,這裏的
cacheDir
則指的是 node_modules/.vite 文件夾
前面在講 DevServer 啓動時,咱們說起預編譯過程會分爲兩種:第一次預編譯和後續的預編譯。二者的區別在於後者會傳入一個 newDeps
,它表示新的須要進行預編譯的依賴:
let deps: Record<string, string>, missing: Record<string, string> if (!newDeps) { ;({ deps, missing } = await scanImports(config)) } else { // 存在 newDeps 的時候,直接將 newDeps 賦值給 deps deps = newDeps missing = {} }
而且,這裏能夠看到對於前者,第一次預編譯,則會調用 scanImports
函數來找出和預編譯相關的依賴 deps
,deps
會是一個對象:
{ lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js' vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js' }
而 missing
則表示在 node_modules
中沒找到的依賴。因此,當 missing
存在時,你會看到這樣的提示:
scanImports
函數內部則是調用的一個名爲dep-scan
的內部插件(Plugin)。這裏就不講解dep-scan
插件的具體實現了,有興趣的同窗能夠自行了解哈~
那麼,回到上面對於後者(newDeps
存在時)的邏輯則較爲簡單,會直接給 deps
賦值爲 newDeps
,而且不須要處理 missing
。由於,newDeps
只有在後續導入並安裝了新的 dependencies
依賴,纔會傳入的,此時是不存在 missing
的依賴的( Vite 內置的 importAnalysisPlugin
插件會提早過濾掉這些)。
4. 處理 optimizeDeps.include 相關依賴
在前面,咱們也說起了須要進行編譯的依賴也會由 vite.config.js 的 optimizeDeps
選項決定。因此,在處理完 dependencies
以後,接着須要處理 optimizeDeps
。
此時,會遍歷前面從 dependencies
獲取到的 deps
,判斷 optimizeDeps.iclude
(數組)所指定的依賴是否存在,不存在則會拋出異常:
const include = config.optimizeDeps?.include if (include) { const resolve = config.createResolver({ asSrc: false }) for (const id of include) { if (!deps[id]) { const entry = await resolve(id) if (entry) { deps[id] = entry } else { throw new Error( `Failed to resolve force included dependency: ${chalk.cyan(id)}` ) } } } }
5. 使用 esbuild 編譯依賴
那麼,在作好上述和預編譯依賴相關的處理(文件 hash 生成、預編譯依賴肯定等)後。則進入依賴預編譯的最後一步,使用 esbuild
來對相應的依賴進行編譯:
... const esbuildService = await ensureService() await esbuildService.build({ entryPoints: Object.keys(flatIdDeps), bundle: true, format: 'esm', ... }) ...
ensureService
函數是 Vite 內部封裝的 util
,它的本質是建立一個 esbuild
的 service
,使用 service.build
函數來完成編譯過程。
此時,傳入的 flatIdDeps
參數是一個對象,它是由上面說起的 deps
收集好的依賴建立的,它的做用是爲 esbuild
進行編譯的時候提供多路口(entry
),flatIdDeps
對象:
{ lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js' moment:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/moment/dist/moment.js' vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js' }
好了,到此咱們已經分析完了整個依賴預編譯的實現 😲(手動給看到這的你們👍)。
那麼,接下來在 DevServer 啓動後,當模塊須要請求通過預編譯的依賴的時候,Vite 內部的 resolvePlugin
插件會解析該依賴是否存在 seen
中(seen
中會存儲編譯過的依賴映射),是則直接應用 node_modules/.vite
目錄下對應的編譯後的依賴,避免直接去請求編譯前的依賴的狀況出現,從而縮短冷啓動的時間。
經過了解 Vite 依賴預編譯的做用、實現等相關知識,我想你們應該不會再去糾結 Bundle 或者 No Bundle 的問題了,仍然是那句話,存在即有價值。而且,依賴預編譯這個知識點在面試場景下,可能也是一個頗有趣的考題 😎。最後,若是文章中存在表達不當或錯誤的地方,歡迎你們提 Issue~
經過閱讀本篇文章,若是有收穫的話,能夠點個贊,這將會成爲我持續分享的動力,感謝~
我是五柳,喜歡創新、搗鼓源碼,專一於源碼(Vue 三、Vite)、前端工程化、跨端等技術學習和分享,歡迎關注個人 微信公衆號:Code center。