Zademy

Nuxt 4:$fetch、useFetch 和 useAsyncData - 完整数据获取指南

Nuxt; Vue; Fetching; SSR; DataFetching
words 字

Nuxt 4 的数据获取有三个层级,每个工具干的活不同。搞混它们是 SSR 项目里最常见的坑——要么双重请求拖慢首屏,要么状态丢了导致水合报错。

三个工具的定位:$fetch 是底层 HTTP 客户端,纯粹发请求,不关心 SSR。useAsyncData 是核心状态原语,负责异步数据的生命周期管理,确保服务端拿到的数据能序列化进 payload 传给客户端。useFetch 是 useAsyncData 加 $fetch 的语法糖,针对单个 GET 端点做了封装。

说白了,useFetch 内部就是调用 useAsyncData + $fetch。

何时使用每个工具

选择标准取决于你在哪用、做什么操作。

1. useFetch:简单的初始数据检索(GET)

页面加载时要从单个端点拉数据,需要 SSR 支持,又不想手动管理状态传输——用 useFetch 就对了。写法最简洁。

典型场景:获取文章列表。

// app/pages/posts.vue
<script setup lang="ts">
  // 负责在服务器上进行一次请求并传输状态。
  const { data: posts } = await useFetch('/api/posts')
</script>

2. useAsyncData:复杂的异步逻辑或非 HTTP 数据源

当你的异步操作不是简单的单端点 HTTP 调用时,useAsyncData 是唯一选择。它接受任意返回 Promise 的 handler,这才是真正的底层原语。

几个场景:

并行调用:需要同时发多个请求,拿到结果后组合返回。

const { data } = await useAsyncData("dashboard", async () => {
	const [user, stats] = await Promise.all([$fetch("/api/user"), $fetch("/api/stats")]);
	return { user, stats };
});

第三方客户端:用 GraphQL 客户端、数据库 ORM 或其他不走 $fetch 的数据源。

// myGetFunction 是您的数据客户端中的自定义函数
const { data } = await useAsyncData("users", () => myGetFunction("users"));

3. $fetch:突变和客户端/服务器驱动的操作

用户点击按钮、提交表单这类纯客户端操作,直接用 $fetch。这些请求不需要 SSR 状态同步,用 useAsyncData 反而是多余的。

突变(POST/PUT/DELETE):用 useFetch 发 POST 通常不划算,因为它依赖 SSR 状态水合,而突变根本不需要。

// 在事件处理程序内使用
async function submitForm() {
	await $fetch("/api/contact", {
		method: "POST",
		body: formData.value
	});
}

内部服务器路由调用:在 Nuxt 的 server/api 路由内部,$fetch 不会真的发 HTTP 请求,而是直接调用处理函数。服务器内部通信零开销。

各自的优势和代价

$fetch 的好处是轻量和直接。内部调用 server/api 时没有 HTTP 往返。代价也明显:在 SSR 组件的 setup 里直接用,客户端水合时会重新发一次请求——经典的"双重获取"。

useAsyncData 控制力最强。它接受任何异步逻辑,保证数据只在服务端请求一次、通过 payload 传给客户端。返回值包括 data、pending、error、status,状态管理很完整。Nuxt 4 里 data 默认是 shallowRef,大型数据结构少了深层响应性的 CPU 开销。代价是需要手写 handler 和唯一 key,比 useFetch 啰嗦。

useFetch 胜在 DX。最简的 SSR 安全写法,自动从 URL 和选项生成 key,还能从服务端路由推断类型。但它只适合对单个端点的 GET 调用,拿来做 POST/PUT/DELETE 不合适。

何时从客户端 vs 服务器端使用

核心判断标准:这次请求的数据需不需要通过 payload 从服务端传到客户端?需要的话必须用支持状态传输的工具。

服务器端(SSR)和通用渲染

SSR 场景下,数据要出现在初始 HTML 中(SEO 和首屏性能的关键)。这时必须用走 payload 机制的工具:

页面或组件中的初始数据检索,用 useFetch 或 useAsyncData。它们确保数据只在服务端获取一次,客户端水合时直接从 payload 读取。

内部 API 路由之间的调用,用 $fetch。Nuxt 会拦截调用直接执行处理函数,跳过真正的 HTTP 请求。

踩坑预警:如果你在 SSR 组件的 <script setup> 里直接用 $fetch 获取初始数据,客户端水合时会重新发请求。双重获取就是这么来的。

客户端

水合后或用户交互触发的操作,不需要状态传输,用 $fetch 最合适。

用户事件触发的突变(POST/PUT/DELETE),直接 $fetch。状态不需要从服务端传过来,useAsyncData 的开销纯属浪费。

非 SEO 关键的数据(比如基于地理位置的内容),可以给 useFetch 或 useAsyncData 加 server: false,让数据获取推迟到客户端水合完成后。

需要手动刷新已有数据,用 useFetch/useAsyncData 返回的 refresh() 或 execute(),不必退回 $fetch。

打个比方:useFetch 和 useAsyncData 是从服务端寄到客户端的保险箱——服务端把数据装进去封好,客户端收到后直接开箱,不用再自己跑一趟。$fetch 就是一通电话,服务端打过一次,客户端还得再打一次才能拿到自己的副本。

高级实际示例

useAsyncData 的优化加载模式

// pages/dashboard.vue
<script setup lang="ts">
// 带有详细状态管理的优化加载
const {
  data: dashboard,
  pending,
  error,
  refresh
} = await useAsyncData('dashboard', async () => {
  // 并行调用以获得更好的性能
  const [user, stats, notifications] = await Promise.all([
    $fetch('/api/user/profile'),
    $fetch('/api/analytics/stats'),
    $fetch('/api/notifications/unread')
  ])

  return {
    user,
    stats,
    notifications
  }
}, {
  // 高级选项
  server: true,        // 在服务器上执行
  lazy: false,         // 等待渲染前
  default: () => ({    // 加载时的默认值
    user: null,
    stats: { views: 0, users: 0 },
    notifications: []
  })
})

// 带防抖的手动刷新
const debouncedRefresh = useDebounceFn(refresh, 300)

// 监视自动刷新
watch(() => route.path, debouncedRefresh)
</script>

带错误处理的 $fetch 突变

// components/ContactForm.vue
<script setup lang="ts">
const formData = ref({
  name: '',
  email: '',
  message: ''
})

const isSubmitting = ref(false)
const submitError = ref<string | null>(null)

async function submitForm() {
  isSubmitting.value = true
  submitError.value = null

  try {
    const response = await $fetch('/api/contact', {
      method: 'POST',
      body: formData.value,
      // 额外选项
      timeout: 10000,
      retry: 2,
      retryDelay: 1000
    })

    // 成功处理
    await navigateTo('/thank-you')
  } catch (error) {
    if (error.data?.message) {
      submitError.value = error.data.message
    } else {
      submitError.value = '提交表单时出错。请重试。'
    }
  } finally {
    isSubmitting.value = false
  }
}
</script>

带数据转换的 useFetch

// pages/products/[id].vue
<script setup lang="ts">
interface Product {
  id: number
  name: string
  price: number
  description: string
  category: string
}

interface ProductWithDetails extends Product {
  formattedPrice: string
  isInStock: boolean
  relatedProducts: Product[]
}

const { data: product, pending } = await useFetch<ProductWithDetails>(
  `/api/products/${route.params.id}`,
  {
    // 数据转换
    transform: (data: Product) => ({
      ...data,
      formattedPrice: new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD'
      }).format(data.price),
      isInStock: data.price > 0,
      relatedProducts: [] // 稍后填充
    }),
    // 高级缓存
    server: true,
    key: `product-${route.params.id}`,
    // 缓存选项
    getCachedData: (key) => {
      // 自定义缓存逻辑
      return nuxtApp.staticData[key] || nuxtApp.payload.data[key]
    }
  }
)

// 之后加载相关产品
watchEffect(async () => {
  if (product.value?.category) {
    const { data: related } = await $fetch<Product[]>(`/api/products/related/${product.value.category}`)
    product.value.relatedProducts = related
  }
})
</script>

最佳实践和模式

1. 唯一键策略

useAsyncData 的 key 决定了 payload 里数据的唯一性。key 冲突会互相覆盖,key 不稳定会导致重复请求。

// ✅ 良好的键策略
const { data } = await useAsyncData(`user-${userId}`, () => $fetch(`/api/users/${userId}`));

// 对于依赖多个参数的数据
const { data } = await useAsyncData(`search-${searchTerm}-${page}-${category}`, () =>
	$fetch("/api/search", {
		query: { q: searchTerm, page, category }
	})
);

2. 加载状态管理

// components/DataLoader.vue
<script setup lang="ts">
interface AsyncDataState<T> {
  data: Ref<T | null>
  pending: Ref<boolean>
  error: Ref<Error | null>
  refresh: () => Promise<void>
}

// 可重用组合式函数
function useAsyncDataWithState<T>(
  key: string,
  handler: () => Promise<T>
): AsyncDataState<T> {
  const { data, pending, error, refresh } = useAsyncData(key, handler)

  return {
    data,
    pending,
    error,
    refresh
  }
}

// 在组件中使用
const { data: posts, pending, error } = useAsyncDataWithState(
  'posts',
  () => $fetch('/api/posts')
)
</script>

<template>
  <div>
    <div v-if="pending" class="loading">
      加载文章...
    </div>

    <div v-else-if="error" class="error">
      错误:{{ error.message }}
      <button @click="refresh">重试</button>
    </div>

    <div v-else-if="data" class="posts">
      <PostCard
        v-for="post in data"
        :key="post.id"
        :post="post"
      />
    </div>
  </div>
</template>

3. useLazyFetch 的网络优化

非关键数据可以延后加载,别让它阻塞首屏渲染。

// 对于可以稍后加载的非关键数据
const { data: comments } = await useLazyFetch(`/api/posts/${postId}/comments`, {
	server: false, // 仅客户端
	lazy: true // 不阻塞渲染
});

// 对于频繁更新的数据
const { data: liveScore } = await useFetch("/api/live-score", {
	server: false,
	refresh: {
		// 每 30 秒刷新
		interval: 30000
	}
});

性能考虑

1. Nuxt 4 中的浅引用

Nuxt 4 默认把 useAsyncData 和 useFetch 返回的 data 做成 shallowRef。这意味着只有顶层引用变化才触发更新,深层属性改动不会。大型数据结构省了大量响应式追踪开销。

// 在 Nuxt 4 中,这默认是 shallowRef
const { data } = await useAsyncData("large-dataset", () => $fetch("/api/large-dataset"));

// 如果需要深度响应性(谨慎使用)
const { data } = await useAsyncData("deep-reactive", () => $fetch("/api/nested-data"), {
	deep: true // 启用深度响应性
});

2. 缓存策略

// 组件级缓存
const { data } = await useFetch("/api/config", {
	key: "app-config",
	// 缓存 5 分钟
	server: true,
	transform: data => {
		// 数据将保留在缓存中
		return data;
	}
});

// 条件缓存
const { data } = await useFetch("/api/user-preferences", {
	key: `user-prefs-${userId}`,
	server: false,
	// 仅在没有错误时缓存
	getCachedData: key => {
		const cached = nuxtApp.staticData[key] || nuxtApp.payload.data[key];
		return cached && !cached.error ? cached : null;
	}
});

TypeScript 集成

useFetch 的强类型

// types/api.ts
export interface User {
  id: number
  name: string
  email: string
  avatar?: string
}

export interface ApiResponse<T> {
  data: T
  message: string
  success: boolean
}

// pages/users/[id].vue
<script setup lang="ts">
const route = useRoute()
const { data: user } = await useFetch<ApiResponse<User>>(`/api/users/${route.params.id}`)

// 类型安全访问
if (user.value?.data) {
  console.log(user.value.data.name) // TypeScript 知道类型
}
</script>

useAsyncData 的类型

// composables/useDashboard.ts
export interface DashboardData {
	user: User;
	stats: {
		views: number;
		clicks: number;
		conversions: number;
	};
	recentActivity: Activity[];
}

export function useDashboardData() {
	return useAsyncData<DashboardData>("dashboard", async () => {
		const [userResponse, statsResponse, activityResponse] = await Promise.all([$fetch<ApiResponse<User>>("/api/user/profile"), $fetch<ApiResponse<Stats>>("/api/analytics/stats"), $fetch<ApiResponse<Activity[]>>("/api/activity/recent")]);

		return {
			user: userResponse.data,
			stats: statsResponse.data,
			recentActivity: activityResponse.data
		};
	});
}

调试和监控

调试工具

// 在开发中启用调试模式
const { data } = await useFetch("/api/debug-example", {
	// 在控制台显示调试信息
	onRequestError({ request, error }) {
		console.error("请求错误:", { request, error });
	},
	onResponseError({ response }) {
		console.error("响应错误:", response.status, response.statusText);
	},
	onResponse({ response }) {
		console.log("收到响应:", response._data);
	}
});

性能监控

// 用于测量性能的组合式函数
function useTrackedFetch<T>(url: string, options = {}) {
	const startTime = Date.now();

	return useFetch<T>(url, {
		...options,
		onResponse() {
			const duration = Date.now() - startTime;
			console.log(`获取 ${url} 耗时 ${duration}ms`);

			// 如果慢则发送到分析
			if (duration > 1000) {
				$fetch("/api/analytics/slow-request", {
					method: "POST",
					body: { url, duration }
				});
			}
		}
	});
}

结论

一句话总结:$fetch 用于突变和直接客户端调用,useFetch 用于带 SSR 的简单数据获取,useAsyncData 用于复杂的异步逻辑和完全的状态控制。

选对工具的关键在于判断这次请求需不需要状态传输。理解了这一点,双重获取的坑就不会再踩,Nuxt 4 的通用渲染能力也能真正发挥出来。

参考来源是 Nuxt 官方文档 和社区实践。