vite 5 源码分析: 模块依赖图

259 阅读18分钟

本文使用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的绝对路径就会映射到这多个描述内联scriptModuleNode集合。当然,这里说绝对路径也是不太准确的,因为还是包含排除模块,所以本质还是经过 resolveId 钩子解析原始请求url的结果,只不过把query去掉了。

safeModulesPath:收集了安全请求的集合。什么是安全请求?我们在serveRawFsMiddleware提到过,Vite是有通过url跨项目目录访问的能力的。那么如何判断当前请求是安全的?其中一条判断——是否为项目中使用到的文件。就是依赖ModuleGraph.safeModulesPath来实现的。

同时符合以下所有标准的模块路径将会被放入safeModulesPath

  • 模块名称不以httpshttp开头以及不是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映射到了已经记录的模块映射,那么就需要使用_ensureEntryFromUrlurlToModuleMap增加一条新纪录。

增加之后,再次使用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根据urlid双层查找模块依赖图,如果被处理过了,肯定存在模块依赖图中,因此将查询出来的ModuleNode再次使用getCachedTransformResult处理。

getCachedTransformResult的逻辑大体上就是拿ModuleNodetransformResult字段。

loadAndTransform

好了,让我们看一看如果一个模块从来没有被处理过,那么它的最终进入loadAndTransform,它会经历什么?

const loadResult = await pluginContainer.load(id, { ssr })

首先,它会使用插件流水线的load钩子跑一遍,最后的结果可能存在以下需要处理的情况:

  • 如果最后的结果是对象,那么就提取这个对象的code字段和map字段。
  • 其他有值的情况,将结果当成code属性
  • 如果没有值,那么使用await fsp.readFile读取文件,将内容赋值给code,并且使用ensureWatchedFile监听此文件,然后计算出map

一般内联script被包装成的模块,就是在当前逻辑处理的,由load钩子直接返回对应的code,因此属于很快处理完的模块,当transformMiddleware进行处理的时候,它们已经躺在模块依赖图里面了。

因此,当前codemap一般都是有值了,如果还没值说明文件是空的,或者不是一个合法文件目录。

这并不妨碍我们使用_ensureEntryFromUrl往模块依赖图塞模块。它的逻辑我们稍后再说,我们只需要知道它塞了模块,并把塞的模块映射,也就是ModuleNode返回回来。

mod ??= await moduleGraph._ensureEntryFromUrl(url, ssr, undefined, resolved)

当然这是mod没有值的情况。

还记得前文,我们从urlToModuleMapidToModuleMap尝试找到当前url对应的模块了吗?一般找到了,并且getCachedTransformResult的返回这是有效值,就走不到loadAndTransform里面,但是——如果是无效值呢?

mod可能之前就得到值了。

因此loadAndTransform优先使用doTransform找到的模块,如果没有再使用_ensureEntryFromUrl新加的模块。

好了,现在mod——也就是ModuleNode也有了,codemap也有了,接下来就要计算transformResult,也就是浏览器可读的代码,因为对于当前模块来说,不管它之前有没有在模块依赖图中存在,走到这一步,它的transformResult都是处于失效的状态。

因此又走了一遍插件流水线的transform钩子。

  const transformResult = await pluginContainer.transform(code, id, {
    inMap: map,
    ss

然后还要校验一下得到的新的transformResult是否有效的,如果新的transformResult没有值或者是一个对象但code属性没值,那么codemap都使用之前没经过转换的。

反之使用transformResult的的codemap

最后,还要计算出etag,连同前面的codemap,整合成result,使用updateModuleTransformResult绑定给前面得出来的ModuleNodetransformResult属性。

然后返回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,这个modmodPromise结尾的_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的原因。

通过clientImportedModulesprevImports循环对比不再引用的模块,然后将这些模块的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) // 将这个模块添加到无引入模块集合中
      }
    }
  })

之后还有一些处理,但是关于热更新相关,我们之后再讲。

结束

我们这次讲了模块依赖图的作用和形成,补上了之前留下的一个坑,接下来就要补上另一个坑——依赖预构建,同时依赖图的另一个作用——热更新我们并没有提到,这个也专门放到后面讲。