Skip to content
Nuxt.js 数据获取与状态管理
概述
Nuxt 提供了几套数据获取工具,它们的关系就像是一套逐渐封装起来的管线:底层是 $fetch,上层是 useAsyncData,最顶层是 useFetch。同时,为了在组件之间共享状态,还有 useState 以及与 Pinia 的集成方案。下面逐一说明这些工具的行为、参数以及在服务端与客户端之间的数据流。
$fetch
$fetch 是 Nuxt 内建的全局 HTTP 客户端,底层基于 ofetch。它可以在组件、组合式函数、插件、服务端 API 路由里直接调用,不依赖任何 setup 环境。
ts
// 在任何地方都可以用,不限于 setup
const response = await $fetch('/api/hello', {
method: 'POST',
body: { name: 'nuxt' }
})它返回的是原生的服务器响应数据,不带响应式包装,也不会自动去重或缓存。也就是说,如果在页面上多次调用同一个 $fetch,每次都真的会发起一次网络请求,并且在客户端导航时也不会复用服务端已经获取过的数据。
这就导致直接用 $fetch 做页面数据获取时会带来两个问题:第一,在服务端渲染时拿到的数据会在 HTML 中丢失,客户端挂载后还会再请求一次,白白浪费一次网络调用;第二,客户端拿到的数据可能与服务端不同,造成水合不匹配。
所以 $fetch 更适合用在一些不承担页面内容渲染的场景里:事件处理器、插件初始化、服务端的 API 路由转发,或者只在客户端执行的逻辑。页面级的初始数据获取,应该交给 useFetch 或 useAsyncData。
useAsyncData
useAsyncData 是 Nuxt 提供的通用数据获取组合式函数。它的职责很简单:接收一个异步函数,在服务端等待它执行完后再渲染页面,并把拿到的数据序列化到 payload 里,客户端激活时直接读这份数据,不用再发请求。
ts
const { data, pending, error, refresh } = await useAsyncData(
'mountains',
() => $fetch('/api/mountains')
)这里第一个参数 'mountains' 是 key。Nuxt 在服务端拿到数据后会把 data 以这个 key 为标识存入 payload,客户端用相同的 key 把它读回来。如果省略 key,Nuxt 会根据调用位置自动生成,但那不太可靠,建议显式指定。
返回值里的几个重要字段:
data:响应式引用,成功时指向异步函数的返回值,初始为null。pending:一个布尔值的 ref,标识请求是否还在进行中。error:错误信息的 ref。refresh:可以手动再次执行 handler 的函数。
useAsyncData 并不管你怎么获取数据,handler 里可以用 $fetch,也可以用 axios、数据库调用、甚至写一段假数据。只要它返回一个 Promise 或者数据,Nuxt 就能正确序列化。
useFetch
useFetch 可以看作 useAsyncData + $fetch 的糖。大部分页面数据请求直接用这个就够了。
ts
const { data, pending, error, refresh } = await useFetch('/api/mountains')它会自动调用 $fetch 发起请求,并把结果塞进 useAsyncData 的管线。key 会根据 URL 和传入的 options 自动生成,因此同一个请求在同一个页面里只会执行一次——这就是所谓的请求去重。
options 里常用的参数
method:HTTP 方法,默认'get'。query:查询参数对象,会自动拼接到 URL。body:请求体,适用于 POST、PUT 等。headers:自定义请求头。transform:一个函数,在数据到达后对data做一层转换再返回,适合做数据格式适配。
ts
const { data } = await useFetch('/api/search', {
method: 'POST',
body: { keyword: 'nuxt' },
transform: (res) => res.items
})值得留意的是 transform 并不影响 key 的生成,所以即使两个请求的 URL 和参数完全一样,如果它们的 transform 不同,也只会发出一次请求,拿到原始数据后各自转换。
key、缓存与请求去重
useFetch 默认的 key 基于请求 URL 与 options(method、query、body 等)生成。这意味着同一页面里,相同 URL 和参数的 useFetch 只会执行一次,后续调用直接复用第一次的结果。
这个去重是“同一页面内”的,不是跨页面的全局缓存。当你从页面 A 导航到页面 B,页面 B 即使在服务端已经渲染过,客户端再导航回去时,useFetch 还是会重新请求(除非设置了某些额外缓存策略)。
如果需要更精细的控制,可以通过 key 选项手动指定:
ts
const { data } = await useFetch('/api/mountains', {
key: 'mountain-list'
})同一个 key 的 useAsyncData 或 useFetch 会共享数据,哪怕它们在不同的组件里。这可以用来做跨组件的数据复用。
dedupe 选项可以调整去重行为:'cancel' 表示如果发现有相同 key 的请求已经存在,就取消当前的;'defer' 则等待已存在的请求完成。默认行为是复用已有结果,不发起新请求。
双端数据获取
在服务端渲染期间,useFetch 和 useAsyncData 会阻塞页面渲染,直到数据返回。数据会被序列化进一个叫 payload 的 JSON 对象,注入到 HTML 里。
当客户端首次加载这个页面时,Nuxt 会检测到 payload 中已经存在对应 key 的数据,于是不再发起请求,直接把数据交还给 data。这就避免了客户端二次请求,也保证了服务端和客户端取得的数据一致。
如果是客户端导航(比如点击 NuxtLink 跳到一个新页面),情况就不一样了:目标页面在客户端组装,这时 useFetch 会真正发起网络请求去获取数据,因为客户端没有那份 payload。所以在客户端导航场景,需要考虑加载状态的展示。
lazy 模式
默认情况下,假如数据还在请求中,整个路由导航就会被阻塞,直到数据回来页面才会渲染。这经常是想要的行为,尤其在 SSR 时希望能一次返回完整的 HTML。
但有时你希望优先显示页面框架,数据可以异步到达,这时就开启 lazy: true:
ts
const { data, pending } = await useFetch('/api/slow-endpoint', { lazy: true })pending 在数据回来之前为 true,可以用它来显示骨架屏或者加载指示器。这类似于把 useFetch 变成一个异步不阻塞导航的操作。
这个行为在客户端导航时尤其有用,因为用户点击链接后期望页面立刻切换,不想等数据。
refresh 与 execute
refresh 是 useFetch 和 useAsyncData 返回值里的一个方法,可以手动重新执行数据获取。比如在按钮点击、下拉刷新或某个事件触发时:
ts
const { data, refresh } = await useFetch('/api/sessions')
// 某个按钮点击事件里
await refresh()refresh() 返回一个 Promise,会在新数据到达后 resolve。在这期间 pending 会变成 true,data 保持旧值直到新数据到来。
还有一个 execute,它是 refresh 的别名。如果设置了 immediate: false,即一开始不自动执行请求,就必须通过 execute() 来触发首次请求:
ts
const { data, execute } = await useFetch('/api/query', { immediate: false })
// 在某个事件里
await execute()初次请求和后续刷新都走同一个入口,只是 immediate 控制是否在 setup 阶段自动执行。
useState 跨组件共享状态
Nuxt 内置了一个 useState 组合式函数,用来在组件之间共享响应式状态。它的 key 机制保证了在服务端和客户端数据能够通联。
ts
// 在任意组件中
const count = useState('counter', () => 0)
// 在其他组件中
const count = useState('counter') // 拿到的是同一个 ref首次调用时提供 factory 函数给出默认值;后续用同一个 key 调用,就直接返回已有状态。
服务端渲染时,Nuxt 会把 useState 的值序列化进 payload,客户端激活时通过相同的 key 恢复响应式状态。所以 useState 只能存放可序列化的数据——数字、字符串、对象、数组等,不能放函数、类实例、Promise 等不可序列化的内容。
因为依赖 key 的唯一性,选择 key 时要避免冲突。它的功能类似一个全局的响应式存储器,但不需要 Pinia 也能用,适合一些轻量的跨组件共享。
集成 Pinia
如果状态结构较复杂,或者在多个页面间需要更精细的管理,Pinia 是官方推荐的方案。Nuxt 通过 @pinia/nuxt 模块提供了一等公民级的集成。
安装:
bash
npx nuxi module add pinia或者手动安装并配置:
bash
npm install @pinia/nuxt pinia然后在 nuxt.config.ts 的 modules 中加上 '@pinia/nuxt'。
之后就可以使用 defineStore 定义 store。Nuxt 会自动导入 defineStore,不需要手动引入。
ts
// stores/counter.ts
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})在页面组件的 script setup 中直接使用:
ts
const store = useCounterStore()默认情况下,Pinia 的 store 在服务端会为每个请求创建独立的实例,避免跨请求的污染。用到的数据会被序列化并注入到 payload,客户端激活时恢复。
如果想在服务端预取数据填充 store,可以在页面的 setup 里先发请求,再把结果写入 store:
ts
const { data } = await useFetch('/api/initial-data')
const store = useMainStore()
if (data.value) {
store.loadData(data.value)
}服务端拿到 data 后写入 store,store 的状态自动序列化到 payload,客户端就能直接从 store 读到这份数据。
页面数据流编排
在一个典型的页面中,可能会同时用到 useFetch 获取列表数据、Pinia store 管理筛选条件、useState 共享某个全局计数器,还需要处理加载状态和错误。
一个常见的模式:
ts
<script setup lang="ts">
const searchStore = useSearchStore()
const { data, pending, error, refresh } = await useFetch('/api/mountains', {
query: computed(() => ({ keyword: searchStore.keyword }))
})
</script>如果请求失败了,error 会有值。可以直接把错误交给 <NuxtErrorBoundary> 处理:
html
<template>
<NuxtErrorBoundary>
<div v-if="pending">加载中...</div>
<div v-else-if="error">出错了:{{ error.message }}</div>
<div v-else>
<!-- 渲染 data.data -->
</div>
</NuxtErrorBoundary>
</template>NuxtErrorBoundary 会捕获子组件树中抛出的错误,并展示预设的错误界面。也可以通过 #error 插槽自定义错误展示。
这种组织方式的核心在于:页面的数据入口统一用 useFetch(或 useAsyncData),由它负责服务端预取、客户端复用以及加载/错误状态的管理;业务状态则放到 Pinia 或 useState 里,与数据获取管线解耦。
注意点
- 别在组件里直接拿
$fetch做页面数据获取。除非在事件处理器里做一次性的客户端请求,否则一定要用useFetch或useAsyncData,不然服务端获取的数据到客户端就丢了,且会白白重复请求,还可能水合失败。 useFetch的 key 去重只作用于当前页面已存在的请求,不会在页面间缓存。从列表页进入详情页再返回,列表页会重新请求数据,除非配合keepalive或自己缓存。refresh期间data保留旧值,这是为了让 UI 在刷新时不出现空白。如果需要清空旧数据,可以手动在调用refresh前设置data.value = null,或者在transform里处理。- 手动指定 key 避免冲突。当多个
useFetch调用 URL 和参数完全相同时,它们会自动去重,这可能是你想要的;但如果两个不同的请求因为 URL 相同而被错误合并,就需要通过key分开。同样,跨组件的共享如果有意为之,就让它们共用同一个 key。 transform里不能做异步操作,它只是一个同步的格式化函数。如果需要对原始数据做复杂的处理,可以在拿到data之后通过computed处理。useState不适合放不可序列化数据。比如不要把 WebSocket 实例、函数或者 DOM 引用丢进去。序列化之后这些都会丢失或变成空对象。- 使用 Pinia store 的 setup 语法时,服务端每个请求都会创建一个 store 实例,不要用模块级的变量来共享状态,否则线上会出现数据窜台的问题。
参考链接
- [1] https://nuxt.com/docs/api/utils/dollarfetch
- [2] https://nuxt.com/docs/api/composables/use-fetch
- [3] https://nuxt.com/docs/api/composables/use-async-data
- [4] https://nuxt.com/docs/getting-started/data-fetching
- [8] https://nuxt.com/docs/api/composables/use-state
- [10] https://pinia.vuejs.org/ssr/nuxt.html
- [11] https://pinia.vuejs.org/core-concepts/
- [12] https://nuxt.com/docs/getting-started/data-fetching#why-use-usefetch
