【Vite】1380- 詳解 Vite 依賴預(yù)構(gòu)建流程
我們知道,首次執(zhí)行 vite 時(shí),服務(wù)啟動(dòng)后會(huì)對(duì) node_modules 模塊和配置 optimizeDeps 的目標(biāo)進(jìn)行預(yù)構(gòu)建。本節(jié)我們就去探索預(yù)構(gòu)建的流程。
按照慣例,先準(zhǔn)備好一個(gè)例子。本文我們用 vue 的模板去初始化 DEMO:
pnpm create vite my-vue-app -- --template vue
項(xiàng)目創(chuàng)建好之后,我們?cè)侔惭b lodash-es 這個(gè)包,去研究 vite 是如何將幾百個(gè)文件打包成一個(gè)文件的:
pnpm add lodash-es -P
DEMO 代碼量比較多,這里就不貼代碼了,嫌麻煩的童鞋可以 fork Github repository[1] 。
流程概覽
當(dāng)我們服務(wù)啟動(dòng)之后,除了會(huì)調(diào)用插件容器的 buildStart 鉤子,還會(huì)執(zhí)行預(yù)構(gòu)建 runOptimize:
// ...
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
// 插件容器初始化
await container.buildStart({})
// 預(yù)構(gòu)建
await runOptimize()
isOptimized = true
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
}) as any
// ...
const runOptimize = async () => {
server._isRunningOptimizer = true
try {
// 依賴預(yù)構(gòu)建
server._optimizeDepsMetadata = await optimizeDeps(
config,
config.server.force || server._forceOptimizeOnRestart
)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
入口處將配置 config 和是否強(qiáng)制緩存的標(biāo)記(通過(guò) --force 傳入或者調(diào)用 restart API)傳到 optimizeDeps。optimizeDeps 邏輯比較長(zhǎng),我們先通過(guò)流程圖對(duì)整個(gè)流程有底之后,再按照功能模塊去閱讀源碼。

簡(jiǎn)述一下整個(gè)預(yù)構(gòu)建流程:
首先會(huì)去查找緩存目錄(默認(rèn)是 node_modules/.vite)下的 _metadata.json 文件;然后找到當(dāng)前項(xiàng)目依賴信息(xxx-lock 文件)拼接上部分配置后做哈希編碼,最后對(duì)比緩存目錄下的 hash 值是否與編碼后的 hash 值一致,一致并且沒(méi)有開(kāi)啟 force 就直接返回預(yù)構(gòu)建信息,結(jié)束整個(gè)流程; 如果開(kāi)啟了 force 或者項(xiàng)目依賴有變化的情況,先保證緩存目錄干凈(node_modules/.vite 下沒(méi)有多余文件),在 node_modules/.vite/package.json 文件寫入 type: module配置。這就是為什么 vite 會(huì)將預(yù)構(gòu)建產(chǎn)物視為 ESM 的原因。分析入口,依次查看是否存在 optimizeDeps.entries、build.rollupOptions.input、*.html,匹配到就通過(guò) dev-scan 的插件尋找需要預(yù)構(gòu)建的依賴,輸出 deps 和 missing,并重新做 hash 編碼; 最后使用 es-module-lexer[2] 對(duì) deps 模塊進(jìn)行模塊化分析,拿到分析結(jié)果做預(yù)構(gòu)建。構(gòu)建結(jié)果將合并內(nèi)部模塊、轉(zhuǎn)換 CommonJS 依賴。最后更新 data.optimizeDeps 并將結(jié)果寫入到緩存文件。
剝絲抽繭
全流程上我們已經(jīng)清楚了,接下來(lái)我們就深入上述流程圖中綠色方塊(邏輯復(fù)雜)的代碼。因?yàn)椴襟E之間的代碼關(guān)聯(lián)比較少,在分析下面邏輯時(shí)會(huì)截取片段代碼
計(jì)算依賴 hash
export async function optimizeDeps(
config: ResolvedConfig,
force = config.server.force,
asCommand = false,
newDeps?: Record<string, string>, // missing imports encountered after server has started
ssr?: boolean
): Promise<DepOptimizationMetadata | null> {
// ...
// 緩存文件信息
const dataPath = path.join(cacheDir, '_metadata.json')
// 獲取依賴的hash,這里的依賴是 lock 文件、以及 config 的部分信息
const mainHash = getDepHash(root, config)
// 定義預(yù)編譯優(yōu)化的元數(shù)據(jù)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
// 不強(qiáng)制刷新
if (!force) {
let prevData: DepOptimizationMetadata | undefined
try {
// 讀取 metadata 信息
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// hash is consistent, no need to re-bundle
if (prevData && prevData.hash === data.hash) {
log('Hash is consistent. Skipping. Use --force to override.')
return prevData
}
}
// 存在緩存目錄,清空目錄
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
// 創(chuàng)建多層級(jí)緩存目錄
fs.mkdirSync(cacheDir, { recursive: true })
}
// 緩存目錄的模塊被識(shí)別成 ESM
writeFile(
path.resolve(cacheDir, 'package.json'),
JSON.stringify({ type: 'module' })
)
// ...
}
// 所有可能的依賴 lock 文件,分別對(duì)應(yīng) npm、yarn、pnpm 的包管理
const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']
/**
* 獲取依賴的 hash 值
*
* @param {string} root 根目錄
* @param {ResolvedConfig} config 服務(wù)配置信息
* @return {*} {string}
*/
function getDepHash(root: string, config: ResolvedConfig): string {
// 獲取 lock 文件的內(nèi)容
let content = lookupFile(root, lockfileFormats) || ''
// 同時(shí)也將跟部分會(huì)影響依賴的 config 的配置一起加入到計(jì)算 hash 值
content += JSON.stringify(
{
mode: config.mode,
root: config.root,
resolve: config.resolve,
assetsInclude: config.assetsInclude,
plugins: config.plugins.map((p) => p.name),
optimizeDeps: {
include: config.optimizeDeps?.include,
exclude: config.optimizeDeps?.exclude
}
},
(_, value) => {
// 常見(jiàn)的坑:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
if (typeof value === 'function' || value instanceof RegExp) {
return value.toString()
}
return value
}
)
// 通過(guò) crypto 的 createHash 進(jìn)行 hash 加密
return createHash('sha256').update(content).digest('hex').substring(0, 8)
}
上述代碼先去 cacheDir 目錄下獲取 _metadata.json 的信息,然后計(jì)算當(dāng)前依賴的 hash 值,計(jì)算過(guò)程主要是通過(guò) xxx-lock 文件,結(jié)合 config 中跟依賴相關(guān)的部分配置去計(jì)算 hash 值。最后判斷如果服務(wù)沒(méi)有開(kāi)啟 force (即刷新緩存的參數(shù))時(shí),去讀取緩存元信息文件中的 hash 值,結(jié)果相同就直接返回緩存元信息文件即 _metadata.json 的內(nèi)容;
否則就判斷是否存在 cacheDir(默認(rèn)情況下是 node_modules/.vite),存在就清空目錄文件,不存在就創(chuàng)建緩存目錄;最后在緩存目錄下創(chuàng)建 package.json 文件并寫入 type: module 信息,這就是為什么預(yù)構(gòu)建后的依賴會(huì)被識(shí)別成 ESM 的原因。
在開(kāi)啟了 force 參數(shù)或者依賴前后的 hash 值不相同時(shí),就會(huì)去掃描并分析依賴,這就進(jìn)入下一個(gè)階段。
依賴搜尋,智能分析
// ... 接上述代碼
let deps: Record<string, string>, missing: Record<string, string>
// 沒(méi)有新的依賴的情況,掃描并預(yù)構(gòu)建全部的 import
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
// update browser hash
data.browserHash = createHash('sha256')
.update(data.hash + JSON.stringify(deps))
.digest('hex')
.substring(0, 8)
// 遺漏的包
const missingIds = Object.keys(missing)
if (missingIds.length) {
throw new Error(
`The following dependencies are imported but could not be resolved:\n\n ${missingIds
.map(
(id) =>
`${colors.cyan(id)} ${colors.white(
colors.dim(`(imported by ${missing[id]})`)
)}`
)
.join(`\n `)}\n\nAre they installed?`
)
}
// 獲取 optimizeDeps?.include 配置
const include = config.optimizeDeps?.include
if (include) {
// 創(chuàng)建模塊解析器
const resolve = config.createResolver({ asSrc: false })
for (const id of include) {
// normalize 'foo >bar` as 'foo > bar' to prevent same id being added
// and for pretty printing
const normalizedId = normalizeId(id)
if (!deps[normalizedId]) {
const entry = await resolve(id)
if (entry) {
deps[normalizedId] = entry
} else {
throw new Error(
`Failed to resolve force included dependency: ${colors.cyan(id)}`
)
}
}
}
}
const qualifiedIds = Object.keys(deps)
if (!qualifiedIds.length) {
writeFile(dataPath, JSON.stringify(data, null, 2))
log(`No dependencies to bundle. Skipping.\n\n\n`)
return data
}
const total = qualifiedIds.length
// pre-bundling 的列表最多展示 5 項(xiàng)
const maxListed = 5
// 列表數(shù)量
const listed = Math.min(total, maxListed)
// 剩余的數(shù)量
const extra = Math.max(0, total - maxListed)
// 預(yù)編譯依賴的信息
const depsString = colors.yellow(
qualifiedIds.slice(0, listed).join(`\n `) +
(extra > 0 ? `\n (...and ${extra} more)` : ``)
)
// CLI 下才需要打印
if (!asCommand) {
if (!newDeps) {
// This is auto run on server start - let the user know that we are
// pre-optimizing deps
logger.info(colors.green(`Pre-bundling dependencies:\n ${depsString}`))
logger.info(
`(this will be run only when your dependencies or config have changed)`
)
}
} else {
logger.info(colors.green(`Optimizing dependencies:\n ${depsString}`))
}
// ...
上述代碼很長(zhǎng),關(guān)鍵都在 scanImports 函數(shù),這個(gè)涉及到 esbuild 插件和 API,我們待會(huì)拎出來(lái)分析。其他部分的代碼我們通過(guò)一張流程圖來(lái)講解:

開(kāi)始通過(guò) scanImports 找到全部入口并掃描全部的依賴做預(yù)構(gòu)建;返回 deps 依賴列表、missings 丟失的依賴列表;
基于 deps 做 hash 編碼,編碼結(jié)果賦給 data.browserHash,這個(gè)結(jié)果就是瀏覽器發(fā)起這些資源的 hash 參數(shù);

對(duì)于使用了 node_modules 下沒(méi)有定義的包,會(huì)發(fā)出錯(cuò)誤信息,并終止服務(wù);舉個(gè)例子,我引入
abcd包:
import { createApp } from 'vue'
// 引用一個(gè)不存在的包
import getABCD from 'abcd'
import App from './App.vue'
import '../lib/index'
const s = getABCD('abc')
console.log(s)
createApp(App).mount('#app')
然后執(zhí)行 dev:

將 vite.config.ts 中的 optimizeDeps.include[3] 數(shù)組中的值添加到 deps 中,也舉個(gè)例子:
// vite.config.js
import { defineConfig } from 'vite'
import path from 'path'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
optimizeDeps: {
include: [
path.resolve(process.cwd(), './lib/index.js')
]
}
})
// ./lib/index.js 文件
import { sayHello } from './foo'
sayHello()
// ./lib/foo.js
export function sayHello () {
console.log('hello vite prebundling')
}
上述代碼我們將 ./lib/index.js 這個(gè)文件添加到預(yù)構(gòu)建的 include 配置中,lib 下的兩個(gè)文件內(nèi)容也已經(jīng)明確了。接下來(lái)執(zhí)行 dev 后,我們從終端上就可以看到這個(gè)結(jié)果:

我們的 lib/index.js 已經(jīng)被添加到預(yù)構(gòu)建列表。最后再看一下 node_modules/.vite,有一個(gè) _Users_yjcjour_Documents_code_vite_examples_vue-demo_lib_index_js.js 文件,并且已經(jīng)被構(gòu)建,還有 sourcemap 文件,這就是 optimizeDeps.include[4] 的作用。具體如何構(gòu)建這個(gè)文件的我們?cè)?導(dǎo)出分析 去梳理。
最后根據(jù) deps 的長(zhǎng)度去計(jì)算命令行中顯示的預(yù)構(gòu)建信息,并打印。
上述整個(gè)流程邏輯比較簡(jiǎn)單,就梳理一個(gè)主流程并實(shí)際展示了部分配置的作用。還有一個(gè)關(guān)鍵的環(huán)節(jié)我們略過(guò)了——scanImports。
/**
* 掃描全部引入
* @param {ResolvedConfig} config
*/
export async function scanImports(config: ResolvedConfig): Promise<{
deps: Record<string, string>
missing: Record<string, string>
}> {
const start = performance.now()
let entries: string[] = []
// 預(yù)構(gòu)建自定義條目
const explicitEntryPatterns = config.optimizeDeps.entries
// rollup 入口點(diǎn)
const buildInput = config.build.rollupOptions?.input
// 自定義條目?jī)?yōu)先級(jí)最高
if (explicitEntryPatterns) {
entries = await globEntries(explicitEntryPatterns, config)
// 其次是 rollup 的 build 入口
} else if (buildInput) {
const resolvePath = (p: string) => path.resolve(config.root, p)
// 字符串,轉(zhuǎn)成數(shù)組
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)]
// 數(shù)組,遍歷輸出路徑
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath)
// 對(duì)象,返回對(duì)象的value數(shù)組
} else if (isObject(buildInput)) {
entries = Object.values(buildInput).map(resolvePath)
} else {
throw new Error('invalid rollupOptions.input value.')
}
// 默認(rèn)情況下,Vite 會(huì)抓取你的 index.html 來(lái)檢測(cè)需要預(yù)構(gòu)建的依賴項(xiàng)
} else {
entries = await globEntries('**/*.html', config)
}
// 合法的入口文件只能是存在的 js、html、vue、svelte、astro 文件
entries = entries.filter(
(entry) =>
(JS_TYPES_RE.test(entry) || htmlTypesRE.test(entry)) &&
fs.existsSync(entry)
)
// 找不到需要預(yù)構(gòu)建的入口
if (!entries.length) {
if (!explicitEntryPatterns && !config.optimizeDeps.include) {
config.logger.warn(
colors.yellow(
'(!) Could not auto-determine entry point from rollupOptions or html files ' +
'and there are no explicit optimizeDeps.include patterns. ' +
'Skipping dependency pre-bundling.'
)
)
}
return { deps: {}, missing: {} }
} else {
debug(`Crawling dependencies using entries:\n ${entries.join('\n ')}`)
}
// 依賴
const deps: Record<string, string> = {}
// 缺失的依賴
const missing: Record<string, string> = {}
// 創(chuàng)建插件容器,為什么這里需要單獨(dú)創(chuàng)建一個(gè)插件容器?而不是使用 createServer 時(shí)創(chuàng)建的那個(gè)
const container = await createPluginContainer(config)
// 創(chuàng)建 esbuild 掃描的插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
// 外部傳入的 esbuild 配置
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
// 遍歷所有入口全部進(jìn)行預(yù)構(gòu)建
await Promise.all(
entries.map((entry) =>
build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
logLevel: 'error',
plugins: [...plugins, plugin],
...esbuildOptions
})
)
)
debug(`Scan completed in ${(performance.now() - start).toFixed(2)}ms:`, deps)
return {
deps,
missing
}
}
掃描入口先從 optimizeDeps.entries 獲??;如果沒(méi)有就去獲取 build.rollupOptions.input 配置,處理了 input 的字符串、數(shù)組、對(duì)象形式;如果都沒(méi)有,就默認(rèn)尋找 html 文件。然后傳入 deps、missing 調(diào)用 esbuildScanPlugin 函數(shù)生成掃描插件,并從 optimizeDeps.esbuildOptions 獲取外部定義的 esbuild 配置,最后調(diào)用 esbuild.build API 進(jìn)行構(gòu)建。整個(gè)流程匯總成一張圖如下:

重點(diǎn)來(lái)了,使用 vite:dep-scan 插件掃描依賴,并將在 node_modules 中的依賴定義在 deps 對(duì)象中,缺失的依賴定義在 missing 中。接著我們就進(jìn)入該插件內(nèi)部,一起學(xué)習(xí) esbuild 插件機(jī)制:
// 匹配 html <script type="module"> 的形式
const scriptModuleRE =
/(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 匹配 vue <script> 的形式
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/script>/gims
// 匹配 html 中的注釋
export const commentRE = /<!--(.|[\r\n])*?-->/
// 匹配 src 的內(nèi)容
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
// 匹配 type 的內(nèi)容
const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
// 匹配 lang 的內(nèi)容
const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
// 匹配 context 的內(nèi)容
const contextRE = /\bcontext\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
/**
* esbuid 掃描依賴插件
*
* @param {ResolvedConfig} config 配置信息
* @param {PluginContainer} container 插件容器
* @param {Record<string, string>} depImports 預(yù)構(gòu)建的依賴
* @param {Record<string, string>} missing 缺失的依賴
* @param {string[]} entries optimizeDeps.entries 的數(shù)據(jù)
* @return {*} {Plugin}
*/
function esbuildScanPlugin(
config: ResolvedConfig,
container: PluginContainer,
depImports: Record<string, string>,
missing: Record<string, string>,
entries: string[]
): Plugin {
const seen = new Map<string, string | undefined>()
const resolve = async (id: string, importer?: string) => {
const key = id + (importer && path.dirname(importer))
if (seen.has(key)) {
return seen.get(key)
}
// 使用 vite 插件容的解析能力,獲取具體的依賴信息
const resolved = await container.resolveId(
id,
importer && normalizePath(importer)
)
const res = resolved?.id
seen.set(key, res)
return res
}
// 獲取 optimizeDeps.include 配置
const include = config.optimizeDeps?.include
// 排除預(yù)構(gòu)建到包內(nèi)的文件
const exclude = [
...(config.optimizeDeps?.exclude || []),
'@vite/client',
'@vite/env'
]
// 將 external 設(shè)置為 true 以將模塊標(biāo)記為外部,這意味著它不會(huì)包含在包中,而是會(huì)在運(yùn)行時(shí)導(dǎo)入
const externalUnlessEntry = ({ path }: { path: string }) => ({
path,
external: !entries.includes(path)
})
// 返回 esbuild 的插件
return {
name: 'vite:dep-scan',
setup(build) {
console.log('build options ------->', build.initialOptions)
// ...省略大量 onResolve 和 onLoad 的回調(diào)
}
}
}
閱讀 esbuild 的 dep-scan 插件代碼需要 esbuild plugin 的前置知識(shí)[5],對(duì)比于 rollup,esbuild 插件有很多相似之處,因?yàn)?API 簡(jiǎn)單也會(huì)更加好理解。
上述代碼先定義了一堆正則表達(dá)式,具體的匹配內(nèi)容已經(jīng)在注釋中聲明??梢钥吹剑瑨呙枰蕾嚭诵木褪?strong style="color: black;">對(duì)依賴進(jìn)行正則匹配(esbuild 的 onResolve),然后對(duì)于語(yǔ)法不支持的文件做處理(esbuild onLoad)。接下來(lái)我們就從 DEMO 入手,來(lái)完整地執(zhí)行一遍 esbuild 的構(gòu)建流程。這樣讀者既能深入了解 vite 預(yù)構(gòu)建時(shí)模塊的構(gòu)建流程,也能學(xué)會(huì) esbuild 插件的開(kāi)發(fā)。我們先來(lái)看一下 DEMO 的模塊依賴圖:

對(duì)于本文示例而言,entries 就只有根目錄的 index.html,所以首先會(huì)進(jìn)入 html 流程:
// const htmlTypesRE = /\.(html|vue|svelte|astro)$/,這些格式的文件都被歸類成 html 類型
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
console.log('html type resolve --------------->', path)
return {
path: await resolve(path, importer),
// 將滿足這類正則的文件全部歸類成 html
namespace: 'html'
}
})
// extract scripts inside HTML-like files and treat it as a js module
// 匹配上述歸類成 html 的模塊,namespace 是對(duì)應(yīng)的
build.onLoad(
{ filter: htmlTypesRE, namespace: 'html' },
async ({ path }) => {
console.log('html type load --------------->', path)
let raw = fs.readFileSync(path, 'utf-8')
// Avoid matching the content of the comment
// 注釋文字全部刪掉
raw = raw.replace(commentRE, '<!---->')
// 是不是 html 文件,也可能是 vue、svelte 等
const isHtml = path.endsWith('.html')
// 是 html 文件就用 script module 正則,否則就用 vue 中 script 的匹配規(guī)則,具體見(jiàn)正則表達(dá)式
const regex = isHtml ? scriptModuleRE : scriptRE
// 注意點(diǎn):對(duì)于帶 g 的正則匹配,如果像匹配多個(gè)結(jié)果需要重置匹配索引
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RegExp/lastIndex
regex.lastIndex = 0
let js = ''
let loader: Loader = 'js'
let match: RegExpExecArray | null
while ((match = regex.exec(raw))) {
// openTag 開(kāi)始標(biāo)簽的內(nèi)容(包含屬性)
const [, openTag, content] = match
// 找到 type 的值
const typeMatch = openTag.match(typeRE)
const type =
typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
// 匹配語(yǔ)言,比如 vue-ts 中經(jīng)常寫的 <script lang="ts">
const langMatch = openTag.match(langRE)
const lang =
langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
// skip type="application/ld+json" and other non-JS types
if (
type &&
!(
type.includes('javascript') ||
type.includes('ecmascript') ||
type === 'module'
)
) {
continue
}
// 等于這些結(jié)果,都用當(dāng)前l(fā)ang的解析器,esbuild 都支持這些 loader
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang
}
// 匹配 src,例子中的 index.html 匹配上了就轉(zhuǎn)成 import '/src/main.js'
const srcMatch = openTag.match(srcRE)
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
// moduleScripts:`<script context="module">` in Svelte and `<script>` in Vue
// localScripts:`<script>` in Svelte and `<script setup>` in Vue
const contextMatch = openTag.match(contextRE)
const context =
contextMatch &&
(contextMatch[1] || contextMatch[2] || contextMatch[3])
// localScripts
if (
(path.endsWith('.vue') && setupRE.test(openTag)) ||
(path.endsWith('.svelte') && context !== 'module')
) {
// append imports in TS to prevent esbuild from removing them
// since they may be used in the template
const localContent =
content +
(loader.startsWith('ts') ? extractImportPaths(content) : '')
localScripts[path] = {
loader,
contents: localContent
}
// 加上 virtual-module: 前綴
js += `import ${JSON.stringify(virtualModulePrefix + path)}\n`
} else {
js += content + '\n'
}
}
}
// ...
return {
loader,
contents: js
}
}
)
當(dāng)入口是 index.html 時(shí),命中了 build.onResolve({ filter: htmlTypesRE }, ...) 這條解析規(guī)則,通過(guò) resolve 處理后返回 index.html 的絕對(duì)路徑,并將 namespace 標(biāo)記為 html,也就是歸成 html 類。后續(xù)匹配上 html 的 load 鉤子,就會(huì)進(jìn)入回到函數(shù)中。
build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, ...) 也是通過(guò) filter 和 namespace 去匹配文件。讀取 index.html 文件內(nèi)容之后,通過(guò)大量的正則表達(dá)式去匹配引入內(nèi)容。重點(diǎn)在于 <script type="module" src="/src/main.js"></script> 這段代碼會(huì)被解析成 import '/src/main.js',這就會(huì)進(jìn)入下一個(gè) resolve、load 過(guò)程。在進(jìn)入 JS_TYPE 的解析之前,有一個(gè)全匹配 resolver 先提出來(lái):
build.onResolve(
{
filter: /.*/
},
async ({ path: id, importer }) => {
console.log('all resloved --------------->', id)
// 使用 vite 解析器來(lái)支持 url 和省略的擴(kuò)展
const resolved = await resolve(id, importer)
if (resolved) {
// 外部依賴
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 擴(kuò)展匹配上了 htmlTypesRE,就將其歸類于 html
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
return {
path: path.resolve(cleanUrl(resolved)),
namespace
}
} else {
// resolve failed... probably unsupported type
return externalUnlessEntry({ path: id })
}
}
)
build.onLoad({ filter: JS_TYPES_RE }, ({ path: id }) => {
console.log('js load --------------->', id)
// 獲取文件的后綴擴(kuò)展
let ext = path.extname(id).slice(1)
// 如果是 mjs,將 loader 重置成 js
if (ext === 'mjs') ext = 'js'
let contents = fs.readFileSync(id, 'utf-8')
// 通過(guò) esbuild.jsxInject 來(lái)自動(dòng)為每一個(gè)被 ESbuild 轉(zhuǎn)換的文件注入 JSX helper
if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) {
contents = config.esbuild.jsxInject + `\n` + contents
}
// import.meta.glob 的處理
if (contents.includes('import.meta.glob')) {
return transformGlob(contents, id, config.root, ext as Loader).then(
(contents) => ({
loader: ext as Loader,
contents
})
)
}
return {
loader: ext as Loader,
contents
}
})
/.*/ 全匹配的 resolver 通過(guò) resolve 函數(shù)獲取完整地依賴路徑,比如這里的 /src/main.js 就會(huì)被轉(zhuǎn)成完整的絕對(duì)路徑。然后再來(lái)到 JS_TYPES_RE 的 loader,最終輸出文件內(nèi)容 contents 和對(duì)應(yīng)的解析器。/src/main.js 文件內(nèi)容:
import { createApp } from 'vue'
import App from './App.vue'
import '../lib/index'
createApp(App).mount('#app')
接著就會(huì)處理 vue、./App.vue、../lib/index 3個(gè)依賴。
對(duì)于 vue 依賴,會(huì)跟 /^[\w@][^:]/[6] 匹配。
// bare imports: record and externalize ----------------------------------
build.onResolve(
{
// avoid matching windows volume
filter: /^[\w@][^:]/
},
async ({ path: id, importer }) => {
console.log('bare imports --------------->', id)
// 首先判斷是否在外部入口列表中
if (moduleListContains(exclude, id)) {
return externalUnlessEntry({ path: id })
}
// 緩存,對(duì)于在node_modules或者optimizeDeps.include中已經(jīng)處理過(guò)的依賴,直接返回
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
const resolved = await resolve(id, importer)
if (resolved) {
// 對(duì)于一些特殊的場(chǎng)景,也會(huì)將其視作 external 排除掉
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 判斷是否在node_modules或者optimizeDeps.include中的依賴
if (resolved.includes('node_modules') || include?.includes(id)) {
// 存到depImports中,這個(gè)對(duì)象就是外部傳進(jìn)來(lái)的deps,最后會(huì)寫入到緩存文件中的對(duì)象
// 如果是這類依賴,直接將其視作為“外部依賴”,不在進(jìn)行接下來(lái)的resolve、load
if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
depImports[id] = resolved
}
return externalUnlessEntry({ path: id })
} else {
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
// linked package, keep crawling
return {
path: path.resolve(resolved),
namespace
}
}
} else {
missing[id] = normalizePath(importer)
}
}
)
經(jīng)過(guò)上述解析,vue 依賴會(huì)被視作 external dep,并將它緩存到 depImports,因?yàn)閷?duì)于 node_modules 或者 optimizeDeps.include 中的依賴,只需抓取一次即可。
對(duì)于 ./App.vue,首先會(huì)進(jìn)入 html type resolve,然后進(jìn)入 html load load 的回調(diào)中。跟 index.html 不同的時(shí),此時(shí)是 .vue 文件,在執(zhí)行 match = regex.exec(raw) 的時(shí)候匹配到 <script setup> 沒(méi)有 src 路徑,會(huì)進(jìn)入 else 邏輯。將 App.vue 中的 script 內(nèi)容提取出來(lái),存到 localScripts 中。最終生成的對(duì)象:
localScripts = {
'/Users/yjcjour/Documents/code/vite/examples/vue-demo/src/App.vue': {
loader: 'js',
// vue 中 <script setup> 內(nèi)容
contents: `
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup
import HelloWorld from './components/HelloWorld.vue'
import _ from 'lodash-es'
console.log(_.trim(' hello '))`
}
}
這次 App.vue 執(zhí)行 load 后返回以下內(nèi)容:
{
loader: 'js',
contents: 'import "virtual-module:/Users/yjcjour/Documents/code/vite/examples/vue-demo/src/App.vue"
'
}
從而繼續(xù)執(zhí)行 virtualModuleRE 的爬?。?/p>
// virtualModuleRE -> /^virtual-module:.*/
// 匹配虛擬的這些模塊,比如 `<script>` in Svelte and `<script setup>` in Vue 都會(huì)生成虛擬模塊
build.onResolve({ filter: virtualModuleRE }, ({ path }) => {
console.log('virtual module resolved -------------->', path)
return {
// strip prefix to get valid filesystem path so esbuild can resolve imports in the file
path: path.replace(virtualModulePrefix, ''),
namespace: 'local-script'
}
})
build.onLoad({ filter: /.*/, namespace: 'local-script' }, ({ path }) => {
// 即返回上述
return localScripts[path]
})
virtual-module 的解析就是直接從 localScripts 獲取內(nèi)容:
import HelloWorld from './components/HelloWorld.vue'
import _ from 'lodash-es'
console.log(_.trim(' hello '))`
又會(huì)接著處理 ./components/HelloWorld.vue、lodash-es 依賴,這兩個(gè)依賴跟 App.vue 和 vue 的處理過(guò)程都是一樣的,這里就不詳細(xì)說(shuō)明了。
最后對(duì)于 ./lib/index,跟上述解析 /src/main.js 的流程也是一致的。我們可以將整個(gè)依賴抓取和解析的過(guò)程用下圖總結(jié):

通過(guò) resolve 依賴,load 解析,esbuild 就掃描完整個(gè)應(yīng)用的依賴。具體的處理路徑通過(guò)在源碼中打上日志,我們可以看到以下的處理路徑:

路徑結(jié)果跟我們的分析是一樣的。插件執(zhí)行結(jié)束,作為參數(shù)傳入的 deps、missing 也完成了它們的收集(在對(duì) /^[\w@][^:]/ 做依賴分析時(shí)會(huì)給這兩個(gè)對(duì)象掛屬性)。
// deps
{
'lodash-es': '/Users/yjcjour/Documents/code/vite/examples/vue-demo/node_modules/lodash-es/lodash.js',
'vue': '/Users/yjcjour/Documents/code/vite/examples/vue-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}
// missing
{}
再結(jié)合開(kāi)頭的的流程,將 optimizeDeps.include 定義的項(xiàng)添加到 deps ,最終生成 deps 會(huì)添加上 lib/index 這條數(shù)據(jù):
// deps
{
'lodash-es': '/Users/yjcjour/Documents/code/vite/examples/vue-demo/node_modules/lodash-es/lodash.js',
'vue': '/Users/yjcjour/Documents/code/vite/examples/vue-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js',
// optimizeDeps.include 定義的預(yù)構(gòu)建項(xiàng)
'/Users/yjcjour/Documents/code/vite/examples/vue-demo/lib/index.js': '/Users/yjcjour/Documents/code/vite/examples/vue-demo/lib/index.js'
}
有了完整的 deps 信息,就可以開(kāi)始執(zhí)行真正的預(yù)構(gòu)建。
執(zhí)行預(yù)構(gòu)建、輸出結(jié)果
在開(kāi)始這部分的分析之前,我們先安裝一個(gè)純 commonjs 的依賴 fs-extra。之后在 main.js 中引入這個(gè)包:
import { createApp } from 'vue'
import * as fsExtra from 'fs-extra'
import App from './App.vue'
import '../lib/index'
createApp(App).mount('#app')
console.log(fsExtra)
然后在下述代碼打上斷點(diǎn):
// esbuild generates nested directory output with lowest common ancestor base
// this is unpredictable and makes it difficult to analyze entry / output
// mapping. So what we do here is:
// 1. flatten all ids to eliminate slash
// 2. in the plugin, read the entry ourselves as virtual files to retain the
// path.
// 拍平的依賴IDs
const flatIdDeps: Record<string, string> = {}
// id <--> export
const idToExports: Record<string, ExportsData> = {}
// 拍平的id <--> export
const flatIdToExports: Record<string, ExportsData> = {}
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
await init
for (const id in deps) {
const flatId = flattenId(id)
const filePath = (flatIdDeps[flatId] = deps[id])
const entryContent = fs.readFileSync(filePath, 'utf-8') // 讀取依賴內(nèi)容
let exportsData: ExportsData
try {
// 使用 es-module-lexer 對(duì)模塊的導(dǎo)入、導(dǎo)出做解析
exportsData = parse(entryContent) as ExportsData
} catch {
// ...
}
// ss -> export start se -> export end
for (const { ss, se } of exportsData[0]) {
// 截取 export、import 整個(gè)表達(dá)式
const exp = entryContent.slice(ss, se)
// 存在復(fù)合寫法 -> export * from 'xxx',打上 hasReExports 的標(biāo)志符號(hào)
if (/export\s+\*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
}
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
// 組合環(huán)境變量
const define: Record<string, string> = {
'process.env.NODE_ENV': JSON.stringify(config.mode)
}
for (const key in config.define) {
const value = config.define[key]
define[key] = typeof value === 'string' ? value : JSON.stringify(value)
}
const start = performance.now()
// 執(zhí)行預(yù)編譯
const result = await build({
// 絕對(duì)路徑的工作目錄(項(xiàng)目根目錄)
absWorkingDir: process.cwd(),
// 入口點(diǎn)
entryPoints: Object.keys(flatIdDeps),
// 集合,將全部文件構(gòu)建后內(nèi)聯(lián)到入口文件
bundle: true,
// 輸出文件格式,支持 iife、cjs、esm
format: 'esm',
// 打包后的支持的環(huán)境目標(biāo)
target: config.build.target || undefined,
// 排除某些依賴的打包
external: config.optimizeDeps?.exclude,
// 日志級(jí)別,只顯示錯(cuò)誤信息
logLevel: 'error',
// 代碼拆分
splitting: true,
sourcemap: true,
// 構(gòu)建輸出目錄,默認(rèn)是 node_modules/.vite
outdir: cacheDir,
// 忽略副作用注釋
ignoreAnnotations: true,
// 輸出構(gòu)建文件
metafile: true,
// 全局聲明
define,
// 插件
plugins: [
...plugins,
esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr)
],
...esbuildOptions
})
// 執(zhí)行構(gòu)建傳入了 metafile: true,這里能夠拿到構(gòu)建信息
const meta = result.metafile!
// the paths in `meta.outputs` are relative to `process.cwd()`
// 緩存目錄
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
// 更新 optimized 信息,全部寫入到 data.optimized 中
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop(
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
// 預(yù)編譯結(jié)果寫入metadata文件
writeFile(dataPath, JSON.stringify(data, null, 2))
debug(`deps bundled in ${(performance.now() - start).toFixed(2)}ms`)
return data
在執(zhí)行預(yù)構(gòu)建前,先將存在多層級(jí)的 dep 拍平,目的是為了產(chǎn)物能夠更明確和簡(jiǎn)單。然后遍歷 deps 做 import、export 的解析。對(duì)于有 export * from 'xx' 的表達(dá)式,會(huì)打上 hasReExports 標(biāo)記;對(duì)于本文 DEMO 而言,執(zhí)行完 deps 的分析之后 flatIdDeps、
idToExports、flatIdToExports 的結(jié)果如截圖所示:

從結(jié)果可以清楚地看到變量的含義:
flatIdDeps:拍平之后的 id 跟具體依賴路徑的映射; idToExports:依賴 id 的 imports、 exports 信息; flatIdToExports:拍平 id 的 imports、exports 信息;
將以上變量傳到 esbuildDepPlugin 創(chuàng)建預(yù)構(gòu)建插件,再將環(huán)境變量等信息全部傳入,執(zhí)行真正的預(yù)構(gòu)建。此時(shí) esbuild.build 需要重點(diǎn)關(guān)注參數(shù)有:
entryPoints: Object.keys(flatIdDeps),就是上述拍平的依賴 id; outdir: cacheDir,將產(chǎn)物輸出到緩存目錄,默認(rèn)是 node_modules/.vite; plugins: [ ...plugins, esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr) ] 使用預(yù)構(gòu)建插件。
緊接著我們進(jìn)入 esbuildDepPlugin 插件內(nèi)部,看預(yù)構(gòu)建執(zhí)行之前做了什么事情?
export function esbuildDepPlugin(
qualified: Record<string, string>,
exportsData: Record<string, ExportsData>,
config: ResolvedConfig,
ssr?: boolean
): Plugin {
// 默認(rèn) esm 默認(rèn)解析器
const _resolve = config.createResolver({ asSrc: false })
// node 的 cjs 解析器
const _resolveRequire = config.createResolver({
asSrc: false,
isRequire: true
})
const resolve = (
id: string,
importer: string,
kind: ImportKind,
resolveDir?: string
): Promise<string | undefined> => {
let _importer: string
// explicit resolveDir - this is passed only during yarn pnp resolve for
// entries
if (resolveDir) {
_importer = normalizePath(path.join(resolveDir, '*'))
} else {
// map importer ids to file paths for correct resolution
_importer = importer in qualified ? qualified[importer] : importer
}
// kind 是 esbuild resolve 回調(diào)中的一個(gè)參數(shù),表示模塊類型,總共有 7 種類型
// https://esbuild.github.io/plugins/#on-resolve-arguments
// 以require開(kāi)頭的表示cjs、否則用esm的解析器
const resolver = kind.startsWith('require') ? _resolveRequire : _resolve
return resolver(id, _importer, undefined, ssr)
}
return {
name: 'vite:dep-pre-bundle',
setup(build) {
console.log('dep-pre-bundle -------->, ', build.initialOptions)
// ...
/**
* 解析入口
*
* @param {string} id
* @return {*}
*/
function resolveEntry(id: string) {
const flatId = flattenId(id)
if (flatId in qualified) {
return {
path: flatId,
namespace: 'dep'
}
}
}
build.onResolve(
{ filter: /^[\w@][^:]/ },
async ({ path: id, importer, kind }) => {
// ...
// ensure esbuild uses our resolved entries
let entry: { path: string; namespace: string } | undefined
// if this is an entry, return entry namespace resolve result
if (!importer) {
if ((entry = resolveEntry(id))) return entry
// ...
}
// use vite's own resolver
const resolved = await resolve(id, importer, kind)
if (resolved) {
if (resolved.startsWith(browserExternalId)) {
return {
path: id,
namespace: 'browser-external'
}
}
if (isExternalUrl(resolved)) {
return {
path: resolved,
external: true
}
}
return {
path: path.resolve(resolved)
}
}
}
)
const root = path.resolve(config.root)
build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
console.log('dep load --------->', id)
const entryFile = qualified[id]
// 相對(duì)根目錄的路徑
let relativePath = normalizePath(path.relative(root, entryFile))
// 自動(dòng)加上路徑前綴
if (
!relativePath.startsWith('./') &&
!relativePath.startsWith('../') &&
relativePath !== '.'
) {
relativePath = `./${relativePath}`
}
let contents = ''
// 獲取文件的 import、export 信息
const data = exportsData[id]
const [imports, exports] = data
if (!imports.length && !exports.length) {
// cjs
contents += `export default require("${relativePath}");`
} else {
if (exports.includes('default')) {
contents += `import d from "${relativePath}";export default d;`
}
if (
data.hasReExports ||
exports.length > 1 ||
exports[0] !== 'default'
) {
contents += `\nexport * from "${relativePath}"`
}
}
let ext = path.extname(entryFile).slice(1)
if (ext === 'mjs') ext = 'js'
return {
loader: ext as Loader,
contents,
resolveDir: root
}
})
// ...
}
}
}
首先,上述 flatIdDeps 作為入口,先依次執(zhí)命中 /^[\w@][^:]/ filter 的解析,最后都會(huì)通過(guò) resolveEntry 套上 namespace: dep 的類型。然后執(zhí)行 namespace: dep 的 load 回調(diào)。接下來(lái)的流程通過(guò)一張圖去展示:

流程可以概括成兩大步:
計(jì)算依賴相較于 root 項(xiàng)目根目錄的相對(duì)路徑,并做規(guī)范化——添加相對(duì)路徑符號(hào); 根據(jù) exportsData 也就是上面的 flatIdToExports 變量獲取 imports、exports 信息;然后做了三層判斷: 如果滿足 !imports.length && !exports.length,說(shuō)明這是一個(gè) CJS 模塊,就會(huì)輸出 export default require("${relativePath}");,對(duì)于 DEMO 而言,fs-extra 就是這種情況,最后輸出的 contents 是'export default require("./node_modules/fs-extra/lib/index.js");';不是 CJS,就判斷是否存在默認(rèn)導(dǎo)出(export default),有的話就會(huì)在 contents 上拼接 import d from "${relativePath}";export default d;;對(duì)于 DEMO 而言,lodash-es 因?yàn)橛心J(rèn)導(dǎo)出,同時(shí)也會(huì)執(zhí)行 c 的流程,最終生成的 contents 如'import d from "./node_modules/lodash-es/lodash.js";export default d;\nexport * from "./node_modules/lodash-es/lodash.js"';在上一步的基礎(chǔ)上,如果有其他的導(dǎo)出表達(dá)式比如 export { compile };,就會(huì)加多一層復(fù)合導(dǎo)出,將模塊的內(nèi)容全部導(dǎo)出,即\nexport * from "${relativePath}",對(duì)于 DEMO 而言,vue 和 lib/index.js 會(huì)執(zhí)行這個(gè)邏輯,最后返回'\nexport * from "./node_modules/vue/dist/vue.runtime.esm-bundler.js"'和'\nexport * from "./lib/index.js"'。
第一層依賴4個(gè)入口就處理完成了,接下來(lái)就會(huì)對(duì)遞歸搜尋依賴的依賴。
完成整個(gè)預(yù)構(gòu)建的依賴查找之后,就會(huì)執(zhí)行構(gòu)建,構(gòu)建后的 metafile 信息如下:

input 信息太長(zhǎng),只打印了搜查的依賴總長(zhǎng)度是 692,最后構(gòu)建的產(chǎn)物從上圖能夠看到對(duì)于 lodash-es 這種包,會(huì)將依賴全部打成一個(gè)包,減少 http 次數(shù)。
最后的最后,將 deps 信息更新到 data.optimized 并寫入到緩存文件目錄。整個(gè)預(yù)構(gòu)建流程就結(jié)束了。
參考資料
Github repository: https://github.com/Jouryjc/vite
[2]es-module-lexer: https://www.npmjs.com/package/es-module-lexer
[3]optimizeDeps.include: https://cn.vitejs.dev/config/#optimizedeps-include
[4]optimizeDeps.include: https://cn.vitejs.dev/config/#optimizedeps-include
[5]前置知識(shí): https://esbuild.github.io/plugins/
[6]/^[\w@][^:]/: https://regex101.com/r/5A7KFx/1
