本文使用vite 5.2.0-beta.0版本
在上文中,我们了解了chokidar
和 中间件
在Vite
起到了什么作用。
我们看看之前一直提到的模块依赖图。
在了解它的运作原理之前,我们先看看它是什么,干了什么。
是什么
模块依赖图是 Vite
在构建过程中生成的一个内部数据结构,它记录了项目中所有模块间的依赖关系。
在Vite
中,文件和模块并非严格的一一对应的关系,比如在devHtmlHookl
中,会将html
的每个内联script
分别划分为不同模块,会发起不同的请求。
模块依赖图也就是ModuleGraph
,每次启动本地服务器都会创建一份,是单例的,对于每个模块,是用 ModuleNode
来描述。
ModuleGraph
具有以下的数据:
urlToModuleMap
:收集了原始请求 url
到对应模块,也就是ModuleNode
的映射。(比如"/cjs.js" => ModuleNode
)
idToModuleMap
:收集了模块id
到对应模块,也就是ModuleNode
的映射,这里的id
实际上是使用 resolveId
钩子解析原始请求url
的结果,大部分情况是这个模块的绝对路径地址,当然也有特殊情况,比如针对于内联script
会在html
的地址加上不同的query
进行区分,以及使用__vite-browser-external:XXX
来标记的排除模块。
fileToModulesMap
:收集了文件绝对路径到ModuleNode
集合的映射,注意,这里使用的集合,因为一个文件可能有多个模块,比如上文的例子,html
存在多个内联script
,那么这个html
的绝对路径就会映射到这多个描述内联script
的ModuleNode
集合。当然,这里说绝对路径也是不太准确的,因为还是包含排除模块,所以本质还是经过 resolveId
钩子解析原始请求url
的结果,只不过把query
去掉了。
safeModulesPath
:收集了安全请求的集合。什么是安全请求?我们在serveRawFsMiddleware提到过,Vite
是有通过url
跨项目目录访问的能力的。那么如何判断当前请求是安全的?其中一条判断——是否为项目中使用到的文件。就是依赖ModuleGraph.safeModulesPath
来实现的。
同时符合以下所有标准的模块路径将会被放入safeModulesPath
:
- 模块名称不以
https
、http
开头以及不是data url
- 模块名称不是
/@vite/client
- 被插件流水线中的
transform
处理的模块的引用模块(注意不是被处理的模块,而是处理的模块所引用的模块)
etagToModuleMap
:收集了etag
对模块的映射。
而对于ModuleNode
,我们当前只需要关注以下的属性:
importers
:引用当前模块的模块的ModuleNode
集合clientImportedModules
:当前模块引用的模块的ModuleNode
集合transformResult
:当前模块返回浏览器识别的最终代码和etag
.
作用
首先,我们在之前提到过Vite
基于preTransformRequests
的预热功能,实现原理是尽可能提前构建对应模块的依赖图,而不是只有在请求新模块的时候进行构建,从而减少加载新模块的等待时间。严格来说,构建模块依赖图只是顺带,而其中的转译模块的性能瓶颈,才是提前构建的原因。
其次,可以起到快速热更新的作用,Vite
可以通过依赖图快速定位到受影响的模块,并重新编译它们。然后,基于模块之间的依赖关系,仅向浏览器推送变更过的模块,从而实现高效的热模块替换。
原理
那么我们通过源码观察下,模块依赖图是如何建立起来的。
transformRequest
首先,我们回忆一下,在dev
环境下,并且启用默认的优化,依赖图实际上是indexHtmlMiddleware
这个中间件开始构建的,在这个中间件的插件中,会递归遍历html
,解析出html
直接引入的模块之后,最后会通过预热逻辑触发transformRequest
。
transformRequest
是构建模块依赖图的主要逻辑,也是transformMiddleware
这个中间件的主要逻辑。
当indexHtmlMiddleware
触发transformRequest
的时候,会将当前源请求(url
)作为key
,然后检查server._pendingRequests
之中是否已经存在了正在转换的请求。
如果不存在,那么使用doTransform
生成一个新的转换promise
,然后跟当前时间戳一起存入_pendingRequests
之中,无论这个promise
是成功还是失败,最后都会从_pendingRequests
剔除这个缓存。
如果存在,会首先从模块依赖图查询是否存在对应的模块,如果模块不存在,或者模块存在,且失效时间早于正在处理的时间,那么这个请求是合法,返回处理中的promise
。其他情况,那么直接从_pendingRequests
剔除这个缓存,并标记为已取消。然后再次使用transformRequest
发起一个新的转换请求。
我们看一下具体代码:
const timestamp = Date.now()
// 检查是否存在正在处理中的请求
const pending = server._pendingRequests.get(cacheKey)
if (pending) {
// 如果存在正在处理中的请求,则查询依赖图中是否存在对应的模块
return server.moduleGraph
// 获取对应的模块
.getModuleByUrl(removeTimestampQuery(url), options.ssr)
.then((module) => {
// 如果模块不存在或者正在处理的请求的时间戳大于模块上次失效的时间戳,则处理请求仍然有效
if (!module || pending.timestamp > module.lastInvalidationTimestamp) {
return pending.request
} else {
// 处理请求已经失效,终止之这个请求并重新进行模块转换
pending.abort()
return transformRequest(url, server, options)
}
})
}
// 如果不存在正在处理中的请求,则执行模块转换和处理
const request = doTransform(url, server, options, timestamp)
既然是第一次转换,显而易见pending
是不存在的,所以我们看一下doTransform
做了什么
doTransform
我们先牢记一点——doTransform
是一个promise
,因此它的行为并非同步。
在doTransform
中,我们首先尝试根据url
从模块依赖图的urlToModuleMap
中获取url
对应的模块映射,如果获取到了,那么使用getCachedTransformResult
进行处理,如果处理后的返回值是有效值,那么返回这个结果。
如果没获取到,或者getCachedTransformResult
的结果是个无效值,就让url
跑一遍插件流水线的resolveId
钩子。
await pluginContainer.resolveId(url, undefined, { ssr }))
最后按照上文中可能找到的模块映射的id
> 插件流水线算出来的模块id
> 当前url
这个优先级,得出一个最终id
。
如果urlToModuleMap
找不到对应模块,那么按照这个id
从模块依赖图的idToModuleMap
再次尝试获取id
对应的模块映射。
如果这次能获取到,说明有一个全新的url
映射到了已经记录的模块映射,那么就需要使用_ensureEntryFromUrl
往urlToModuleMap
增加一条新纪录。
增加之后,再次使用getCachedTransformResult
进行处理,如果处理后的返回值是有效值,那么返回这个结果。
如果依然没有从idToModuleMap
找到,或者处理之后依然不是一个有效值,那么说明当前url
很可能是一个全新模块,就用到了loadAndTransform
进行处理。
const result = loadAndTransform(
id,
url,
server,
options,
timestamp,
module,
resolved,
)
return result
loadAndTransform
是一个异步函数。
我们注意到,在 Vite
中,并没有使用 await
来处理 loadAndTransform
。根据 Promise
的常规行为,这种操作将导致 loadAndTransform
中的第一个 await
的结果被挂起,同时继续执行 loadAndTransform
后续的代码。这里直接返回了 loadAndTransform
的返回值。
因此我们可以确认,如果模块是第一次加载的话,_pendingRequests
存储的promise
实际上就是loadAndTransform
的返回值,而一个异步函数的返回值是什么?就是promise
。
这样就不会阻塞后续代码的执行——不要忘了,当前处理的模块是indexHtmlMiddleware
通过预热逻辑递归解析出来的。真正浏览器并没有加载当前模块。甚至html
都没解析出来。
换句话说,正主transformMiddleware
都还没接触到这个模块。
所以当前要做的事情并不是转换模块,而是尽可能地解析出模块,然后启动转换模块的逻辑,并且将这些逻辑,也就是这些promise
推入_pendingRequests
之中。
当indexHtmlMiddleware
执行完毕后,浏览器将会收到最终的html
,然后根据html
中的链接,再次请求对应的资源,此时,才轮到transformMiddleware
中间件。
在前面,我们已经讲过transformMiddleware
的大部分作用,但剩下的模块依赖图相关的并没有讲。针对模块依赖图,transformMiddleware
中间件依然调用了transformRequest
。
但在transformRequest
中,经过indexHtmlMiddleware
一波递归遍例,html
直接引入的模块都化作了promise
存入了_pendingRequests
,但也有例外情况:
indexHtmlMiddleware
新增的模块,比如:/@vite/client
- 很快被处理完的模块——它们是自带
finally
的,还记得前面提的么——无论这个promise
是成功还是失败,最后都会从_pendingRequests
剔除这个缓存,finally
就是处理这件事的。
并且,被html
间接引用的模块,依然没有被处理到。
因此transformRequest
本身是没办法区分这个模块有没有被处理过,只能区分这个模块在不在处理中。
-
如果在处理中,那么返回这个正在处理中的请求(当然还要修正请求过时的情况)
-
如果不在处理中,那么就使用
doTransform
创建一个新的处理请求。
虽然transformRequest
没办法区分这个模块有没有被处理过,但doTransform
能啊。
doTransform
根据url
和id
双层查找模块依赖图,如果被处理过了,肯定存在模块依赖图中,因此将查询出来的ModuleNode
再次使用getCachedTransformResult
处理。
而getCachedTransformResult
的逻辑大体上就是拿ModuleNode
的transformResult
字段。
loadAndTransform
好了,让我们看一看如果一个模块从来没有被处理过,那么它的最终进入loadAndTransform
,它会经历什么?
const loadResult = await pluginContainer.load(id, { ssr })
首先,它会使用插件流水线的load
钩子跑一遍,最后的结果可能存在以下需要处理的情况:
- 如果最后的结果是对象,那么就提取这个对象的
code
字段和map
字段。 - 其他有值的情况,将结果当成
code
属性 - 如果没有值,那么使用
await fsp.readFile
读取文件,将内容赋值给code
,并且使用ensureWatchedFile
监听此文件,然后计算出map
。
一般内联script
被包装成的模块,就是在当前逻辑处理的,由load
钩子直接返回对应的code
,因此属于很快处理完的模块,当transformMiddleware
进行处理的时候,它们已经躺在模块依赖图里面了。
因此,当前code
和map
一般都是有值了,如果还没值说明文件是空的,或者不是一个合法文件目录。
这并不妨碍我们使用_ensureEntryFromUrl
往模块依赖图塞模块。它的逻辑我们稍后再说,我们只需要知道它塞了模块,并把塞的模块映射,也就是ModuleNode
返回回来。
mod ??= await moduleGraph._ensureEntryFromUrl(url, ssr, undefined, resolved)
当然这是mod
没有值的情况。
还记得前文,我们从urlToModuleMap
和idToModuleMap
尝试找到当前url
对应的模块了吗?一般找到了,并且getCachedTransformResult
的返回这是有效值,就走不到loadAndTransform
里面,但是——如果是无效值呢?
mod
可能之前就得到值了。
因此loadAndTransform
优先使用doTransform
找到的模块,如果没有再使用_ensureEntryFromUrl
新加的模块。
好了,现在mod
——也就是ModuleNode
也有了,code
和map
也有了,接下来就要计算transformResult
,也就是浏览器可读的代码,因为对于当前模块来说,不管它之前有没有在模块依赖图中存在,走到这一步,它的transformResult
都是处于失效的状态。
因此又走了一遍插件流水线的transform
钩子。
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ss
然后还要校验一下得到的新的transformResult
是否有效的,如果新的transformResult
没有值或者是一个对象但code
属性没值,那么code
和map
都使用之前没经过转换的。
反之使用transformResult
的的code
和map
。
最后,还要计算出etag
,连同前面的code
和map
,整合成result
,使用updateModuleTransformResult
绑定给前面得出来的ModuleNode
的transformResult
属性。
然后返回result
。
当然,因为transformResult
是新的,因此绑定过程中,也要从etagToModuleMap
中将旧的etag
删除,添加新的etag
映射。
因此在transformMiddleware
中,最后的result
就是上面我们计算出来的result
。
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html'),
})
然后提取对应属性,使用send
返回给客户端,当然,如果当前url
所对应的是一个npm
包,那么还会加上max-age=31536000,immutable
缓存。
可能有人看到这里有疑问:为什么会存在
urlToModuleMap
或者idToModuleMap
有对应模块,但getCachedTransformResult
却获取不到transformResult
的情况?答案很简单,这里可以创建模块依赖图,不意味着只有这里可以创建模块依赖图,我们后面会提到,插件流水线的
transform
钩子也可以创建模块依赖图。甚至在
devHtmlHook
中会将css
添加到模块依赖图。但
transformResult
是基于插件流水线的transform
钩子结果,因此其他地方创建的模块依赖图是没有transformResult
的。只有这里有。所以:可以创建模块依赖图的地方不少,但可以创建
transformResult
的只有两个地方,loadAndTransform
就是其中一处。
_ensureEntryFromUrl
这个方法是创建模块依赖图的核心方法,因此我们需要结合代码。
async _ensureEntryFromUrl(){
// 略 尝试根据url从缓存获取结果
// 如果不存在缓,就创建一个
const modPromise = (async () => {
const [url, resolvedId, meta] = await this._resolveUrl( // 解析Url
rawUrl,
ssr,
resolved,
)
mod = this.idToModuleMap.get(resolvedId) // 尝试从idToModuleMap获取模块
if (!mod) { // 如果模块不存在
mod = new ModuleNode(url, setIsSelfAccepting) // 创建新的模块映射
this.urlToModuleMap.set(url, mod) // 设置urlToModuleMap中url到当前模块的映射
mod.id = resolvedId // 设置模块的id
this.idToModuleMap.set(resolvedId, mod) // 设置idToModuleMap中id到当前模块的映射
const file = (mod.file = cleanUrl(resolvedId))
let fileMappedModules = this.fileToModulesMap.get(file) // 尝试从fileToModulesMap获取模块
if (!fileMappedModules) { // 不存在
fileMappedModules = new Set() // 创建新的集合
this.fileToModulesMap.set(file, fileMappedModules) // 向fileToModulesMap增加file映射
}
fileMappedModules.add(mod) // 填充file对应的模块映射集合
}
// 多个url可以映射到同一个模块id
else if (!this.urlToModuleMap.has(url)) {
this.urlToModuleMap.set(url, mod) // 如果id映射存在url映射不存在,添加url映射
}
// 解析出来后覆盖缓存,之前缓存的是promise
this._setUnresolvedUrlToModule(rawUrl, mod, ssr)
return mod // 返回模块
})()
// 因为_resolveUrl本质是调用插件流水线,因此设置为异步,并把异步放入缓存
this._setUnresolvedUrlToModule(rawUrl, modPromise, ssr)
return modPromise // 返回结果
}
首先,会从_unresolvedUrlToModuleMap
查找有没有对应缓存,_unresolvedUrlToModuleMap
是什么我们暂时略过,只要知道有对应的缓存就直接返回缓存,没有才会继续处理。
然后定义了一个异步函数modPromise
,这个异步函数虽然是自执行函数,但没有使用await
,因此不会阻塞后续代码。
这个函数首先调用了_resolveUrl
方法,这个方法会尝试将url
解析为模块id
,当然,如果前面已经解析过了,那么直接用之前解析出来的模块id
,否则就需要跑一遍插件流水线的resolveId
钩子。
const resolved = alreadyResolved ?? (await this.resolveId(url, !!ssr))
_resolveUrl
方法之后还补充了扩展名,就讲url
和模块id
返回了。
然后通过idToModuleMap
寻找对应模块。
如果没找到:
那么就新建一个模块节点,然后把url
作为key
,模块节点作为value
填充进urlToModuleMap
。
这还没完事,还要把这个新建的模块节点,将模块id
作为key
,模块节点作为value
填充进idToModuleMap
。
还没结束,还要根据模块id
,切掉query
,生成模块file
,大多数情况就是文件绝对路径。向fileMappedModules
,使用file
作为key
,然后将模块节点添加到作为value
的集合中。
如果找到了:
说明多个url
可以映射到同一个模块id
,那么就把找到的结果增加到urlToModuleMap
。
然后把这个模块节点存入_unresolvedUrlToModuleMap
,并返回模块节点。
这个是modPromise
的逻辑,实际上它是异步,那么它执行之后的代码做了什么呢?
之后将这个modPromise
存入了_unresolvedUrlToModuleMap
并返回。
换句话说,_unresolvedUrlToModuleMap
承担了唯一结果的责任:
- 当它没对应的缓存的的时候,构建模块依赖图还没开始,那就构建一个自执行
modPromise
放入_unresolvedUrlToModuleMap
- 当它有对应缓存的时候,那么直接返回这个缓存,但是缓存有两种形态
- 缓存是
promise
的时候,loadAndTransform
使用的是await
接受的值,因此接受到modPromise
返回的mod
- 缓存是
mod
的时候,await
也会接受到mod
,这个mod
由modPromise
结尾的_setUnresolvedUrlToModule
放入缓存。
- 缓存是
vite:import-analysis
可能有人问,模块依赖图就这样构建完了吗?clientImportedModules
之类的也没见到填充啊?
我们知道Vite
是有很多内置插件的,其中有一个插件叫做vite:import-analysis
。
这个插件的transform
钩子就是用来收集并填充ModuleNode
数据的。
首先这个插件会通过es-module-lexer
收集当前模块的引入模块和导出模块。(当前逻辑暂时不涉及导出模块)
;[imports, exports] = parseImports(source)
然后会根据模块id
查找是否存在于模块依赖图中,以此来校验当前模块是不是一个有效请求。
transform
钩子是在构建了当前模块的依赖图之后运行的,正常情况模块依赖图是存在当前模块的,但因为这里面存在异步操作,也存在当前模块立即失效的可能。
如果当前模块有引入其它模块,那就使用Promise.all
并行同步解析这些引入模块,尝试将它们添加到模块依赖图中。
针对每一个引入的模块,首先跑一遍插件流水线的resolve
钩子,此时,会解析出这个引入模块的id
。
如果这个模块id
不是https
等开头以及其它特殊模块,一般都会向模块依赖图增加这个模块。
const depModule = await moduleGraph._ensureEntryFromUrl(
unwrapId(url),
ssr,
canSkipImportAnalysis(url) || forceSkipImportAnalysis,
resolved,
)
处理完这些模块,还会再处理一下插件流水线的_addedImports
里面的模块,这些模块由addWatchFile
添加,具体可看上一篇文章。
我们看看已经有什么数据了:
- 当前模块的
ModuleNode
——我们从一开始模块依赖图的校验中拿到了 - 当前模块所依赖的模块——通过
es-module-lexer
获取到了。 - 当前模块所依赖的模块已经被加入模块依赖图,他们有自己的
ModuleNode
了。
好像已经够用了。
那么接下来调用moduleGraph.updateModuleInfo
来更新模块。
const prunedImports = await moduleGraph.updateModuleInfo(
importerModule,
importedUrls,
importedBindings,
normalizedAcceptedUrls,
isPartiallySelfAccepting ? acceptedExports : null,
isSelfAccepting,
ssr,
staticImportedUrls
)
updateModuleInfo
更新模块信息主要由这个方法完成。我们看一下它的源码。
首先,会记录之前所引入的模块。
const prevImports = mod.clientImportedModules
然后,它会遍历现在引入的模块,对于每个模块都会进行以下操作。
- 检查引入的模块是不是字符串,如果是字符串,那么默认这是引入模块的
url
,那么再次使用ensureEntryFromUrl
将引入模块尝试添加到模块依赖图,同时获取引入模块的ModuleNode
。然后将当前模块添加到引入模块的importers
集合。 - 如果不是字符串,那么默认就是引入模块的
ModuleNode
,然后将当前模块添加到引入模块的importers
集合。 - 这些引入模块的
ModuleNode
推入resolveResults
数组。
let resolvePromises = []
let resolveResults = new Array(importedModules.size)
let index = 0
for (const imported of importedModules) { // 遍历引入的模块集合
const nextIndex = index++ // 下一个索引值
if (typeof imported === 'string') { // 如果导入的是字符串
resolvePromises.push( // 添加解析 Promise
this.ensureEntryFromUrl(imported, ssr).then((dep) => {
dep.importers.add(mod) // 添加模块的导入者
resolveResults[nextIndex] = dep // 将解析结果存入数组
}),
)
} else {
imported.importers.add(mod) // 添加模块的导入者
resolveResults[nextIndex] = imported // 将导入模块存入解析结果数组
}
}
if (resolvePromises.length) {
await Promise.all(resolvePromises)
}
至此,引入模块的importers
字段更新完毕。
然后将resolveResults
数组转成Set
集合,直接覆盖当前模块的clientImportedModules
字段。
但此时还没结束,因为当前模块不再应用某个模块,不光处理当前模块的clientImportedModules
字段,还要将不再引用的模块的importers
去掉当前模块。
这就一开始保存prevImports
的原因。
通过clientImportedModules
和prevImports
循环对比不再引用的模块,然后将这些模块的importers
去掉当前模块。
prevImports.forEach((dep) => {
if (
!mod.clientImportedModules.has(dep) &&
!mod.ssrImportedModules.has(dep)
) {
dep.importers.delete(mod) // 移除当前模块
if (!dep.importers.size) {
// 如果它没有引入其它模块
;(noLongerImported || (noLongerImported = new Set())).add(dep) // 将这个模块添加到无引入模块集合中
}
}
})
之后还有一些处理,但是关于热更新相关,我们之后再讲。
结束
我们这次讲了模块依赖图的作用和形成,补上了之前留下的一个坑,接下来就要补上另一个坑——依赖预构建,同时依赖图的另一个作用——热更新我们并没有提到,这个也专门放到后面讲。