Skip to content

Nuxt useAsyncData 缓存键的共享陷阱:缓存、去重与那个“串数据”的 Bug ​

译注:本文译自 dev.to 作者 parsajiravand 的技术文章,原文链接见文末。文章基于 Nuxt 4.5.x 编写,Nuxt 3 已于 2026 年 7 月 31 日结束生命周期。文中代码使用 Nuxt 4 的 app/ 目录约定,若项目仍沿用 Nuxt 3 的扁平结构,逻辑完全一致,仅目录位置不同。

同一个页面上有两张商品卡片,用两个不同的 ID 分别请求数据,结果两张卡片都显示 1 号商品。控制台没有报错,网络面板里也没有失败请求——useAsyncData 完全按你的指令执行了,问题在于你下达的指令并不是你以为的那条。

这是 Nuxt 中最常见的“除了这个封装以外哪儿都正常”类 Bug 之一,根源是一个极易被忽略的事实:useAsyncData 的缓存键并非来自你请求的数据,而是来自你在源码中调用它的位置。

问题:两个商品,一份数据 ​

假设你写了一个小的封装 composable 来保持组件整洁:

typescript
// app/composables/useProduct.ts
export function useProduct(id: Ref<string> | string) {
  return useAsyncData(() => $fetch(`/api/products/${unref(id)}`))
  //                    ^ 没有传 key —— 看起来无害
}

然后在同一个页面里用了两次:

vue
<script setup lang="ts">
const { data: featured } = useProduct('sku-101')
const { data: related } = useProduct('sku-204')
</script>

<template>
  <ProductCard :product="featured" />
  <ProductCard :product="related" />
  <!-- 两张卡片都渲染 sku-101 -->
</template>

两张 <ProductCard> 显示同一个商品。没有抛错,因为网络层面没有任何异常——两个请求甚至可能都正确发出了。Bug 出在网络之前:Nuxt 把两次调用交给了同一个缓存条目,于是第二次调用的结果覆盖(或直接复用了)第一次的结果。

直觉上你会怪 $fetch、怪 API、怪竞态条件。都不是。问题在 key。

心智模型:key 是这次请求的身份,不是标签 ​

核心心智模型: 每一次 useAsyncData(以及 useFetch,它本质上是把 URL 折进去的 useAsyncData)调用,背后都是页面级共享存储中的一个条目,以字符串为键。对 Nuxt 来说,相同 key 的两次调用就是同一次请求——它们共享同一个 data ref、同一个 pending ref、同一个 error ref,以及同一个进行中的请求。而不同 key 的两次调用则完全无关,哪怕它们请求的是同一个 URL。

key 不是你给已有数据贴的标签,它是 Nuxt 判断应用中两次调用是否在要同一样东西的依据。如果你不提供 key,Nuxt 就必须自己造一个——它通过构建期编译步骤,读取 useAsyncData() 在源码中所在的文件与行号,把该位置转换成一个确定性的自动 key。

陷阱就在这里:编译器看到的是 useProduct.ts 内部的调用点,而不是你页面里的调用点。useProduct('sku-101') 和 useProduct('sku-204') 都解析为同一文件同一行上的 useAsyncData() 调用——也就是封装函数体。因此无论传入哪个 ID,两者都拿到同一个自动生成的 key。封装恰好藏起了 Nuxt 用来区分这两次调用的唯一信息。

阶段一:最小的 useAsyncData / useFetch 调用 ​

直接写在页面或组件里(不经封装)时,显式 key 能消除一切歧义:

vue
<script setup lang="ts">
const route = useRoute()

// 关键概念:key 表明“这是 product-<id>”,除此之外没有别的依据
const { data: product, status } = useAsyncData(
  `product-${route.params.id}`,
  () => $fetch(`/api/products/${route.params.id}`)
)
</script>

useFetch 是常见场景的简写——本质上就是“响应式地 GET 这个 URL”:

vue
<script setup lang="ts">
const route = useRoute()

// useFetch 会从 method、URL 和响应式选项中推导出自己的 key——
// 这里很少需要显式传入,因为 URL 本身已经携带了 id。
const { data: product } = useFetch(() => `/api/products/${route.params.id}`)
</script>

关键概念: useFetch 的隐式 key 已经把 URL 包含在内,因此响应式 URL(如上例中的箭头函数)会自然地为每个商品生成不同的 key。正是这个细节让 useFetch 默认感觉比 useAsyncData 更安全——它的自动 key 来自真正随数据变化的东西,而不是源码位置。

阶段二:不传 key 时会发生什么 ​

在页面里直接调用 useAsyncData、中间没有封装时,文件加行号的自动 key 工作正常——代码库中每个不同的调用点,按定义就是不同的行。陷阱只在把 useAsyncData 调用抽进一个被不同调用方以不同参数调用的共享函数时才出现——而这恰恰是 Vue 和 Nuxt 平时鼓励的“抽成 composable 以复用”的直觉。

typescript
// app/composables/useProduct.ts —— 真正能工作的版本
export function useProduct(id: Ref<string> | string) {
  return useAsyncData(
    `product-${unref(id)}`,           // key 现在随参数变化
    () => $fetch(`/api/products/${unref(id)}`)
  )
}

修复只有一行:传入一个由参数而非调用点构建的 key。同样的规则适用于任何围绕 useAsyncData、useLazyAsyncData 或手写数据请求钩子编写的 composable——一旦 useAsyncData 调用被包进一个会被多处用不同输入调用的函数,这个 key 就必须显式指定,并且必须包含这些输入。

阶段三:dedupe —— cancel 与 defer ​

key 决定两次调用是否是同一次请求;dedupe 决定当同一个 key 在请求仍在进行中时被再次请求会发生什么。它只有两个取值,Nuxt 默认是 'cancel':

  • dedupe: 'cancel'(默认)——中止该 key 进行中的请求,重新发起一个。适用于只有最新请求才重要的场景:每次按键都重新请求的搜索框、筛选面板,以及任何旧的在途响应到达时已经过时的情况。
  • dedupe: 'defer'——如果该 key 已有请求在途,就不再发起第二个;新调用复用这个进行中的请求。适用于请求昂贵或有副作用、对同一 key 重复触发纯属浪费的场景(同一次渲染中两个组件同时请求同一个 key,或用户可能双击的按钮)。
typescript
// 类型提示:只有最新一次按键的结果应该胜出。
const { data: results } = useAsyncData(
  () => `search-${query.value}`,
  () => $fetch('/api/search', { query: { q: query.value } }),
  { watch: [query], dedupe: 'cancel' }
)
typescript
// 两个仪表盘组件同时需要的昂贵报表:
// 一个在途请求对两者来说就够了。
const { data: report } = useAsyncData(
  'quarterly-report',
  () => $fetch('/api/reports/quarterly'),
  { dedupe: 'defer' }
)

关键概念: dedupe 不是防抖。防抖延迟的是调用的发起;dedupe 决定的是对同一 key 已经发起的调用如何处理。两者常常需要同时使用——先对按键防抖,再让 dedupe: 'cancel' 处理仍然重叠的请求。

阶段四:key 如何连接服务端与客户端 ​

SSR 期间,Nuxt 会解析页面的 useAsyncData/useFetch 调用,并把结果序列化进一个 payload——一份随 HTML 一起发送到浏览器的纯数据快照,其键正是前面一直在说的那些字符串。在客户端,hydration 不会从头重跑请求:对每个 key,Nuxt 的 getCachedData 步骤会先查 payload。如果 key 存在,就立即使用缓存值,处理函数在客户端根本不会执行。

这也是重复自动 key 危险的另一个原因,且不只是表面上的错误:它意味着服务端对你以为的两份不同数据也只跑了一次请求,所以 payload 从一开始就只包含 1 号商品的响应。这个 Bug 并非纯粹的客户端渲染故障——错误的数据在服务端只被请求了一次,然后被忠实地送到了浏览器。

阶段五:refresh、clear 与 watch ​

useAsyncData 返回的不只是 data:还有 status、pending、error、refresh(别名 execute 作用相同)以及 clear。

vue
<script setup lang="ts">
const { data: product, refresh, clear } = useAsyncData(
  `product-${route.params.id}`,
  () => $fetch(`/api/products/${route.params.id}`)
)
</script>

<template>
  <button @click="refresh()">Reload this product</button>
  <button @click="clear()">Reset</button>
</template>
  • refresh() / execute()——为该次调用的 key 重新执行处理函数,并原地更新 data。这是从拥有该调用的组件内部强制重新请求的正确方式。
  • clear()——把 data 重置为 undefined(或配置的默认值),error 重置为 undefined,status 重置为 idle,但不重新请求。
  • refreshNuxtData(key?) / clearNuxtData(key?)——同样两个操作,可在任意位置按 key 调用,适用于你手上没有原始 composable 调用引用的场景(例如一个组件里的“保存”动作需要让另一个组件渲染的列表失效)。
  • watch: [...]——一组响应式来源;其中任何一个变化时,Nuxt 会自动替你调用 refresh()。这正是阶段三搜索示例无需手写 watch() 块就能在每次按键时重新请求的原因。

关键概念: 改变 key 与添加 watch 来源是让请求具备响应性的两种不同方式,不可互换。变化的 key 会为每个值生成独立的缓存条目(当你希望保留旧结果时有用,比如分页列表的缓存页);watch 来源则原地重新请求同一个条目并覆盖它(当你只关心当前值时有用,比如实时搜索)。

边界情况与坑 ​

  • 路由参数没有体现在 key 里。 在动态 [id].vue 页面上写 useAsyncData('product', ...),会让用户在客户端导航到的每个商品路由都复用同一个缓存条目——这就是经典的“导航时旧数据闪现一下”的 Bug。务必把参数折进 key。
  • useLazyAsyncData 不阻塞导航,但用的是同一套 key 机制。 一个与页面别处非 lazy 调用共享 key 的 lazy 调用,仍然参与同一套 dedupe 和缓存条目——“lazy”只改变 Nuxt 是否在渲染前等待它,不改变其 key 的行为。
  • 两个组件在同一页面请求同一个 key 往往是刻意为之,而非 Bug——这正是 Nuxt 避免为两个组件都需要的数据跑两次网络往返的方式。本文描述的失败模式恰恰相反:在你不希望它们碰撞时,key 却碰撞了。

最佳实践:可信赖的 key 与 dedupe ​

  • 任何会被多处用不同输入调用的封装 composable,都必须显式传入包含这些输入的 key。
  • 动态路由页面务必把路由参数折进 key。
  • 只有最新结果重要的 UI 用 dedupe: 'cancel';请求昂贵或重复触发浪费的用 dedupe: 'defer'。
  • 想保留历史结果用变化的 key;只关心当前值用 watch 来源原地刷新。
  • 跨组件失效数据时用 refreshNuxtData(key) / clearNuxtData(key),而不是试图传递 composable 引用。

原文链接 ​

https://dev.to/parsajiravand/useasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1