Vite5.0 moduleGraph软失效(soft invalidate)浅析

soft invalidation是vite处理热更新的时候优化方式,首先看看官方对soft invalidation的解释:链接

案例

如果没看懂可以看个案例,案例中是个vite创建的vue项目,项目主要代码如下:

javascript 复制代码
// src/app.vue
<script setup lang="ts">
  import { foo } from './foo'
</script>

<template>
  {{ foo }}
</template>

<style></style>

// src/foo.ts
export const foo = ['foo', 'bar']

当我修改foo.ts后会触发热更新,热更新第一步会执行一个moduleGraph.invalidateModule()方法

typescript 复制代码
// vite4.5.1中的 moduleGraph.invalidateModule
export class ModuleGraph {
    invalidateModule(  
    	mod: ModuleNode,
      seen: Set<ModuleNode> = new Set(),
      timestamp: number = Date.now(),
      isHmr: boolean = false,
      hmrBoundaries: ModuleNode[] = []
    ): void {

    	// ... 省略
      // Don't invalidate mod.info and mod.meta, as they are part of the processing pipeline
      // Invalidating the transform result is enough to ensure this module is re-processed next time it is requested
      mod.transformResult = null
      mod.ssrTransformResult = null
      mod.ssrModule = null
      mod.ssrError = null

      // ... 省略
    }
}

如果对moduleNode,moduleGraph不熟悉的可以参考这篇文章

这个方法是清空掉foo.ts对应moduleNode中的transformResult属性,以及所有导入foo.ts文件中的transformResult,在上面案例中就是app.vue。

我们来看看transformResult在vite中的作用

开发环境中,浏览器请求到src/foo.ts(地址简写了)文件时候会发生如下过程:

对应源码简要概括:

typescript 复制代码
// vite 4.5.1 中的doTransform
async function doTransform(
  url: string,
  server: ViteDevServer,
  options: TransformOptions,
  timestamp: number,
) {
  url = removeTimestampQuery(url)

  const { config, pluginContainer } = server
  const prettyUrl = debugCache ? prettifyUrl(url, config.root) : ''
  const ssr = !!options.ssr

  const module = await server.moduleGraph.getModuleByUrl(url, ssr)

  // check if we have a fresh cache
  // 检查是否有缓存
  const cached =
    module && (ssr ? module.ssrTransformResult : module.transformResult)
  if (cached) {
    // TODO: check if the module is "partially invalidated" - i.e. an import
    // down the chain has been fully invalidated, but this current module's
    // content has not changed.
    // in this case, we can reuse its previous cached result and only update
    // its import timestamps.
    debugCache?.(`[memory] ${prettyUrl}`)
    return cached
  }

  // 重新加载模块
  const resolved = module
    ? undefined
    : (await pluginContainer.resolveId(url, undefined, { ssr })) ?? undefined

  // resolve
  const id = module?.id ?? resolved?.id ?? url

  const result = loadAndTransform(
    id,
    url,
    server,
    options,
    timestamp,
    module,
    resolved,
  )

  getDepsOptimizer(config, ssr)?.delayDepsOptimizerUntil(id, () => result)

  return result
}

transformResult是moduleNode用来储存其对应模块代码。简单来说就是当一个模块被vite钩子load(加载),transform(代码转换)处理后,得到了最终代码code,该moduleNodetransformResult属性值就是code。

设计transformResult的原因是为了缓存本次处理模块的结果,如果没有发生变化,下次请求模块的时候会先去访问模块对应的moduleNodetransformResult属性,如果有则直接使用,没有则去加载模块。

再回到刚才的案例上面, 我们修改了src/foo.ts后,vite中会执行moduleGraph.invalidateModule(),其对应的moduleNode中的transformResult就会被清空,下次再请求的时候才能请求到最新的src/foo.ts中的内容。

moduleGraph.invalidateModule()不仅仅会清空src/foo.ts中transformResult内容,还会清空导入src/foo.ts中父模块内容。

typescript 复制代码
// vite4.5.1中的 moduleGraph.invalidateModule
invalidateModule(  
	mod: ModuleNode,
  seen: Set<ModuleNode> = new Set(),
  timestamp: number = Date.now(),
  isHmr: boolean = false,
  hmrBoundaries: ModuleNode[] = []
) 
{
	// ... 省略
  mod.importers.forEach((importer) => {
    if (!importer.acceptedHmrDeps.has(mod)) {
      this.invalidateModule(importer, seen, timestamp, isHmr)
    }
  })
}

产生的问题:

如果很多文件都导入了foo.ts,那么如果foo.ts修改了,如上图所示,所有的对应的导入文件都会重新加载。

解决办法

为了避免导入文件重新加载的问题, vite5.0实现了soft invalidate

还是上面的例子,一旦修改了foo.ts,会发生如下过程:

  • foo.ts 改变,invalidate执行,foo的transformResult被置空。同时moduleNodelastHMRTimestamp会被赋值为当前时间。
  • 找到导入foo.ts的文件App.vue,发现此时App.vue中静态导入了foo.ts(就是import {xx} from './foo.ts'),此时App.vue就会被定义为soft invalidate状态。
  • vite出发热更新,此时会去请求App.vue文件,此时检测到App.vue是soft invalidate状态,App.vue会继续使用上一次transformResult的结果,并且会修改transformResult, 会给所有静态导入模块加上请求时间戳(例如?t=xxxx),这个时间戳就是第一步中设置的lastHMRTimestamp
  • 请求最新的foo.ts/?t=xxx文件。

整个过程中,通过给静态导入的模块更新其最新的请求时间戳的方式,避免了模块重新加载问题。

源码

invalidateModule

invalidateModule方法中形参添加softInvalidate参数,根据它来判断当前是否soft invalidate

typescript 复制代码
// vite 5.1.10
  invalidateModule(
    mod: ModuleNode,
    seen: Set<ModuleNode> = new Set(),
    timestamp: number = Date.now(),
    isHmr: boolean = false,
    /** @internal */
    softInvalidate = false,
  ): void {
    const prevInvalidationState = mod.invalidationState
    const prevSsrInvalidationState = mod.ssrInvalidationState

    // Handle soft invalidation before the `seen` check, as consecutive soft/hard invalidations can
    // cause the final soft invalidation state to be different.
    // If soft invalidated, save the previous `transformResult` so that we can reuse and transform the
    // import timestamps only in `transformRequest`. If there's no previous `transformResult`, hard invalidate it.
    // 一个模块要soft invalidate的话,那么必须要有之前就有transformResult 且 传参softInvalidte 为true
		if (softInvalidate) {
      mod.invalidationState ??= mod.transformResult ?? 'HARD_INVALIDATED'
      mod.ssrInvalidationState ??= mod.ssrTransformResult ?? 'HARD_INVALIDATED'
    }
    // If hard invalidated, further soft invalidations have no effect until it's reset to `undefined`
    // 非soft invalidte状态
    else {
      mod.invalidationState = 'HARD_INVALIDATED'
      mod.ssrInvalidationState = 'HARD_INVALIDATED'
    }

    // Skip updating the module if it was already invalidated before and the invalidation state has not changed
    // 如果已经失效过,且失效状态都一样
		if (
      seen.has(mod) &&
      prevInvalidationState === mod.invalidationState &&
      prevSsrInvalidationState === mod.ssrInvalidationState
    ) {
      return
    }
    seen.add(mod)

    if (isHmr) {
      mod.lastHMRTimestamp = timestamp
    } else {
      // Save the timestamp for this invalidation, so we can avoid caching the result of possible already started
      // processing being done for this module
      mod.lastInvalidationTimestamp = timestamp
    }

    // Don't invalidate mod.info and mod.meta, as they are part of the processing pipeline
    // Invalidating the transform result is enough to ensure this module is re-processed next time it is requested
    mod.transformResult = null
    mod.ssrTransformResult = null
    mod.ssrModule = null
    mod.ssrError = null

    mod.importers.forEach((importer) => {
      if (!importer.acceptedHmrDeps.has(mod)) {
        // If the importer statically imports the current module, we can soft-invalidate the importer
        // to only update the import timestamps. If it's not statically imported, e.g. watched/glob file,
        // we can only soft invalidate if the current module was also soft-invalidated. A soft-invalidation
        // doesn't need to trigger a re-load and re-transform of the importer.
        // 这边是判断导入模块是否soft invalidate 的方式
        // 1. 导入模块中导入当前模块是静态导入方式
        //  	App.vue 中 import { foo } from './foo.ts', 此时App.vue就是soft invalidate状态
        // 2. 当前模块已经是soft invalite了,那么其导入模块也是soft invalidate方式
        //  	App.vue是soft invalidate,	其导入模块也是soft invalidte方式
        const shouldSoftInvalidateImporter =
          importer.staticImportedUrls?.has(mod.url) || softInvalidate
        this.invalidateModule(
          importer,
          seen,
          timestamp,
          isHmr,
          shouldSoftInvalidateImporter,
        )
      }
    })
  }

上面代码中,我们把当前的transormResult存储到moduleNode.invalidationState中,等下vite加载模块的时候也是通过invalidationState来判断当前模块是否是soft invalidate状态。

doTransform

typescript 复制代码
async function doTransform(
  url: string,
  server: ViteDevServer,
  options: TransformOptions,
  timestamp: number,
) {
  url = removeTimestampQuery(url)

  const { config, pluginContainer } = server
  const prettyUrl = debugCache ? prettifyUrl(url, config.root) : ''
  const ssr = !!options.ssr

  const module = await server.moduleGraph.getModuleByUrl(url, ssr)

  // tries to handle soft invalidation of the module if available,
  // returns a boolean true is successful, or false if no handling is needed
  const softInvalidatedTransformResult =
    module &&
    (await handleModuleSoftInvalidation(module, ssr, timestamp, server))
  if (softInvalidatedTransformResult) {
    debugCache?.(`[memory-hmr] ${prettyUrl}`)
    return softInvalidatedTransformResult
  }

  // check if we have a fresh cache
  const cached =
    module && (ssr ? module.ssrTransformResult : module.transformResult)
  if (cached) {
    debugCache?.(`[memory] ${prettyUrl}`)
    return cached
  }

 // ... 和vite4.5.1中一致 省略
}

vite5.0 在加载模块内容的时候,在之前判断缓存的基础上追加了soft invalidate的判断。

当一个模块是soft invalidate的时候,那么就要去更新其中静态导入模块的时间戳。

时间戳更新完毕后,如有结果则会返回当前的结果。

处理静态导入模块时间戳的方法是handleModuleSoftInvalidation, 其代码如下:

typescript 复制代码
async function handleModuleSoftInvalidation(
  mod: ModuleNode,
  ssr: boolean,
  timestamp: number,
  server: ViteDevServer,
) {
  // 是否能进行soft invalidate
  const transformResult = ssr ? mod.ssrInvalidationState : mod.invalidationState

  // Reset invalidation state
  // 重置状态
  if (ssr) mod.ssrInvalidationState = undefined
  else mod.invalidationState = undefined

  // Skip if not soft-invalidated
  // 此时不能soft invalidte
  if (!transformResult || transformResult === 'HARD_INVALIDATED') return
	
  if (ssr ? mod.ssrTransformResult : mod.transformResult) {
    throw new Error(
      `Internal server error: Soft-invalidated module "${mod.url}" should not have existing transform result`,
    )
  }

  // 能够soft invalidte
  let result: TransformResult
  // For SSR soft-invalidation, no transformation is needed
  // ssr 过程无需考虑 
  if (ssr) {
    result = transformResult
  }
  // For client soft-invalidation, we need to transform each imports with new timestamps if available
  // 修改url
  else {
    await init
    const source = transformResult.code
    const s = new MagicString(source)
    const [imports] = parseImports(source)

    for (const imp of imports) {
      let rawUrl = source.slice(imp.s, imp.e)
      if (rawUrl === 'import.meta') continue

      const hasQuotes = rawUrl[0] === '"' || rawUrl[0] === "'"
      if (hasQuotes) {
        rawUrl = rawUrl.slice(1, -1)
      }

      const urlWithoutTimestamp = removeTimestampQuery(rawUrl)
      // hmrUrl must be derived the same way as importAnalysis
      // 处理hmrUrl
      const hmrUrl = unwrapId(
        stripBase(removeImportQuery(urlWithoutTimestamp), server.config.base),
      )

      // 找到所有的静态导入的模块并更新其时间戳
      for (const importedMod of mod.clientImportedModules) {
        if (importedMod.url !== hmrUrl) continue
        if (importedMod.lastHMRTimestamp > 0) {
          const replacedUrl = injectQuery(
            urlWithoutTimestamp,
            `t=${importedMod.lastHMRTimestamp}`,
          )
          const start = hasQuotes ? imp.s + 1 : imp.s
          const end = hasQuotes ? imp.e - 1 : imp.e

          // 修改导入的url
          s.overwrite(start, end, replacedUrl)
        }

        if (imp.d === -1 && server.config.server.preTransformRequests) {
          // pre-transform known direct imports
          server.warmupRequest(hmrUrl, { ssr })
        }

        break
      }
    }

    // Update `transformResult` with new code. We don't have to update the sourcemap
    // as the timestamp changes doesn't affect the code lines (stable).
    // 修改transformResult, sourcemap无需修改 transformResult加了时间戳,代码行数不影响
    const code = s.toString()
    result = {
      ...transformResult,
      code,
      etag: getEtag(code, { weak: true }),
    }
  }

  // Only cache the result if the module wasn't invalidated while it was
  // being processed, so it is re-processed next time if it is stale
  // 当前时间大于上次更新时间 则存储下当前的处理结果
  if (timestamp > mod.lastInvalidationTimestamp) {
    if (ssr) mod.ssrTransformResult = result
    else mod.transformResult = result
  }

  return result
}

总结

vite5.0中通过针对静态导入的模块,通过更新导入url时间戳方式,避免了模块重新加载。

上面内容如果有错误,请欢迎指出~~☺️

相关推荐
zwhfyy26 分钟前
webstorm 设置总结
前端·ide·webstorm
前端青山1 小时前
Node.js + MongoDB + Vue 3 全栈应用项目开发
开发语言·前端·javascript·vue.js·mongodb·node.js
wakangda1 小时前
使用 React Native WebView 实现 App 与 Web 的通讯
前端·react native·react.js
练习两年半的工程师2 小时前
使用vite构建一个react网站,并部署到Netlify上
前端·react.js·前端框架
王哲晓3 小时前
第三十六章 Vue之路由重定向/404页面设置/路径模式设置
前端·vue.js
Fan_web3 小时前
Node.js——fs模块-相对路径的bug与解决
开发语言·前端·node.js·bug
拼图2093 小时前
Vue中箭头函数和普通函数的区别
前端·javascript·vue.js
有一个好名字4 小时前
解决路由缓存问题
前端·vue.js
前端熊猫4 小时前
keep-alive的tab栏内容缓存
前端·vue.js·缓存
一條狗4 小时前
20241108 “postinstall“: “electron-builder install-app-deps“ 導致無法正常下載依賴
前端·javascript·electron