Nuxt3 useFetch缓存问题

8,730 阅读2分钟

最近在使用 Nuxt3 提供的 useFetch 方法时,第一次请求可以正常收发,之后同一个请求就不发送了,所以看了一下 useFetch 的实现,发现是在特定场景下,命中了 nuxt 接口数据缓存。

正常情况:

1.gif

刷新页面后,命中缓存:

2.gif

先看下 useFetch 的 API:

function useFetch(
  url: string | Request | Ref<string | Request> | () => string | Request,
  options?: UseFetchOptions<DataT>
): Promise<AsyncData<DataT>>

type UseFetchOptions = {
  key?: string
  method?: string
  query?: SearchParams
  params?: SearchParams
  body?: RequestInit['body'] | Record<string, any>
  headers?: Record<string, string> | [key: string, value: string][] | Headers
  baseURL?: string
  server?: boolean
  lazy?: boolean
  immediate?: boolean
  default?: () => DataT
  transform?: (input: DataT) => DataT
  pick?: string[]
  watch?: WatchSource[]
}

type AsyncData<DataT> = {
  data: Ref<DataT>
  pending: Ref<boolean>
  refresh: (opts?: { dedupe?: boolean }) => Promise<void>
  execute: () => Promise<void>
  error: Ref<Error | boolean>
}

useFetch 是对 useAsyncData$fetch 的封装,默认情况下,如果 url 或者 options 是响应式的数据,会自动触发请求,更新请求结果。 这会带来一些方便,比如常见的列表搜索场景,只需将筛选条件变成响应式数据,作为 options 调用 useFetch 即可。 不过,有些场景下可能不适用,比如需要组合几个筛选条件再去查询,就可能在每个条件变化的时候多触发了几次查询请求。 useFetch 还可以通过 options 的 server 字段方便的实现 SSR。

接口数据缓存

问题出现:
商品加购请求 /cart/{id} 支持重复发送,测试过程中发现,第一次发送加购请求,收到响应之后,后续同一商品加购请求不会再次发出了。单步调试发现,命中了nuxt内部的接口数据缓存。 useFetch 显示调用以及监听到 url、options 变化,都是调用 refresh方法,只是_initial参数不同。

简化后的源代码:

 asyncData.refresh = asyncData.execute = (opts = {}) => {
    // Avoid fetching same key that is already fetched
    if (opts._initial && hasCachedData()) {
      return getCachedData()
    }
    asyncData.pending.value = true
    const promise = new Promise<ResT>(
      (resolve, reject) => {
        try {
          resolve(handler(nuxt))
        } catch (err) {
          reject(err)
        }
      })
      .then((_result) => {
        asyncData.data.value = result
        asyncData.error.value = null
      })
      .catch((error: any) => {
        asyncData.error.value = error
        asyncData.data.value = unref(options.default?.() ?? null)
      })
      .finally(() => {
        asyncData.pending.value = false
        nuxt.payload.data[key] = asyncData.data.value
        }
      })
    nuxt._asyncDataPromises[key] = promise
    return nuxt._asyncDataPromises[key]
  }

可以看到,接口请求之后,在 finally 中更新了 nuxt.payload.data

nuxt.payload.data[key] = asyncData.data.value

下一次请求,首先判断 nuxt.payload.data 有没有缓存,有缓存不再重复请求

if (opts._initial && hasCachedData()) {
  return getCachedData()
}
const getCachedData = () => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key]

命中缓存的条件

1. opts._initial === true
  • client端主动触发的请求 _initial 的值都是 ture
  • watch监听到依赖变化和 app:data:refresh hook 触发的请求 _initial 为 false,不会命中缓存
2. nuxt.isHydrating === true

创建nuxt实例:

  function createNuxtApp (options: CreateOptions) {
      let hydratingCount = 0
      const nuxtApp: NuxtApp = {
        isHydrating: process.client,
        deferHydration () {
          if (!nuxtApp.isHydrating) { return () => {} }
          hydratingCount++
          let called = false
          // deferHydration返回方法,后面统一叫做 cb
          return () => {
            if (called) { return }
            called = true
            hydratingCount--
            if (hydratingCount === 0) {
              nuxtApp.isHydrating = false
              return nuxtApp.callHook('app:suspense:resolve')
            }
          }
        }
      }
      return nuxtApp
    }

client端应用初始化时,isHydrating 为 ture,调用 deferHydration 的返回方法 cb 才可以将 isHydrating 的值置为 false,有两个时机:

  • page:finish hook 的回调方法
  • nuxt-root Suspense的 resolve 回调方法
      <Suspense @resolve="nuxtApp.deferHydration()">
        <ErrorComponent v-if="error" :error="error" />
        <IslandRenderer v-else-if="islandContext" :context="islandContext" />
        <component :is="SingleRenderer" v-else-if="SingleRenderer" />
        <AppComponent v-else />
      </Suspense>
    
3. nuxt.payload.data[key]通过 key 查询到接口数据缓存

isHydrating 为 true 时,请求就会通过 key 去查接口数据缓存

a unique key to ensure that data fetching can be properly de-duplicated across requests, if not provided, it will be generated based on the static code location where useAsyncData is used.

key如果不传的话,会默认生成一个:

 function useFetch(request, arg1, arg2 ) {
   const [opts = {}, autoKey] = typeof arg1 === 'string' ? [{}, arg1] : [arg1, arg2]
   const _key = opts.key || hash([autoKey, unref(opts.baseURL), typeof request === 'string' ? request : '', unref(opts.params || opts.query)])
   if (!_key || typeof _key !== 'string') {
     throw new TypeError('[nuxt] [useFetch] key must be a string: ' + _key)
   }
   const key = _key === autoKey ? '$f' + _key : _key
   // ...
 }
  • arg2 是一个默认的 hash,也就是上面代码中的 autoKey,在 nuxt 的插件中被自动生成,实际就是通过 AST 找到 useFetch,然后根据一系列的规则,生成这样一个 hash,作为 useFetch 的第三个参数

image.png 具体的生成规则感兴趣的朋友可以自行翻阅packages/vite/src/plugins/composable-keys.ts 查看。

  • _key的优先级:options.key > 通过 autoKey,opts.baseURL,opts.params,opts.query 等计算出的 hash
正常流程

渲染 <AppComponent />(即 nuxt-root ) -> 渲染 <NuxtPage /> -> 执行 layout supsense 的 resolve cb -> 执行 NuxtPage 回调 cb ( Suspense 的 reslove 事件)

由于 cb 是个闭包,第一次执行时 hydratingCount === 2,执行两次之后 hydratingCount === 0,所以会将 isHydrating 置为 false,client端同构完成。后续路由发生变化,重新渲染 NuxtPage,执行 deferHydration,由于 isHydrating 为 false, cb 是一个空方法,不会再更新 isHydrating, 就不会命中缓存;

即开启服务端渲染时,server 端将接口数据同步到 nuxt.payload.data 中,client 端初次渲染时,不再重复请求;后续 update 流程才去正常请求;

异常流程
  • url 和 options 都不是 响应式数据,且请求都是用户手动触发的,满足 _initial === true
  • 某一个页面作为首屏或者刷新时,在 setup 或者 watch immediate 中使用 useRouter().replace 改变了 query 参数,触发 NuxtPage 重新渲染,这时 hydratingCount 会累加;RouterView 匹配到同一个组件,Suspense resolve事件只执行一次,hydratingCount 没有减小到 0,nuxtApp.isHydrating 不会更新,还是 true
  • options 中没有提供 key,由 nuxt 自动生成,由于 opts.baseURL,opts.params,opts.query 都没有提供,所以都是 undefined,这样每次计算出来的 _key 都是同一个;这样第一次nuxt.payload.data[key]为空,拿到响应赋值后,nuxt.payload.data[key]就会被赋值

三个条件都满足,所有就命中了 nuxt 的接口数据缓存

清除接口数据缓存
  1. 如果接口的传参不变,但是需要实时请求,可以在useFetch option 传入 key,请求响应使用后手动调用 clearNuxtData(${mykey});,将数据从 nuxt.payload.data中清除。 image.png

  2. 通过上一步,可以把当前请求的缓存清除,但是只是针对这一个请求,其他请求还是会有同样的问题,可以手动将缓存清空或者isHydrating设置为 false image.png

  3. 另一个方法是在onMounted钩子中改变路由,也可以避免缓存问题