theme: cyanosis
highlight: monokai
先看下官网介绍
当你首次启动 vite
时,你可能会注意到打印出了以下信息: bash Optimizable dependencies detected: (侦测到可优化的依赖:) react, react-dom Pre-bundling them to speed up dev server page load...(将预构建它们以提升开发服务器页面加载速度) (this will be run only when your dependencies have changed)(这将只会在你的依赖发生变化时执行)
预构建作用
CommonJS 和 UMD 兼容性
开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
当转换 CommonJS 依赖时,Vite 会执行智能导入分析,这样即使导出是动态分配的(如 React),按名导入也会符合预期效果: typescript // 符合预期 import React, { useState } from 'react'
性能
Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能;减少网络请求。
缓存
文件系统缓存
Vite 会将预构建的依赖缓存到 node_modules/.vite
。它根据几个源来决定是否需要重新运行预构建步骤: - package.json
中的 dependencies
列表 - 包管理器的 lockfile,例如 package-lock.json
, yarn.lock
,或者 pnpm-lock.yaml
- 可能在 vite.config.js
相关字段中配置过的
只有在上述其中一项发生更改时,才需要重新运行预构建。
如果出于某些原因,你想要强制 Vite 重新构建依赖,你可以用 --force
命令行选项启动开发服务器,或者手动删除 node_modules/.vite
目录。
浏览器缓存
解析后的依赖请求会以 HTTP 头 max-age=31536000,immutable
强缓存,以提高在开发时的页面重载性能。一旦被缓存,这些请求将永远不会再到达开发服务器。如果安装了不同的版本(这反映在包管理器的 lockfile 中),则附加的版本 query(v=xxx
) 会自动使它们失效。
接下来来看源码是怎么实现上述功能的
源码
本地服务器启动时,会进行预构建
```typescript const server = await createServer({ root, base: options.base, mode: options.mode, configFile: options.config, logLevel: options.logLevel, clearScreen: options.clearScreen, server: cleanOptions(options), })
await server.listen() `` 通过
createServer创建
server对象后,调用
server.listen方法启动服务器。启动后会执行
httpServer.listen`方法
在执行createServer
时,会重写server.listen
方法 typescript let isOptimized = false // overwrite listen to run optimizer before server start const listen = httpServer.listen.bind(httpServer) httpServer.listen = (async (port: number, ...args: any[]) => { if (!isOptimized) { try { // 调用 所有插件的 buildStart 钩子函数 await container.buildStart({}) await runOptimize() isOptimized = true } catch (e) { httpServer.emit('error', e) return } } return listen(port, ...args) }) as any
首先调用所有插件的buildStart
方法,然后调用runOptimize
方法 typescript const runOptimize = async () => { // 获取缓存路径,默认是 node_modules/.vite if (config.cacheDir) { // 表示当前正在预构建 server._isRunningOptimizer = true try { server._optimizeDepsMetadata = await optimizeDeps(config) } finally { server._isRunningOptimizer = false } server._registerMissingImport = createMissingImporterRegisterFn(server) } }
上述代码先调用optimizeDeps
方法,然后调用createMissingImporterRegisterFn
方法。
先看下optimizeDeps
方法,这个方法比较大,这里我们分步来看他做了啥 ```typescript export async function optimizeDeps( config: ResolvedConfig, force = config.server.force, // 设置为 true 强制使依赖预构建 asCommand = false, newDeps?: Record
, // missing imports encountered after server has started ssr?: boolean ): Promise
{ // 重新赋值 config config = { ...config, command: 'build', } const { root, logger, cacheDir } = config
// 拼接 _metadata.json 文件的路径(一般在 node_modules/.vite/_metadata.json)
const dataPath = path.join(cacheDir, '_metadata.json')
// 根据包管理器的 lockfile、vite.config.js 相关字段生成 hash 值
// 官网说还会根据 package.json 中的 dependencies 列表,但是现在这个版本没有这样,可能是后续版本更新了
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {},
}
// ...
`` 首先拼接
metadata.json文件的路径,一般在
nodemodules/.vite/_metadata.json。然后通过
getDepHash生成 hash 值。并创建一个
data`对象
_metadata.json
文件的作用是存储了预构建模块的一些信息,后续会详细介绍
typescript if (!force) { let prevData: DepOptimizationMetadata | undefined try { // 获取 cacheDir 中 _metadata.json 文件的内容 prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8')) } catch (e) {} // 如果 _metadata.json 有内容,并且之前的 哈希值和现在刚生成的哈希值相同 // 则表示没有依赖项发生改变,直接返回现在的 _metadata.json 的内容 if (prevData && prevData.hash === data.hash) { log('Hash is consistent. Skipping. Use --force to override.') return prevData } }
如果没有设置force
(强制使依赖预构建),从_metadata.json
中读取上次文件的内容,并判断hash值是否和现在的相同,如果相同,则直接返回_metadata.json
中的内容。
接下来的逻辑就是如果依赖发生改变,或者没有预构建过;根据cacheDir
创建空文件夹,这里就是.vite
文件夹。然后在文件夹中创建package.json
文件,并写入"type": "module"
typescript // 如果有 cacheDir(默认.vite),清空缓存文件夹 // 如果没有,创建一个空文件 if (fs.existsSync(cacheDir)) { emptyDir(cacheDir) } else { // 如果 recursive 为 true 返回创建的第一个目录路径,反之返回 undefined fs.mkdirSync(cacheDir, { recursive: true }) } // cacheDir 中创建 package.json,并写入 "type": "module" // 作用:给 Node 提示,缓存目录中的所有文件都应该被识别为 ES 模块 writeFile( path.resolve(cacheDir, 'package.json'), JSON.stringify({ type: 'module' }) )
小结
在这里先总结下上面的流程
- 根据包管理器的 lockfile、vite.config.js 相关字段生成 hash 值
- 获取
_metadata.json
文件的路径,里面的内容是上次预构建模块的信息 - 如果不是强制预构建,比对
_metadata.json
文件中的 hash 和新创建的 hash 值- 如果一致直接返回
_metadata.json
中的内容 - 如果不一致,创建/清空缓存文件夹(默认是
.vite
);缓存文件内创建package.json
文件,并写入"type": "module"
- 如果一致直接返回
继续向下,根据传入的newDeps
判断有没有依赖列表。如果没有,通过scanImports
方法收集列表
typescript let deps: Record<string, string>, missing: Record<string, string> if (!newDeps) { ;({ deps, missing } = await scanImports(config)) } else { deps = newDeps missing = {} }
自动依赖搜寻
scanImports
方法定义如下,也是一步一步的看
```typescript export async function scanImports(config: ResolvedConfig): Promise<{ deps: Record missing: Record }> { const start = performance.now()
let entries: string[] = []
// 一般都是用默认的 index.html
// 默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项。如果指定了 build.rollupOptions.input,Vite 将转而去抓取这些入口点。
// 如果这两者都不适合你的需要,则可以使用此选项指定自定义条目,或者是相对于 vite 项目根的模式数组。这将覆盖掉默认条目推断。
const explicitEntryPatterns = config.optimizeDeps.entries
const buildInput = config.build.rollupOptions?.input
if (explicitEntryPatterns) {
// 如果配置了 config.optimizeDeps.entries
// 在 config.root 下查找 explicitEntryPatterns 中对应的文件,并返回绝对路径
entries = await globEntries(explicitEntryPatterns, config)
} else if (buildInput) {
// 如果配置了 build.rollupOptions.input
const resolvePath = (p: string) => path.resolve(config.root, p)
// 下面的逻辑都是将 buildInput 的路径修改为相对于 config.root 的路径
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)]
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath)
} else if (isObject(buildInput)) {
entries = Object.values(buildInput).map(resolvePath)
} else {
throw new Error('invalid rollupOptions.input value.')
}
} else {
// 查找 html 文件
entries = await globEntries('**/*.html', config)
}
// 不支持的入口文件类型和虚拟文件不应该扫描依赖项。
// 过滤非 .jsx .tsx .mjs .html .vue .svelte .astro 文件,并且文件必须存在
entries = entries.filter(
(entry) =>
(JS_TYPES_RE.test(entry) || htmlTypesRE.test(entry)) &&
fs.existsSync(entry)
)
`` 首先查找入口文件 - 如果有
config.optimizeDeps.entries配置项,则入口文件从这里查找 - 如果有
config.build.rollupOptions.input配置项,则入口文件从这里查找 - 上述两种都没有,在项目跟目录下查找
html`文件
typescript const deps: Record<string, string> = {} const missing: Record<string, string> = {} // 创建插件容器 const container = await createPluginContainer(config) // 创建 esbuildScanPlugin 插件 const plugin = esbuildScanPlugin(config, container, deps, missing, entries) // 获取 预构建的 plugins 和 配置项 const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
接下来就是定义查找预构建模块需要的变量,比如插件容器container
、esbuildScanPlugin
插件、config.optimizeDeps.esbuildOptions
中定义的plugins
和其他 ESbuild 配置项
```typescript // 打包每个入口文件,并将引入的 js 合并到一起 await Promise.all( entries.map((entry) => build({ absWorkingDir: process.cwd(), write: false, entryPoints: [entry], bundle: true, format: 'esm', logLevel: 'error', plugins: [...plugins, plugin], ...esbuildOptions }) ) )
return { deps, missing } `` 接着就是调用 ESbuild 从入口模块开始构建整个项目,获取需要预构建的模块,并将依赖列表返回。其中会执行
esbuildScanPlugin`插件,这个插件就是查找的核心,看下这个插件的实现
```typescript const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
function esbuildScanPlugin( config: ResolvedConfig, container: PluginContainer, depImports: Record , missing: Record , entries: string[] ): Plugin { const seen = new Map ()
// 通过 container.resolveId 处理 id,最终返回该模块的绝对路径
// 并将这个模块路径添加到 seen 中,key 是 id + importer的上级目录,value是模块绝对路径
const resolve = async (id: string, importer?: string) => {}
const include = config.optimizeDeps?.include
// 忽略的包
const exclude = [
...(config.optimizeDeps?.exclude || []),
'@vite/client',
'@vite/env',
]
// 设置 build.onResolve 钩子函数的返回值
const externalUnlessEntry = ({ path }: { path: string }) => ({
path, // 模块路径
// 如果 entries 包含当前id,返回false
// 如果 external 为 true,不会将当前模块打包到 bundle 中
// 这段代码的意思是,如果当前模块包含在 entries 中,将这个模块打包到 bundle 中
external: !entries.includes(path),
})
return {
name: 'vite:dep-scan',
setup(build) {},
}
} ``
esbuildScanPlugin方法返回一个插件对象;定义了一个查找路径的
resolve`函数,和获取配置项中需要预构建的列表和忽略列表
这个插件会针对不同类型的文件做不同处理
外部文件、data:
开头的文件、css、json、不知名文件
```typescript setup(build) { // 如果是 http(s) 的外部文件,不打包到 bundle 中 build.onResolve({ filter: externalRE }, ({ path }) => ({ path, external: true, })) // 如果是以 data: 开头,不打包到 bundle 中 build.onResolve({ filter: dataUrlRE }, ({ path }) => ({ path, external: true, })) // css & json build.onResolve( {filter: /.(css|less|sass|scss|styl|stylus|pcss|postcss|json)$/}, externalUnlessEntry )
// known asset types
build.onResolve(
{filter: new RegExp(`\\.(${KNOWN_ASSET_TYPES.join('|')})$`)},
externalUnlessEntry
)
// known vite query types: ?worker, ?raw
build.onResolve({ filter: SPECIAL_QUERY_RE }, ({ path }) => ({
path,
external: true, // 不注入 boundle 中
}))
} ```
第三方库
上面这些比较简单,这里就不多介绍了,接下来看下对于第三方依赖是怎么处理的 typescript build.onResolve({ filter: /^[\w@][^:]/ }, async ({ path: id, importer }) => { // 判断引入的第三方模块是不是包含在 exclude 中 if (exclude?.some((e) => e === id || id.startsWith(e + '/'))) { return externalUnlessEntry({ path: id }) } // 如果当前模块已经被收集 if (depImports[id]) { return externalUnlessEntry({ path: id }) } // 获取 第三方模块的绝对路径 const resolved = await resolve(id, importer) if (resolved) { // 虚拟路径、非绝对路径、非 .jsx .tsx .mjs .html .vue .svelte .astro 文件返回 true if (shouldExternalizeDep(resolved, id)) { return externalUnlessEntry({ path: id }) } // 重点!!!! 这里进行收集第三方依赖 // 如果路径包含 node_modules 子字符串,或者该文件在 include 中存在 if (resolved.includes('node_modules') || include?.includes(id)) { // OPTIMIZABLE_ENTRY_RE = /\\.(?:m?js|ts)$/ if (OPTIMIZABLE_ENTRY_RE.test(resolved)) { // 添加到 depImports 中 depImports[id] = resolved } // 如果当前id,比如 vue,没有包含在entries时不将此文件打包到 bundle 中,反之打包 return externalUnlessEntry({ path: id }) } else { const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined // linked package, keep crawling return { path: path.resolve(resolved), namespace, } } } else { // 说明没找到 id 对应的模块 missing[id] = normalizePath(importer) } })
如果模块导入路径以字母、数字、下划线、汉字、@
开头,会被这个钩子函数捕获;获取模块绝对路径;如果模块路径包含node_modules
子字符串,或者该模块在include
中存在,并且是mjs
、js
、ts
文件,则将这个模块添加到depImports
中
如果根据传入的id
没有解析到模块路径,添加到missing
中
HTML、Vue 文件
对于 HTML、Vue文件,设置namespace
为html
typescript // htmlTypesRE = /\.(html|vue|svelte|astro)$/ // importer:绝对路径,该文件在哪个文件里被导入的 // 设置路径,并设置 namespace 为 html build.onResolve( { filter: htmlTypesRE }, async ({ path, importer }) => { return { path: await resolve(path, importer) namespace: 'html', } } )
当执行build.onLoad
钩子函数时,namespace
为html
会命中一个onload
钩子函数 ``typescript build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => { // 读取html、Vue内容 let raw = fs.readFileSync(path, 'utf-8') // 将注释内容替换成 <!----> raw = raw.replace(commentRE, '<!---->') // 如果是 .html 结尾,则为true const isHtml = path.endsWith('.html') // 如果是 .html 结尾,则 regex 匹配 type 为 module 的 script 标签,反之,比如 Vue 匹配没有 type 属性的 script 标签 // scriptModuleRE[1]: <script type="module"> 开始标签 // scriptModuleRE[2]、scriptRE[1]: script 标签的内容 // scriptRE[1]: <script> 开始标签 const regex = isHtml ? scriptModuleRE : scriptRE // 重置 regex.lastIndex regex.lastIndex = 0 let js = '' let loader: Loader = 'js' let match: RegExpExecArray | null while ((match = regex.exec(raw))) { const [, openTag, content] = match // 获取开始标签上的 src 内容 const srcMatch = openTag.match(srcRE) // 获取开始标签上的 type 内容 const typeMatch = openTag.match(typeRE) // 获取开始标签上的 lang 内容 const langMatch = openTag.match(langRE) const type = typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3]) 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 } // 不同文件设置不同 loader if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') { loader = lang } // 将 src 或者 script 代码块内容添加到 js 字符串中 if (srcMatch) { const src = srcMatch[1] || srcMatch[2] || srcMatch[3] js +=
import ${JSON.stringify(src)}\n } else if (content.trim()) { js += content + '\n' } } // 清空多行注释和单行注释内容 const code = js.replace(multilineCommentsRE, '/* */').replace(singlelineCommentsRE, '') // 处理 ts + <script setup 形式的 Vue 文件 if (loader.startsWith('ts') && (path.endsWith('.svelte') || (path.endsWith('.vue') && /<script\s+setup/.test(raw))) ) { // 对于 ts + <script setup 形式的 Vue 文件,导入的内容会被 ESbuild 原样输出,这将阻止 ESbuild 进一步爬行 // 解决方案是为每个导入添加' import 'x' ',以强制 ESbuild 保持爬行 while ((m = importsRE.exec(code)) != null) { if (m.index === importsRE.lastIndex) { importsRE.lastIndex++ } js +=
\nimport ${m[1]}` } } // ...
return {
loader,
contents: js,
}
}) `` 这个钩子函数的主要作用就是将所有导入拼成一个字符串,并设置
loader`属性值;然后交给 ESbuild 处理,这样做的目的就是 ESbuild 会加载这些导入内容,并收集符合条件的模块。
HTML文件: 获取 HTML 中所有script
标签,对于有src
属性的,通过import()
的形式拼接成字符串;对于内联的script
标签,将内联代码拼接到字符串中,因为这里面可能包含导入代码,最后返回loader
和content
Vue文件:和 HTML 文件大致相同,都是获取导入内容并返回loader
和content
jsx、tsx、mjs
这几个比较简单,也是通过onload
钩子函数,判断有没有配置jsxInject
选项,如果有将这个选项内容添加到代码中 ```typescript build.onLoad({ filter: JSTYPESRE }, ({ path: id }) => { let ext = path.extname(id).slice(1) if (ext === 'mjs') ext = 'js'
let contents = fs.readFileSync(id, 'utf-8')
// 如果是 tsx、jsx并且配置了 jsxInject,则将 jsxInject 注入到代码中
if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) {
contents = config.esbuild.jsxInject + `\n` + contents
}
return {
loader: ext as Loader,
contents,
}
}) ```
esbuildScanPlugin
插件小结
esbuildScanPlugin
插件的作用就是收集要进行预构建的模块。Vite 将从入口文件开始(默认是index.html
)通过 ESbuild 抓取源码,并自动寻找引入的依赖项(即 "bare import",表示期望从 node_modules
解析)
回到scanImports
方法中,通过 ESbuild 编译源码并收集需要预构建的第三方模块。最后返回deps
(收集的模块)和missing
(未找到的模块)
typescript // 打包每个入口文件,并将引入的 js 合并到一起 await Promise.all( entries.map((entry) => build({/* ... */}) ) ) return { deps, missing }
deps
和missing
的数据结构如下
bash deps: { 导入模块名/路径: 经解析后的绝对路径 } missing: { 导入模块名/路径: 导入该模块模块的路径 }
回到optimizeDeps
中,调用scanImports
方法之后,获取到丢失的模块和需要预构建的模块列表。
typescript let deps: Record<string, string>, missing: Record<string, string> if (!newDeps) { ;({ deps, missing } = await scanImports(config)) } else { deps = newDeps missing = {} }
继续向下 ```typescript // update browser hash // 这个属性就是预构建模块的url上的 v 参数 data.browserHash = createHash('sha256') .update(data.hash + JSON.stringify(deps)) .digest('hex') .substr(0, 8)
const missingIds = Object.keys(missing) if (missingIds.length) { throw new Error(/* ... /) } // 收集 include 中剩余需要预构建的模块 // 将 config.optimizeDeps?.include 中的文件写入 deps 中 const include = config.optimizeDeps?.include if (include) {/ ... */}
const qualifiedIds = Object.keys(deps) // 如果 deps 没有,写入 metadata.json 中 if (!qualifiedIds.length) { writeFile(dataPath, JSON.stringify(data, null, 2)) log(No dependencies to bundle. Skipping.\n\n\n
) return data } `` 更新
browserHash,如果有丢失模块,就抛出异常。反之将
config.optimizeDeps.include中的剩余需要预构建的模块写入
deps中。判断有没有需要预构建的依赖,如果没有将
data写入
metadata.json`中。
到此,收集过程结束,接下来进入编译过程
依赖收集小结
通过 ESbuild从入口文件开始(index.html
)编译, 收集第三方依赖。如果是HTML、Vue文件会触发build.onload
钩子函数,函数内获取script
标签;对于有src
属性的,通过import()
的形式拼接成字符串;对于内联的script
标签,将内联代码拼接成字符串;最后返回这个字符串;这样就可以获取HTML、Vue文件的导入内容。编译完成后,将includes
中剩余需要预构建的模块添加到预构建列表中。到此预构建收集阶段结束,进入编译过程。
编译过程
获取 Esbuild 配置项、初始化变量
先是定义一堆变量,并获取 Esbuild 配置项 ```typescript // 需要预构建的模块数量 const total = qualifiedIds.length const maxListed = 5 const listed = Math.min(total, maxListed) const flatIdDeps: Record = {} const idToExports: Record = {} const flatIdToExports: Record = {}
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {} // 初始化 es-module-lexer await init ```
收集
然后遍历所有需要预构建模块列表
typescript for (const id in deps) { // 将 > 替换成 __,将 \ 和 . 替换成 _ const flatId = flattenId(id) const filePath = (flatIdDeps[flatId] = deps[id]) // 读取需要预构建模块的代码 const entryContent = fs.readFileSync(filePath, 'utf-8') let exportsData: ExportsData try { // 通过 es-module-lexer 获取导入和导出位置 exportsData = parse(entryContent) as ExportsData } catch {/* ... */} for (const { ss, se } of exportsData[0]) { // 获取导入内容 // 比如 exp = import { initCustomFormatter, warn } from '@vue/runtime-dom' const exp = entryContent.slice(ss, se) if (/export\s+\*\s+from/.test(exp)) { // 如果 exp 是 export * from xxx 的形式,则设置 hasReExports 为 true exportsData.hasReExports = true } } idToExports[id] = exportsData flatIdToExports[flatId] = exportsData }
遍历所有需要预构建模块列表,将对应模块的绝对路径添加到flatIdDeps
中;读取模块代码,通过es-module-lexer
将模块转换成AST,并赋值给exportsData
。查找有没有export * from xxx
形式的代码,如果有exportsData.hasReExports
设置成true
。最后将AST赋值给idToExports
和flatIdToExports
flatIdToExports
、idToExports
、flatIdDeps
结构如下
```bash
id: 导入的模块或导入的路径
flatId: 将 > 替换成 __,将 \ 和 . 替换成 _ 的导入路径/模块
flatIdDeps: { flatId: 对应模块的绝对路径 } idToExports: { id: 对应模块的AST,是一个数组 } flatIdToExports: { flatId: 对应模块的AST,是一个数组 } ```
开始构建
继续往下,开始通过 ESbuild 构建模块
```typescript // 构建过程中要替换的字符串 const define: Record = { 'process.env.NODE_ENV': JSON.stringify(config.mode), } // 设置 esbuild.define 的内容,用于替换编译后的内容 for (const key in config.define) { const value = config.define[key] define[key] = typeof value === 'string' ? value : JSON.stringify(value) }
// 打包 deps 中的文件 const result = await build({ absWorkingDir: process.cwd(), entryPoints: Object.keys(flatIdDeps), bundle: true, // 这里为 true,可以将有许多内部模块的 ESM 依赖关系转换为单个模块 format: 'esm', target: config.build.target || undefined, external: config.optimizeDeps?.exclude, logLevel: 'error', splitting: true, sourcemap: true, outdir: cacheDir, ignoreAnnotations: true, metafile: true, define, plugins: [ ...plugins, esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr), // 注意这里 ], ...esbuildOptions, }) `` 通过 ESbuild 编译所有需要预编译的模块,入口文件就是这些需要预编译的模块。这里面使用了一个自定义插件
esbuildDepPlugin`,后面会分析,继续向下
生成预建模块的信息
```typescript // 获取打包到一起的文件生成的依赖图 const meta = result.metafile!
// 获取 cacheDir 相对于工作目录的路径 const cacheDirOutputPath = path.relative(process.cwd(), cacheDir) // 拼接 data 数据,并将 data 数据写入到 metadata.json 中 for (const id in deps) { const entry = deps[id] data.optimized[id] = { file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')), src: entry, needsInterop: needsInterop( // needsInterop 作用是判断这个模块是不是 CommonJS 模块 id, idToExports[id], meta.outputs, cacheDirOutputPath ) } } writeFile(dataPath, JSON.stringify(data, null, 2)) // 返回 data return data `` 先获取依赖图,然后拼接
data对象,并写入到
metadata.json中,最后返回
data
- 对于 CommonJS 模块,如果 ESbuild 设置了
format: 'esm'
,会导致将它包装为ESM。就像下面这样
```typescript // a.ts module.exports = { test: 1 } // 编译后 import { __commonJS } from "./chunk-Z47AEMLX.js";
// src/a.ts var requirea = _commonJS({ "src/a.ts"(exports, module) { module.exports = { test: 1 }; } });
// dep:srcats var srcatsdefault = requirea(); export { srcatsdefault as default }; //# sourceMappingURL=srca_ts.js.map `` -
es-module-lexer`转换 CommonJS 模块,转换后的内容,导出和引入都是空数组
_metadata.json
介绍
假设项目中只引入了 Vue,生成的_metadata.json
如下 json { "hash": "861d0c42", "browserHash": "c30d2c95", "optimized": { "vue": { "file": "/xxx/node_modules/.vite/vue.js", // 预构建生成的地址 "src": "/xxx/node_modules/vue/dist/vue.runtime.esm-bundler.js", // 源码地址 "needsInterop": false // 是否是 CommonJS 模块转成的 ESM 模块 } } }
esbuildDepPlugin
esbuildDepPlugin
插件定义如下
```typescript export function esbuildDepPlugin( qualified: Record , exportsData: Record , config: ResolvedConfig, ssr?: boolean ): Plugin { // 创建 ESM 的路径查找函数 const _resolve = config.createResolver({ asSrc: false }) // 创建 CommonJS 的路径查找函数 const _resolveRequire = config.createResolver({ asSrc: false, isRequire: true, })
const resolve = (
id: string, // 当前文件
importer: string, // 导入该文件的文件地址,绝对路径
kind: ImportKind, // 导入类型
resolveDir?: string
): Promise<string | undefined> => {
let _importer: string
if (resolveDir) {
_importer = normalizePath(path.join(resolveDir, '*'))
} else {
// importer 表示导入该文件的文件
// 如果 importer 在 qualified 中存在则设置对应文件路径,反之设置 importer
_importer = importer in qualified ? qualified[importer] : importer
}
const resolver = kind.startsWith('require') ? _resolveRequire : _resolve
// 根据不同模块类型返回不同的路径查找函数
return resolver(id, _importer, undefined, ssr)
}
return {
name: 'vite:dep-pre-bundle',
setup(build) {},
}
} ``
esbuildDepPlugin`函数会创建一个函数,这个函数的作用是根据模块种类返回不同的路径查找函数;并返回一个插件对象
这个插件对象主要功能和钩子函数如下
```typescript // qualified 包含所有入口模块 // 如果 flatId 在入口模块里面,设置 namespace 为 dep 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 }) => { let entry: { path: string; namespace: string } | undefined
if (!importer) {
// 如果没有 importer 说明是入口文件
// 调用 resolveEntry 方法,如果有返回值直接返回
if ((entry = resolveEntry(id))) return entry
// 入口文件可能带有别名,去掉别名之后再调用 resolveEntry 方法
const aliased = await _resolve(id, undefined, true)
if (aliased && (entry = resolveEntry(aliased))) {
return entry
}
}
// use vite's own resolver
const resolved = await resolve(id, importer, kind)
if (resolved) {
// ...
// http(s)类型的路径
if (isExternalUrl(resolved)) {
return {
path: resolved,
external: true,
}
}
return {
path: path.resolve(resolved)
}
}
}
) `` 这个钩子函数的作用是 - 对预构建模块入口文件设置namespace
设置为dep
- http(s)类型的路径不打包到 bundle 中,保持原样不变 - 其他类型的只返回路径
对于入口文件还有一个build.onLoad
钩子函数,内容如下
``typescript const root = path.resolve(config.root) build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => { // 获取 id 对应的绝对路径 const entryFile = qualified[id] // 获取 id 相对于 root 的路径 let relativePath = normalizePath(path.relative(root, entryFile)) // 拼接路径 if ( !relativePath.startsWith('./') && !relativePath.startsWith('../') && relativePath !== '.' ) { relativePath =
./${relativePath}` }
let contents = ''
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,
}
}) `` 这个钩子函数的作用就是构建一个虚拟模块,并导入预构建的入口模块。虚拟模块内容如下 - CommonJS 类型的文件,导出的虚拟模块内容是
export default require("模块路径"); -
export default的文件,导出的虚拟模块内容是
import d from "模块路径";export default d; - 其他 ESM 类型的文件,导出的虚拟模块内容是
export * from "模块路径"`
之后就通过这个虚拟模块开始打包所有预渲染模块。
预构建模块小结
- 遍历所有预构建模块,将对应模块的绝对路径添加到
flatIdDeps
中;读取模块代码,通过es-module-lexer
将模块转换成AST,并赋值给exportsData
。查找有没有export * from xxx
形式的代码,如果有exportsData.hasReExports
设置成true
。最后将AST赋值给idToExports
和flatIdToExports
- 通过 ESbuild 打包所有预构建模块。并设置
bundle
为true
。从而实现上面说的将有许多内部模块的 ESM 依赖关系转换为单个模块 - 最后生成预建模块的信息
由此预构建结束。回到runOptimize
方法
typescript const runOptimize = async () => { if (config.cacheDir) { server._isRunningOptimizer = true try { server._optimizeDepsMetadata = await optimizeDeps(config) } finally { server._isRunningOptimizer = false } server._registerMissingImport = createMissingImporterRegisterFn(server) } }
预构建之后的返回值会挂载到server._optimizeDepsMetadata
上。
怎么注册新的依赖预编译函数
通过createMissingImporterRegisterFn
创建一个函数,并将这个函数挂载到server._registerMissingImport
上。这个函数的作用是注册新的依赖预编译
```typescript export function createMissingImporterRegisterFn( server: ViteDevServer ): (id: string, resolved: string, ssr?: boolean) => void { let knownOptimized = server._optimizeDepsMetadata!.optimized let currentMissing: Record = {} let handle: NodeJS.Timeout
let pendingResolve: (() => void) | null = null
async function rerun(ssr: boolean | undefined) {}
return function registerMissingImport(}
} ```
当需要预编译新的模块时,调用这个返回函数registerMissingImport
typescript return function registerMissingImport( id: string, resolved: string, ssr?: boolean ) { if (!knownOptimized[id]) { // 收集需要预编译的模块 currentMissing[id] = resolved if (handle) clearTimeout(handle) handle = setTimeout(() => rerun(ssr), debounceMs) server._pendingReload = new Promise((r) => { pendingResolve = r }) } }
函数内,先将需要预编译的模块添加到currentMissing
中,然后调用rerun
函数
```typescript async function rerun(ssr: boolean | undefined) { // 获取新的预编译模块 const newDeps = currentMissing currentMissing = {} // 合并新老的预编译模块 for (const id in knownOptimized) { newDeps[id] = knownOptimized[id].src } try { server.isRunningOptimizer = true server.optimizeDepsMetadata = null // 调用 optimizeDeps 函数,开始预编译过程 const newData = (server._optimizeDepsMetadata = await optimizeDeps( server.config, true, // 注意这里为 true,说明需要清空缓存重新预编译 false, newDeps, // 传入了 newDeps ssr )) // 更新预编译模块列表 knownOptimized = newData!.optimized
} catch (e) { } finally { server.isRunningOptimizer = false pendingResolve && pendingResolve() server.pendingReload = pendingResolve = null } // 清空所有模块的 transformResult 属性 server.moduleGraph.invalidateAll() // 通知客户端重新加载页面 server.ws.send({ type: 'full-reload', path: '*', }) } ```
上述代码中将新老预编译模块合并,然后调用optimizeDeps
函数重新预构建所有模块。需要注意的点是force
传入的是true
,表示清空缓存重新预编译。而且还传入了newDeps
,不会再次收集预构建列表,而是直接使用传入的newDeps
typescript if (!newDeps) { ;({ deps, missing } = await scanImports(config)) } else { deps = newDeps missing = {} }
当新的预构建完成后,通知客户端重新加载页面。
小结
整个预编译流程如下
导入路径是怎么映射到缓存目录中
当被请求模块中导入了预构建模块时,在重写导入路径的时候会通过preAliasPlugin
插件获取并返回预构建后的路径。
看下preAliasPlugin
插件中这块逻辑
typescript // preAliasPlugin 插件的 resolveId 内部 resolveId(id, importer, _, ssr) { if (!ssr && bareImportRE.test(id)) { return tryOptimizedResolve(id, server, importer); } }
调用tryOptimizedResolve
方法
```typescript // tryOptimizedResolve 内部 const cacheDir = server.config.cacheDir const depData = server._optimizeDepsMetadata
if (!cacheDir || !depData) return
const getOptimizedUrl = (optimizedData: typeof depData.optimized[string]) => { return ( optimizedData.file + ?v=${depData.browserHash}${ optimizedData.needsInterop ?
&es-interop: `` }
) }
// check if id has been optimized const isOptimized = depData.optimized[id] if (isOptimized) { return getOptimizedUrl(isOptimized) } ```
可以看到,根据传入的id
从预构建列表中获取缓存文件的路径,并拼接v
参数;对于CommonJS 转成 ESM 的模块,会再拼接一个es-interop
参数。分析importAnalysis
插件时说过,对于有es-interop
参数的 URL 会在导入的地方重写导入逻辑。这也就解释了为什么 CommonJS 模块也可以通过 ESM 的方式引入。