本文使用vite 5.2.8版本
依赖预构建的入口是initDepsOptimizer
函数,由initServer
触发。
但在触发之前,通过isDepsOptimizerEnabled
来判断,是否需要进行依赖预构建。
而isDepsOptimizerEnabled
的逻辑与文档保持一致。
如果你想完全禁用优化器,可以设置
optimizeDeps.noDiscovery: true
来禁止自动发现依赖项,并保持optimizeDeps.include
未定义或为空。
在代码中也针对noDiscovery
和optimizeDeps.include
进行了判断,并进行取反,如果不符合禁止依赖预构建的条件,那么就启用依赖预构建。
export function isDepsOptimizerEnabled(
config: ResolvedConfig,
ssr: boolean,
): boolean {
// 获取预构建配置
const optimizeDeps = getDepOptimizationConfig(config, ssr)
// 如果不符合禁止预构建的条件
return !(optimizeDeps.noDiscovery && !optimizeDeps.include?.length)
}
而initDepsOptimizer
实际是启用了一个单例的依赖预构建,如果当前的配置已经存在一个对应的预构建对象,那么就使用缓存,否则就创建一个。
export function getDepsOptimizer(
config: ResolvedConfig,
ssr?: boolean,
): DepsOptimizer | undefined {
// 根据config从 WeakMap 获取预构建对象
return (ssr ? devSsrDepsOptimizerMap : depsOptimizerMap).get(config)
}
export async function initDepsOptimizer(
config: ResolvedConfig,
server: ViteDevServer,
): Promise<void> {
// 如果本地缓存 (WeakMap)获取不到预构建对象,使用createDepsOptimizer创建一个新的
if (!getDepsOptimizer(config, false)) {
await createDepsOptimizer(config, server)
}
}
因此,我们的目光来到了createDepsOptimizer
这个函数,我们分段理解这个函数。
createDepsOptimizer
首先,使用loadCachedDepOptimizationMetadata
,通过配置中的config.cacheDir
,并且拼接上deps
和_metadata.json
,来获取本地存储的_metadata.json
。
一般情况下, config.cacheDir
会被给予默认值node_modules/.vite
,因此_metadata.json
的默认地址是node_modules/.vite/deps/_metadata.json
。
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr)
如果cachedMetadata
是空的,也就是loadCachedDepOptimizationMetadata
获取不到硬盘上存储的_metadata.json
,那么就使用initDepsOptimizerMetadata
,来初始化一个metadata
数据。
let metadata =
cachedMetadata || initDepsOptimizerMetadata(config, ssr, sessionTimestamp)
initDepsOptimizerMetadata
仅仅是初始化metadata
,并不负责将它存储在硬盘上。
我们看看initDepsOptimizerMetadata
初始化了什么数据。
export function initDepsOptimizerMetadata(
config: ResolvedConfig,
ssr: boolean,
timestamp?: string,
): DepOptimizationMetadata {
const { lockfileHash, configHash, hash } = getDepHash(config, ssr)
return {
hash,
lockfileHash,
configHash,
browserHash: getOptimizedBrowserHash(hash, {}, timestamp),
optimized: {},
chunks: {},
discovered: {},
depInfoList: [],
}
}
lockfileHash
:枚举常见包管理工具的lock
文件,优先使用npm_config_user_agent
中定义的包,否则依照以下优先级:package-lock.json
>yarn.lock
>pnpm-lock.yaml
>bun.lockb
从项目文件或者父目录中寻找,计算找到文件的hash
。configHash
:从config
摘取了几个关键配置组成了JSON
,这个JSON
的hash
。hash
:lockfileHash
和configHash
拼接到一起的字符串,计算出来的hash
。browserHash
:上面的hash
和空JSON
字符串以及时间戳拼接在一起的字符串,计算出来的hash
。optimized
:每个预构建依赖的对照集合。depInfoList
:依赖列表。chunks
:chunk
集合。discovered
:新发现的依赖。
因此,变量metadata
在经过以上逻辑后,总会有值的。
然后,连同预构建配置项,一起包装成预构建对象,并存入本地缓存中。
const depsOptimizer: DepsOptimizer = {
metadata, // 上文读取的metadata
registerMissingImport, // 发现新的依赖进行预构建
run: () => debouncedProcessing(0), // 立即预构建
isOptimizedDepFile: createIsOptimizedDepFile(config), //是否是经过预构建的文件
isOptimizedDepUrl: createIsOptimizedDepUrl(config),//是否是经过预构建的
getOptimizedDepId: (depInfo: OptimizedDepInfo) =>//预构建产物拼入hash
`${depInfo.file}?v=${depInfo.browserHash}`,
close, //结束预构建
options, //预构建配置项
}
depsOptimizerMap.set(config, depsOptimizer)
接着,会判断如果本地硬盘没有找到_metadata.json
,那么首先会处理optimizeDeps.include
,通过跑一遍插件流水线resolveId
钩子,计算出将它们的相关信息,然后推入metadata
的discovered
和depInfoList
字段。
这里需要注意的是,这些信息中的file
字段,是这个依赖被预构建之后的地址,因为预构建还没开始,因此此时此刻,硬盘上并没有对应地址。
如果使用optimizeDeps.noDiscovery: true
来禁止自动发现依赖项,那么调用runOptimizer
,将只处理metadata
中,optimized
和discovered
记录的依赖。
如果没有禁止自动发现依赖,那么就使用discoverProjectDependencies
进行依赖扫描。
scanImports
虽然discoverProjectDependencies
开启了依赖扫描,但实际核心函数在scanImports
之中,discoverProjectDependencies
是针对scanImports
的进一步包装。
在scanImports
中,首先定义了两个空对象,收集的依赖deps
和找不到的依赖missing
,这俩相当重要!它们是依赖收集的主要容器。
我们知道,依赖扫描实际上是依靠esbuild
实现的,所以,之后调用computeEntries
计算出入口文件。在computeEntries
中,进行了以下逻辑。
初始化
entries
数组。检查配置中是否存在明确的入口(
optimizeDeps.entries
)。如果存在,使用
globEntries
根据这些模式解析匹配的文件路径,并将结果存储在entries
数组中。
如果不存在明确的入口模式,检查配置中是否存在
build.rollupOptions.input
。如果存在,则根据
rollupOptions.input
的类型进行处理:如果是字符串,则将其解析为绝对路径,并将其添加到
entries
数组中。如果是数组,则对数组中的每个路径执行相同的操作。
如果是对象,则对对象的每个值(路径)执行相同的操作。
如果
rollupOptions.input
不是字符串、数组或对象,则抛出错误。
如果既没有明确的入口模式也没有
rollupOptions.input
,则默认使用**/*.html
作为入口模式,并使用globEntries
函数解析匹配的文件路径。这还没完事,还需要对确定的入口文件路径进行过滤:
排除不支持的入口文件类型和虚拟文件。
排除不存在的文件。
返回
entries
数组。
如果computeEntries
计算出来的入口数组有值,那么使用prepareEsbuildScanner
函数处理。
在prepareEsbuildScanner
中,定义了esbuild
的扫描插件esbuildScanPlugin
。
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
注意,这个插件把插件流水线container
、上文中把相当重要的deps
和missing
以及算出来的入口数组entries
传入了。
扫描插件往deps
和missing
写入数据,因为内存不变的原因,是可以不用通过返回值就可以拿到的。
我们待会看这个插件逻辑。
在构造完扫描插件后,还会尝试取optimizeDeps.esbuildOptions
的数据,并把其中的插件选项剥离出来。
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
然后针对esbuild
的tsconfigRaw
进行兼容处理。
最后调用esbuild.context
并使prepareEsbuildScanner
返回它的上下文对象。
// prepareEsbuildScanner
return await esbuild.context({
absWorkingDir: process.cwd(),
write: false,
stdin: {
contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
loader: 'js',
},
bundle: true,
format: 'esm',
logLevel: 'silent',
plugins: [...plugins, plugin],
...esbuildOptions,
tsconfigRaw,
})
然后在scanImports
触发这个上下文对象的rebuild
函数,让esbuild
进行真正的构建。当构建结束的时候,返回已经被填充好的、并且排好序的deps
对象和missing
对象。
const result = esbuildContext
.then((context) => {
return context
.rebuild()
.then(() => {
return {
deps: orderedDependencies(deps),
missing,
}
})
})
而这个result
其实也作为scanImports
的返回值返回给了discoverProjectDependencies
。
之前提到过,discoverProjectDependencies
实际上是scanImports
的包装,因此,它将result
再次包装处理:
如果
missing
是空对象,那么就抛出错误——找不到对应的依赖。只返回
deps
。也就是收集的依赖。
好了,我们现在通过esbuild
得到了扫描到的依赖对象,接下来跟处理optimizeDeps.include
一样,把它们对应的信息推入depInfoList
和discovered
之中。
同样的,这些信息中的file
字段,是这个依赖被预构建之后的地址,因为预构建还没开始,因此此时此刻,硬盘上并没有对应地址。
最后把已知依赖组合起来,传入runOptimizeDeps
。
runOptimizeDeps
首先runOptimizeDeps
会往node_modules/.vite/
创建一个deps_temp_[hash]
的文件夹。
之所以不直接创建或者修改deps
文件夹,是因为deps
文件可能是存在的,并且此时还没进行依赖预构建,而是仅仅完成了依赖收集,因此如果依赖预构建出现了错误,就没有回退余地了。所以创立了临时目录,当预构建成功结束后,临时目录转正。
然后再次通过initDepsOptimizerMetadata
计算出一份新的metadata
模板。与之前相同,不同的是本次发现了依赖,因此browserHash
不能以空对象作为基准计算hash
了,而是前面组合的已知依赖。
然后使用prepareEsbuildOptimizerRun
进行依赖预构建。
prepareEsbuildOptimizerRun
预构建部分逻辑很核心,但比较简单。
首先,跟prepareEsbuildScanner
类似,尝试取optimizeDeps.esbuildOptions
的数据,并把其中的插件剥离出来。
然后,它会处理传入的依赖,也就是上文的出来的已知依赖组合,将其扁平化为 flatIdDeps
对象,并提取每个依赖项的导出信息,将其存储在 idToExports
对象中。
接着,它准备一些构建配置,包括定义一些全局变量、选择平台、指定外部模块、设置插件等。
const context = await esbuild.context({
absWorkingDir: process.cwd(),
// 入口,上文扁平的flatIdDeps
entryPoints: Object.keys(flatIdDeps),
bundle: true,
format: 'esm',
// 构建输出的 JavaScript 版本
target: ESBUILD_MODULES_TARGET,
// 是否生成源映射文件
sourcemap: true,
// 输出目录
outdir: processingCacheDir,
// 是否生成元数据文件,用于后续分析依赖关系
metafile: true,
// 构建插件,用于在构建过程中对代码进行处理
plugins,
//略
})
我们省略了一些配置,剩下的配置需要我们重点关照:
entryPoints
: 将每一个依赖作为入口文件。format
: 转为esm
。outdir
:输出目录,还记得我们之前新建了临时目录吗?就是那个临时目录。metafile
:元数据,我们之后要用。
最后,它使用这些配置调用 esbuild.context
方法,生成一个新的构建上下文,并返回该上下文以及导出信息对象 idToExports
。当然,这个是esbuild.context
方法,因此并没有实际构建。
因此在runOptimizeDeps
中,调用完prepareEsbuildOptimizerRun
后,会调用返回的esbuild
上下文的rebuild
方法,触发构建,构建结束后,临时目录中已经有已知依赖的esm
产物了。
注意:是已知依赖,不是所有依赖。以上逻辑是vite启动时候的逻辑。当项目运行中,新增依赖,页面也引用了,但没有访问对应页面,那么这个依赖就是未知依赖。
当构建结束后,我们可以从rebuild
的then
方法获取metafile
。我们使用metafile.outputs
,根据已知依赖,来填充前文刚建立起来的metadata.optimized
。
这里需要注意的是,虽然两次都是调用了esbuild
。
但第一次的入口是项目入口,所遍历出来的依赖是项目直接引用的依赖。
第二次入口是这些依赖,因此构建出来的产物不仅仅有这些依赖的esm
格式,还有它们本身的依赖也会被打包进chunk
里面。
同时,如果是异步依赖,会自动在名称后面拼入hash
。
因此,根据metafile.outputs
,不存在已知依赖列表的产物,都会被填充到metadata.chunks
里面——包括上面的异步依赖——它们被拼入一个hash
字符串,并且依赖扫描不会收集异步依赖。
最后构建出一个对象successfulResult
,作为预构建调用链的返回值,填充到runOptimizeDeps
的返回值的result
属性上。
这个successfulResult
对象暴露三个属性。
之前构建的
metadata
。取消函数
cancel
。更新
deps
文件的commit
函数。
最重要的就是commit
。但我们稍后再讲。
最后runOptimizeDeps
的返回值挂载到createDepsOptimizer
的optimizationResult
属性上。
意味着,只要能访问到optimizationResult
,那么就可以通过await optimizationResult.result
获取上文定义的successfulResult
。从而可以更新deps
或者获取填充的metadata
。
但到目前为止,预构建已经结束了。
我们整理一下现状:
metadata
有两份,一个是createDepsOptimizer
初始化的,另一个是runOptimizeDeps
返回值,挂载到optimizationResult
上的。两个
metadata
已经填充完毕了,但都只在内存中,并没有写入硬盘。createDepsOptimizer
的metadata
数据比较基础,依赖最多收集到了依赖扫描到的依赖。discovered
是有值的。optimizationResult
的metadata
数据比较全,discovered
没有值。依赖预构建产物已经生成了,但放入的是临时文件夹。
创建、更新
deps
文件的函数commit
和metadata
挂载到了optimizationResult
属性上。
那么什么时候才会将metadata
填充到_metadata.json
中呢?以及什么时候预构建产物所在文件夹才会转正呢?
onCrawlEnd
onCrawlEnd
函数用于处理静态文件爬取结束后的依赖优化行为。主要负责处理临时文件转正和写入metadata
的操作。
我们来看看它的逻辑。
首先它会使用await
取出optimizationResult
的内容。
然后清空optimizationResult
。
const afterScanResult = optimizationResult.result
optimizationResult = undefined
const result = await afterScanResult
如果createDepsOptimizer
中的metadata.discovered
所记录的依赖都能在optimizationResult
的metadata
的optimized
找到。
那么就可以进行写入_metadata.json
和临时文件转正了。
如果optimized
比discovered
少,说明存在遗漏依赖,或者虽然一一对应,但数据属性不一致。
那么optimizationResult
中的数据作废,删除临时文件夹。并且将optimized
中多了的依赖添加到discovered
中,并且立刻重新开始一轮运行时的预构建。
注意,这里是将
optimized
比discovered
多的依赖添加到discovered
,确实存在这种情况,比如optimized
的依赖是【A,B,D】而discovered
是【A,B,C,E】,那么就会把D添加到discovered
。
最后执行runOptimizer(result)
执行依赖预构建收尾,我们待会看看runOptimizer
做了什么。
我们先看看onCrawlEnd
是如何被调用的。
在createDepsOptimizer
中,将onCrawlEnd
使用server._onCrawlEnd
推入到了onCrawlEndCallbacks
这个数组中。
这个数组最后是被谁消费呢?答案是setupOnCrawlEnd
。
setupOnCrawlEnd
内部定义了一个逻辑,确保onCrawlEndCallbacks
中的回调函数只会执行一次,它返回一个函数,叫做crawlEndFinder
。
这些不重要,重要的是crawlEndFinder
最终被_registerRequestProcessing
包装了下,并被doTransform
调用。
doTransform
是不是很眼熟,没错,就是模块依赖图的核心逻辑。
const result = loadAndTransform(...)
if (!ssr) {
const depsOptimizer = getDepsOptimizer(config, ssr)
if (!depsOptimizer?.isOptimizedDepFile(id)) {
server._registerRequestProcessing(id, () => result)
}
}
如果当前文件不是需要预构建的文件,那么就会调用_registerRequestProcessing
。从而最终调用runOptimizer(result)
。
registerMissingImport
我们上文提到了运行时的预构建。什么意思?
比如现在项目完全启动起来,并且浏览器已经渲染出目标页面。当前情况已经超出了我们上文分析过的任何逻辑,是一个运行时的环境。
那么通过新增依赖包,并在页面进行引用,此时新增的包同样需要进行预构建。
那么需要一个对应的逻辑处理这种情况,那么就需要运行时的预构建。
当然,显而易见,增加一个运行时是不太划算的,那么目前vite
有什么可以针对新引入的包做出反应的功能吗?
答案就是钩子。
更加具体点就是vite:resolve
插件的resolveId
钩子。
这个钩子如果发现引入的模块是一个并没有被预构建的钩子,那么就会调用registerMissingImport
方法。
这个方法我们之前提到过,是一个专门注册未预构建依赖的方法。
这个方法会检测新发现的模块在不在待预构建的列表里面,也就是在不在discovered
里面,如果在,就返回待预构建的信息。
否则就将此模块添加到discovered
里面,并调用一次runOptimizer()
,同时在控制台打印new dependencies found: xxx
。
runOptimizer
好了,现在两个方法最后都调用runOptimizer
。不同的是,onCrawlEnd
会传入optimizationResult.result
,而registerMissingImport
不会传入任何参数。
如果传入参数的话,那么入参会赋值给
processingResult
。否则,就会整理已知依赖,然后调用
runOptimizeDeps
,把它的返回值赋值给optimizationResult
,并且把optimizationResult.result
赋值给processingResult
。换句话说,又走了遍初始化metadata
-创建临时文件夹-将已知依赖构建输出到临时文件夹这个流程。
之后,会调用processingResult.commit()
,并在调用结束后,将createDepsOptimizer
中的metadata.discovered
所记录的依赖赋值给optimizationResult
的metadata
的discovered
。
也就是说,runOptimizer
起到了收束的作用,让不同时态的预构建逻辑,收束为调用commit
,也就是更新deps
。
commit
我们看看commit
的逻辑,首先,会往临时目录下写入_metadata.json
。
fs.writeFileSync(
dataPath,
stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)
好了,现在临时目录什么都有了:预构建产物、_metadata.json
。只差转正临门一脚了。
接着会判断现在当下有没有.vite/deps
目录。如果有,将它重命名为一个临时文件。
然后将之前创建的临时文件夹重命名为.vite/deps
,完成转正。
最后删除之前已经被重命名临时文件的原.vite/deps
。
为什么这么做呢?
这也做是为了最大程度地减少.vite/deps
处于不一致状态的时间。因为重命名-重命名(然后在后台删除旧文件夹)比删除-重命名操作快。
同时我们也从侧面看出,如果进行依赖预构建,一直是针对所有已知依赖的预构建——即使仅仅新增了一个依赖包。
看到这里,我们应该了解依赖预构建的流程了,但是,构建是结束了,那么怎么将请求的包,指向构建结束后的产物呢?
当然还是vite:resolve
插件的resolveId
钩子。
当它第一次处理依赖的时候,就会执行tryOptimizedResolve
函数。
tryOptimizedResolve
在tryOptimizedResolve
中,会使用await
等待依赖扫描结束。
然后获取createDepsOptimizer
的metadata
。查询当前依赖是否存在optimized
、discovered
、chunks
之中,如果存在,那么就返回metadata
记录的元数据。
然后拿这个元数据去调用预构建对象的getOptimizedDepId
。
getOptimizedDepId
我们一开始就提到了,代码很简单,就是拿元数据的file
拼上一个hash
然后返回。
getOptimizedDepId: (depInfo: OptimizedDepInfo) => `${depInfo.file}?v=${depInfo.browserHash}`
而file
,正是预构建产物所在的deps
目录。
可能有人问了,这是当前依赖可以在预构建metadata
中找到,如果找不到呢?
如果找不到,就会尝试从package.json
的browser
拿地址,当然,如果还拿不到,就会调用tryNodeResolve
方法。
tryNodeResolve
方法可能大家不熟,它里面有个情况就是发现当前包是一个新依赖,就会调用registerMissingImport
——发现新依赖,并添加到预构建列表。
也就是tryNodeResolve
是registerMissingImport
前置逻辑。
当然,上面的逻辑并非重新请求(刷新页面)都会调用一次,而是项目启动后,只会调用一次。
因为处理完当前依赖后,之后依赖都被指向预构建的地址。
准确来说,会被vite:import-analysis
插件的transform
钩子中的interopNamedImports
方法,将原来的依赖地址,使用MagicString
替换为预构建产物的地址。
MagicString
是一个用于处理字符串的JavaScript
库。它可以让你在字符串中进行插入、删除、替换等操作。可以看这篇文章。
替换完毕后,这个预构建产物就如同普通模块一样,在之后的逻辑中,被记录在模块依赖图里面。
之后再次刷新页面,由于地址已经被重写,并且模块依赖图存在对应数据,因此直接从模块依赖图拿数据,返回给浏览器。
好了,以上是预构建的所有逻辑,但我们还有个坑没有填,扫描插件是什么样的?
esbuildScanPlugin
还得提一下那两个比较重要的对象deps
和missing
,这两个分别记录收集到的依赖和找不到的依赖。
一开始,会定义以下规则:
data
请求不处理。http
、https
开头的模块不处理。worker
不做处理。剔除
virtual-module
前缀,Svelte
的<script>
和Vue
的<script setup>
会被增加这个虚拟前缀。如果是
.html
,.vue
,.svelte
,.astro
, 或.imba
结尾的文件,并且可被插件流水线的resolveId
解析,那么返回解析后的链接,并归类到html
类别。css
等样式文件,json
与wasm
和其它静态文件,如果不被指定为入口文件,那么就不处理。
对于bare imports
,会进行以下操作
首先会判断扫描到的依赖是否是被
optimizeDeps.exclude
排除的,并且不是@vite/client
和@vite/env
。如果是的话,就
return
,不过return
的时候还会判断是不是入口模块,如果不是入口模块,那么就返回忽略标记,让esbuild
遇到相同的路径的时候进行忽略。如果已经记录到
deps
里面,就return
,同样的,如果不是入口模块,那么就返回忽略标记,让esbuild
遇到相同的路径的时候进行忽略。然后进入插件流水线的
resolveId
进行解析,如果解析不成功,那么就放到missing
里面。【这里收集到了missing
依赖】如果解析成功还会判断解析出来的是否是绝对路径。如果不是绝对路径,或者是虚拟模块,那么就
return
,并根据是否是入口模块打上忽略标记。如果是
node_modules
模块,或者记录在optimizeDeps.include
,那么就将此模块收集到deps
中。并根据是否是入口模块打上忽略标记。【这里收集到了deps
依赖】如果不是
node_modules
模块,会按需归为html
类别。
那些归为html
类别的文件,会进行以下操作
首先会读取源码,然后正则匹配所有的
script
标签。针对每个匹配,会进行如下处理。
如果是
.html
结尾的文件,并且当前匹配的type
不是module
,那么跳过。如果
script
的type
不是javaScript
或ecmascript
或module
,则跳过。确定
loader
,如果是typeScript
或tsx
,则使用对应的loader
,否则默认使用javaScript
的loader
。如果标签包含
src
属性,则将其作为模块导入。否则,如果内容不为空,则处理内容。如果是
typescript
的话,那么需要把导入的模块再次全量引入,这样可以防止esbuild
优化代码的时候把它们删除,比如在vue3
的setup
中,使用import { ModuleA } from './modules'
,然后在<template>
使用ModuleA
,但esbuild
不认<template>
,会认为ModuleA
是无效引入,从而删除,因此这里会在内容后面拼接\nimport './modules'
,从而避免esbuild
删除。根据内容是否包含
import.meta.glob
来确定是否需要转换glob
导入路径,并将内容存储到scripts
对象中,以供后续加载时使用。生成
virtual-module
路径——还记得上文中剔除的virtual-module
前缀吗,在这里添加的。根据文件类型和上下文属性生成导出语句。进行引入或者重导出。
对于非
Vue
文件或者不包含默认导出的文件,在末尾添加默认导出。最后返回
loader
和处理后的内容。
这样就可以让上面的bare imports
逻辑捕获刚刚生成的内容。从而解析html
类别文件中的依赖。
结束
至此,Vite
开发环境下的源码已经分析了大致脉络,但需要学习的东西还有很多,因此我们依然在路上。