要点
数据获取是 Nuxt 与纯客户端框架分野最大的地方:同一行取数代码,要在服务端渲染时跑一次、结果传给浏览器复用,而不是 hydration 后再取一遍。Nuxt 为此给了三个工具——$fetch、useAsyncData、useFetch。这篇把三者的层级关系、SSR 序列化机制、缓存与刷新选项、错误处理一次讲清,并给 React Query 用户一张心智对照表。前置阅读是上一篇的框架认知与Vue 响应式基础。
三者是什么关系
三个 API 不是三选一的并列关系,而是一层包一层的栈:$fetch 是基于 ofetch 的 HTTP 客户端(Nuxt 全局注入,无需安装);useAsyncData 把任意异步操作包装成 SSR 感知的组合式函数(按 key 缓存、结果进 payload);useFetch 是 useAsyncData + $fetch 的便捷组合——”向一个 URL 发请求并 SSR 感知”这一最常见场景的语法糖。
useFetch(url) ≈ useAsyncData(autoKey, () => $fetch(url))
useAsyncData(key, fn) 任意异步:$fetch、数据库、CMS SDK、多请求聚合
$fetch 纯 HTTP 客户端,无 SSR 语义
| 场景 | 用哪个 | 为什么 |
|---|---|---|
| setup 里取一个接口的数据 | useFetch | 自动生成 key、自动处理 SSR 传递,代码最少 |
| 聚合多个请求、查数据库、调 CMS SDK | useAsyncData(内部可再用 $fetch) | 处理函数是任意异步逻辑,不限于单个 URL |
| 事件处理器、表单提交、按钮触发的取数 | $fetch | 用户交互没有”SSR 传递”问题,包一层组合式函数反而绕 |
| server/api 内部调用另一个接口 | $fetch(‘/api/…’) | 服务端内直接调用处理函数,不走 HTTP 环回 |
反模式点名:在 setup 顶层裸用 $fetch 取页面数据——它在服务端渲染时请求一次,浏览器 hydration 后又原样执行一遍,同一个数据请求两次;并且它不阻塞渲染,客户端拿到的是骨架再等数据,SSR 的首屏意义被抵消。setup 里的页面级数据一律 useFetch/useAsyncData。
SSR 数据序列化:payload 与 key
useFetch 最”魔法”的部分是数据如何从服务端到达浏览器。流程是:服务端渲染时执行请求,把结果序列化进 HTML 内嵌的 payload(用 devalue 库,支持 Date、Map 等超 JSON 结构);浏览器 hydration 时按 key 从 payload 取回结果,不再发请求。key 因此承担两个职责:同 key 的调用共享缓存与 payload 槽位;key 就是组件树里这笔数据的”身份证”。
<script setup lang="ts">
// useFetch 用 URL + 选项自动生成稳定 key;不需要手写
const { data, status, error, refresh } = await useFetch('/api/notes', {
// transform 只影响返回给组件的数据,payload 里存的是转换后的结果
transform: (res) => res.notes.map(({ id, title }) => ({ id, title })),
})
</script>
两个由此而来的坑。其一,useAsyncData 需要显式传 key,写成随机值(比如 key: Date.now())等于每次都是一个新身份,payload 缓存全部失效——key 必须稳定且唯一,重名则互相覆盖。其二,序列化是有损的:class 实例会被压成普通对象,函数、Symbol 直接丢失。数据模型里如果有方法或类实例,要么在 transform 里降级成纯数据,要么接受浏览器里拿到的是”数据相同、类型不同”的对象。判断方法很直接:在客户端代码里对 data 做 instanceof,如果是 false,就是序列化边界在提醒你。
缓存与刷新:key、transform、lazy
useFetch/useAsyncData 的选项控制”什么时候取、取多少、何时再取”:
| 选项 | 作用 | 什么时候用 |
|---|---|---|
| key(useAsyncData 必填) | 缓存与 payload 的身份标识 | 永远稳定唯一;同 key 调用共享结果 |
| transform | 在入缓存前裁剪/重塑数据 | 接口返回一大坨、组件只要三五个字段时,瘦数据也瘦了 payload |
| lazy: true | 不阻塞导航,先渲染再取数 | 次要内容:侧栏、评论,首屏不等它 |
| server: false | 只在客户端取数 | 依赖浏览器环境(window、地理定位)或纯个性化数据 |
| immediate: false | 不自动发首请求 | 等用户输入搜索词再 execute(),配合 watch 手动触发 |
| getCachedData | 自定义”何时算已缓存”(Nuxt 3.8+) | 导航回退不想重新请求等精细缓存策略 |
| 返回的 refresh/execute | 手动重取(execute 配合 immediate:false) | 增删改之后刷新列表、下拉刷新 |
<script setup lang="ts">
// 搜索场景:不自动请求,关键词变化才取数
const keyword = ref('')
const { data, execute, status } = await useFetch('/api/notes', {
query: { q: keyword }, // 响应式 query:keyword 变化即自动重取
immediate: false,
lazy: true,
})
// immediate:false 时首次要手动触发一次
onMounted(() => execute())
</script>
注意 lazy 与 server:false 的区别经常被混:lazy 只改变”阻塞不阻塞导航”,请求仍然可能在服务端发起;server:false 才是明确”这件事只在浏览器做”。两个都不设时,useFetch 会等数据回来再渲染——这对首屏指标(LCP 类)通常是正确默认,代价是接口慢时整个页面陪等,所以才有这两个选项。
错误处理
useFetch 不抛异常,错误进返回值的 error(响应式),加载状态看 status——status 是 ‘idle’、’pending’、’success’、’error’ 四态(3.9 起提供);旧的 pending 布尔值在 3.x 仍可用,Nuxt 4 计划统一为 status 并弃用它,新代码建议直接用 status:
<template>
<p v-if="status === 'pending'">加载中</p>
<p v-else-if="error">{{ error.statusCode === 404 ? '不存在' : '出错了' }}</p>
<ul v-else-if="data"><li v-for="n in data" :key="n.id">{{ n.title }}</li></ul>
</template>
// server/api/notes/[id].ts —— 服务端用 createError 带上状态码抛出
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
if (!/^d+$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'id 必须是数字' })
}
// ...
})
页面级兜底两条路:整页错误建 app/error.vue(框架自动渲染,能拿到 error.statusCode);局部交互失败用 showError() 或就地展示 error。原则:预期内的业务错误(404、参数错)在服务端用 createError 明确状态码,预期外的错误别吞——空 catch 看起来稳,实际把 status 停在 pending 的问题藏到更远处。
与 React Query 的心智对照
React 生态里这套问题由 TanStack Query 解决,站内《TanStack Query 基础》讲过它的缓存模型。用过 React Query 再来用 Nuxt,最大的观念转变是:React Query 把”客户端缓存与后台刷新”做成默认,SSR 预取要你手动脱水(dehydrate)注水(hydrate);Nuxt 反过来,把”服务端取数、客户端免重取”做成默认,客户端缓存策略要你自己补。对照表:
| 概念 | TanStack Query | Nuxt |
|---|---|---|
| 缓存身份 | queryKey | key(useAsyncData 显式 / useFetch 自动) |
| 基础组合式 | useQuery | useFetch / useAsyncData |
| 变更后刷新 | useMutation + invalidateQueries | 任意调用后 refresh()/execute() |
| 新鲜度策略 | staleTime / gcTime 声明式 | getCachedData 自定义函数 |
| SSR 数据传递 | 手动 dehydrate/hydrate 或第三方方案 | 内置 payload,key 对上即免重取 |
| 缓存范围 | 全局 QueryClient,跨页面共享 | 组件实例级 + payload,跨页共享要靠同 key 或自建层 |
实操结论:页面内一两处取数,Nuxt 内置足够;一旦出现”多处共享同一份数据、要全局失效、要窗口聚焦重验证”这类复杂客户端缓存诉求,要么把数据上收到服务端接口聚合,要么在 Nuxt 里真的引入 TanStack Query(它有官方 Vue 版),别用一串 getCachedData 模拟出一个残缺的缓存层。
常见问题
Q1: 两个组件 useFetch 同一个 URL,会请求两次吗
会,useFetch 的自动 key 含选项信息,两处调用默认各自请求(服务端合并执行,代价可控)。想共享就改用 useAsyncData 传同一个显式 key,或把数据上提到父组件/provide。
Q2: 为什么我的 Date 进了页面变成字符串
payload 序列化虽然支持 Date,但一旦经过 JSON 接口边界($fetch 返回的是 JSON)日期已是字符串。在 transform 里统一 new Date(n.createdAt),让”类型恢复”发生在数据边界而不是各个组件里。
Q3: onMounted 里 useFetch 行不行
useFetch 必须在 setup 同步上下文调用(await 在顶层),放 onMounted 会拿到非 SSR 感知的行为并可能告警。确实要”挂载后取”用 server: false 或 lazy,语义更明确。
练习与实践
练习一:写一个 server/api/notes 接口,页面 useFetch 展示,用”查看源代码”找到内嵌 payload 里的数据,再注释掉接口重启,确认 hydration 后没有第二次请求。练习二:给列表加 lazy: true 与骨架屏,对比开与关时导航到该页的白屏感受。练习三:故意把接口抛 createError 404,分别体验 error 分支与 app/error.vue。验收标准:能对每个选项说出”不加它默认发生什么”。
延伸阅读
框架认知(目录、路由、渲染模式)回到《Nuxt 入门》;响应式与组合式函数的地基见《Vue 3 语法要点》。系列收官是把整套能力用起来的实战与路线图。
版本核实(撰写时检索,2025-04):$fetch(ofetch)、useFetch、useAsyncData、transform/lazy/server/immediate/getCachedData(自 3.8)、createError、app/error.vue 均为官方文档确认特性;status 四态(idle/pending/success/error)自 3.9 起提供,Nuxt 4 计划以 status 统一并弃用 pending 布尔值;TanStack Query 有官方 Vue 支持(@tanstack/vue-query)。具体以官方文档为准。