Nuxt 生态 精选推荐

Nuxt 服务端数据获取完全指南 - useFetch 与 useAsyncData 深度解析

HTMLPAGE 团队
12 分钟阅读

深入讲解 Nuxt 3 的数据获取机制,包括 useFetch、useAsyncData、$fetch 的使用场景、区别和最佳实践。提供完整的代码示例和性能优化技巧。

#Nuxt 3 #useFetch #useAsyncData #SSR #数据获取

Nuxt 服务端数据获取完全指南

概述

数据获取是 Nuxt 应用的核心功能。Nuxt 3 提供了 useFetchuseAsyncData$fetch 三种数据获取方式,它们在服务端和客户端的行为有所不同。正确使用这些工具可以实现高效的 SSR 数据预取,避免水合不匹配问题。

核心概念

Nuxt 数据获取体系

Nuxt 3 数据获取工具对比

useFetch
├── 封装了 useAsyncData + $fetch
├── 适合:获取 API 数据
├── 自动处理:SSR、缓存、去重
└── 最常用的数据获取方式

useAsyncData
├── 通用的异步数据获取
├── 适合:非 HTTP 请求的异步操作
├── 可自定义:获取逻辑
└── 更灵活的控制

$fetch
├── 基于 ofetch 的 HTTP 客户端
├── 适合:事件处理器、非组件代码
├── 无 SSR 集成:不会自动传输到客户端
└── 轻量级请求

服务端渲染中的数据流

SSR 数据获取流程

服务端
├── 1. 接收请求
├── 2. 执行 useFetch/useAsyncData
├── 3. 等待数据返回
├── 4. 使用数据渲染 HTML
├── 5. 将数据序列化到 payload
└── 6. 发送 HTML + payload

客户端(水合)
├── 1. 接收 HTML
├── 2. 解析 payload
├── 3. useFetch/useAsyncData 直接使用 payload
├── 4. 无需重复请求
└── 5. 完成水合

useFetch 详解

基本用法

<script setup>
// 基础用法
const { data, pending, error, refresh } = await useFetch('/api/users')

// 带参数
const { data: user } = await useFetch(`/api/users/${userId}`)

// 完整配置
const { data, pending, error, refresh, status } = await useFetch('/api/posts', {
  // 请求方法
  method: 'GET',
  
  // 查询参数
  query: {
    page: 1,
    limit: 10
  },
  
  // 请求头
  headers: {
    'Authorization': `Bearer ${token}`
  },
  
  // 请求体(POST/PUT)
  body: {
    title: 'New Post'
  },
  
  // 缓存键(用于去重和缓存)
  key: 'posts-page-1',
  
  // 只在服务端获取
  server: true,
  
  // 懒加载(不阻塞导航)
  lazy: false,
  
  // 立即执行(默认 true)
  immediate: true,
  
  // 数据转换
  transform: (data) => data.items,
  
  // 默认值
  default: () => []
})
</script>

<template>
  <div>
    <div v-if="pending">加载中...</div>
    <div v-else-if="error">错误: {{ error.message }}</div>
    <ul v-else>
      <li v-for="post in data" :key="post.id">{{ post.title }}</li>
    </ul>
  </div>
</template>

响应式参数

<script setup>
const page = ref(1)
const category = ref('all')

// 方法 1:使用函数返回 URL
const { data: posts } = await useFetch(() => `/api/posts?page=${page.value}`)

// 方法 2:使用响应式 query
const { data: articles } = await useFetch('/api/articles', {
  query: {
    page,        // 直接使用 ref
    category,    // 会自动追踪变化
    limit: 10
  }
})

// 方法 3:使用计算属性
const apiUrl = computed(() => `/api/users/${userId.value}/posts`)
const { data: userPosts } = await useFetch(apiUrl)

// 当参数变化时,会自动重新获取数据
function nextPage() {
  page.value++  // 自动触发请求
}
</script>

请求拦截

<script setup>
const { data } = await useFetch('/api/protected', {
  // 请求前拦截
  onRequest({ request, options }) {
    // 添加认证头
    options.headers = options.headers || {}
    options.headers.Authorization = `Bearer ${useAuthToken().value}`
    
    console.log('发起请求:', request)
  },
  
  // 请求错误拦截
  onRequestError({ request, error }) {
    console.error('请求失败:', error)
  },
  
  // 响应拦截
  onResponse({ response }) {
    // 处理响应
    console.log('响应状态:', response.status)
  },
  
  // 响应错误拦截
  onResponseError({ response }) {
    if (response.status === 401) {
      // 跳转到登录页
      navigateTo('/login')
    }
  }
})
</script>

条件获取

<script setup>
const userId = ref(null)

// 只有当 userId 存在时才获取
const { data: user } = await useFetch(() => `/api/users/${userId.value}`, {
  // 控制是否立即执行
  immediate: !!userId.value,
  
  // 使用 watch 选项控制
  watch: [userId]
})

// 或使用 watch: false 完全禁用自动获取
const { data: profile, execute } = await useFetch('/api/profile', {
  immediate: false,
  watch: false
})

// 手动触发
function loadProfile() {
  execute()
}
</script>

useAsyncData 详解

基本用法

<script setup>
// 基础用法
const { data, pending, error, refresh } = await useAsyncData(
  'users',  // 唯一键
  () => $fetch('/api/users')  // 异步函数
)

// 完整配置
const { data, pending, error, refresh, status } = await useAsyncData(
  'posts',
  async () => {
    const posts = await $fetch('/api/posts')
    const comments = await $fetch('/api/comments')
    return { posts, comments }
  },
  {
    // 缓存键(可选,默认使用第一个参数)
    key: 'posts-with-comments',
    
    // 只在服务端执行
    server: true,
    
    // 懒加载
    lazy: false,
    
    // 立即执行
    immediate: true,
    
    // 数据转换
    transform: (result) => result.posts,
    
    // 选择性获取(优化 payload 大小)
    pick: ['id', 'title', 'summary'],
    
    // 默认值
    default: () => ({ posts: [], comments: [] }),
    
    // 监听依赖
    watch: [someRef]
  }
)
</script>

复杂数据获取场景

<script setup>
// 场景 1:聚合多个 API
const { data: dashboard } = await useAsyncData('dashboard', async () => {
  const [stats, recentOrders, topProducts] = await Promise.all([
    $fetch('/api/stats'),
    $fetch('/api/orders/recent'),
    $fetch('/api/products/top')
  ])
  
  return { stats, recentOrders, topProducts }
})

// 场景 2:依赖其他数据
const { data: user } = await useFetch('/api/user')

const { data: userPosts } = await useAsyncData(
  'user-posts',
  () => $fetch(`/api/users/${user.value.id}/posts`),
  {
    watch: [user],
    immediate: !!user.value
  }
)

// 场景 3:非 HTTP 异步操作
const { data: config } = await useAsyncData('config', async () => {
  // 从本地存储或其他来源获取
  if (process.server) {
    return await readServerConfig()
  }
  return loadClientConfig()
})
</script>

缓存策略

<script setup>
// getCachedData 允许自定义缓存行为
const { data } = await useAsyncData('cached-data', 
  () => $fetch('/api/data'),
  {
    getCachedData(key) {
      // 返回 undefined 表示需要重新获取
      // 返回数据表示使用缓存
      
      const nuxtApp = useNuxtApp()
      const cachedData = nuxtApp.payload.data[key] || nuxtApp.static.data[key]
      
      if (!cachedData) {
        return undefined
      }
      
      // 检查缓存是否过期(示例:1 小时)
      const expirationTime = 60 * 60 * 1000
      const now = Date.now()
      
      if (cachedData._fetchedAt && now - cachedData._fetchedAt > expirationTime) {
        return undefined
      }
      
      return cachedData
    },
    
    transform(data) {
      return {
        ...data,
        _fetchedAt: Date.now()
      }
    }
  }
)
</script>

useFetch vs useAsyncData

选择指南

// 选择决策树
const chooseDataFetchMethod = {
  // 使用 useFetch 的场景
  useFetch: [
    '获取 REST API 数据',
    '简单的 CRUD 操作',
    '需要自动处理 baseURL',
    '大多数标准数据获取场景'
  ],

  // 使用 useAsyncData 的场景
  useAsyncData: [
    '需要聚合多个请求',
    '非 HTTP 异步操作',
    '需要自定义获取逻辑',
    '需要更细粒度的控制'
  ],

  // 使用 $fetch 的场景
  '$fetch': [
    '事件处理器中的请求',
    '不需要 SSR 的客户端请求',
    '非组件代码(utils、stores)',
    '服务端 API 路由中'
  ]
}

代码对比

<script setup>
// 场景:获取用户列表

// 方式 1:useFetch(推荐)
const { data: users } = await useFetch('/api/users')

// 方式 2:useAsyncData + $fetch(等效)
const { data: users2 } = await useAsyncData(
  'users',
  () => $fetch('/api/users')
)

// 场景:复杂数据获取

// 方式 1:useAsyncData(更适合)
const { data: dashboard } = await useAsyncData('dashboard', async () => {
  const [users, orders, stats] = await Promise.all([
    $fetch('/api/users'),
    $fetch('/api/orders'),
    $fetch('/api/stats')
  ])
  return { users, orders, stats }
})

// 方式 2:多个 useFetch(并行但独立)
const { data: users } = useFetch('/api/users')
const { data: orders } = useFetch('/api/orders')
const { data: stats } = useFetch('/api/stats')
// 需要等待所有完成
await Promise.all([users, orders, stats])
</script>

$fetch 使用场景

事件处理中使用

<script setup>
const loading = ref(false)

// 事件处理器中使用 $fetch
async function submitForm(formData) {
  loading.value = true
  
  try {
    const result = await $fetch('/api/submit', {
      method: 'POST',
      body: formData
    })
    
    // 处理成功
    console.log('提交成功:', result)
  } catch (error) {
    // 处理错误
    console.error('提交失败:', error)
  } finally {
    loading.value = false
  }
}

// 删除操作
async function deleteItem(id) {
  await $fetch(`/api/items/${id}`, {
    method: 'DELETE'
  })
  
  // 刷新列表
  await refreshItems()
}
</script>

服务端 API 路由中使用

// server/api/aggregate.ts
export default defineEventHandler(async (event) => {
  // 在服务端 API 中使用 $fetch 请求其他服务
  const [users, products] = await Promise.all([
    $fetch('https://api.example.com/users'),
    $fetch('https://api.example.com/products')
  ])

  return {
    users,
    products,
    timestamp: Date.now()
  }
})

工具函数中使用

// utils/api.ts
export const api = {
  async getUsers(params?: { page?: number; limit?: number }) {
    return await $fetch('/api/users', {
      query: params
    })
  },

  async createUser(userData: CreateUserInput) {
    return await $fetch('/api/users', {
      method: 'POST',
      body: userData
    })
  },

  async updateUser(id: string, userData: UpdateUserInput) {
    return await $fetch(`/api/users/${id}`, {
      method: 'PUT',
      body: userData
    })
  },

  async deleteUser(id: string) {
    return await $fetch(`/api/users/${id}`, {
      method: 'DELETE'
    })
  }
}

// 在组件中使用
// const users = await api.getUsers({ page: 1 })

高级用法

数据刷新策略

<script setup>
// 基础刷新
const { data, refresh, pending } = await useFetch('/api/data')

// 手动刷新
async function handleRefresh() {
  await refresh()
}

// 定时刷新
const refreshInterval = ref(null)

onMounted(() => {
  refreshInterval.value = setInterval(() => {
    refresh()
  }, 30000) // 每 30 秒刷新
})

onUnmounted(() => {
  clearInterval(refreshInterval.value)
})

// 条件刷新
const shouldRefresh = ref(false)

watch(shouldRefresh, (newVal) => {
  if (newVal) {
    refresh()
    shouldRefresh.value = false
  }
})
</script>

乐观更新

<script setup>
const { data: todos, refresh } = await useFetch('/api/todos')

async function toggleTodo(todo) {
  // 乐观更新:先更新 UI
  const originalStatus = todo.completed
  todo.completed = !todo.completed

  try {
    // 发送请求
    await $fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: { completed: todo.completed }
    })
  } catch (error) {
    // 失败时回滚
    todo.completed = originalStatus
    console.error('更新失败:', error)
  }
}

async function addTodo(title) {
  // 创建临时项
  const tempId = `temp-${Date.now()}`
  const newTodo = { id: tempId, title, completed: false }
  
  // 乐观添加
  todos.value.push(newTodo)

  try {
    const created = await $fetch('/api/todos', {
      method: 'POST',
      body: { title }
    })
    
    // 替换临时 ID
    const index = todos.value.findIndex(t => t.id === tempId)
    todos.value[index] = created
  } catch (error) {
    // 失败时移除
    todos.value = todos.value.filter(t => t.id !== tempId)
  }
}
</script>

分页与无限滚动

<script setup>
const page = ref(1)
const allPosts = ref([])

const { data, pending } = await useFetch('/api/posts', {
  query: {
    page,
    limit: 10
  },
  transform: (response) => response.data
})

// 监听数据变化,累积结果
watch(data, (newPosts) => {
  if (newPosts) {
    if (page.value === 1) {
      allPosts.value = newPosts
    } else {
      allPosts.value = [...allPosts.value, ...newPosts]
    }
  }
})

// 无限滚动实现
function loadMore() {
  page.value++
}

// 使用 Intersection Observer
const loadMoreRef = ref(null)

onMounted(() => {
  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting && !pending.value) {
      loadMore()
    }
  })

  if (loadMoreRef.value) {
    observer.observe(loadMoreRef.value)
  }
})
</script>

<template>
  <div>
    <div v-for="post in allPosts" :key="post.id">
      {{ post.title }}
    </div>
    <div ref="loadMoreRef">
      <span v-if="pending">加载中...</span>
    </div>
  </div>
</template>

错误处理

<script setup>
const { data, error, refresh } = await useFetch('/api/data', {
  // 自定义错误处理
  onResponseError({ response }) {
    const statusCode = response.status
    
    switch (statusCode) {
      case 401:
        navigateTo('/login')
        break
      case 403:
        throw createError({
          statusCode: 403,
          message: '没有权限访问此资源'
        })
      case 404:
        throw createError({
          statusCode: 404,
          message: '资源不存在'
        })
      case 500:
        // 记录错误
        console.error('服务器错误:', response._data)
        break
    }
  }
})

// 全局错误处理(通过插件)
// plugins/fetch-error.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('app:error', (error) => {
    console.error('应用错误:', error)
    // 可以上报到错误监控服务
  })
})
</script>

<template>
  <div>
    <template v-if="error">
      <div class="error-container">
        <h2>出错了</h2>
        <p>{{ error.message }}</p>
        <button @click="refresh">重试</button>
      </div>
    </template>
    <template v-else>
      <!-- 正常内容 -->
    </template>
  </div>
</template>

性能优化

减少 Payload 大小

<script setup>
// 使用 pick 只选择需要的字段
const { data: posts } = await useFetch('/api/posts', {
  pick: ['id', 'title', 'summary', 'createdAt']
  // 完整的 post 对象可能有 20+ 个字段
  // 只传输需要的字段可以显著减少 payload
})

// 使用 transform 处理数据
const { data: users } = await useFetch('/api/users', {
  transform: (users) => users.map(u => ({
    id: u.id,
    name: u.name,
    avatar: u.avatar
  }))
})
</script>

并行请求优化

<script setup>
// ❌ 串行请求(慢)
const { data: user } = await useFetch('/api/user')
const { data: posts } = await useFetch('/api/posts')
const { data: comments } = await useFetch('/api/comments')
// 总时间 = user + posts + comments

// ✅ 并行请求(快)
const [
  { data: user },
  { data: posts },
  { data: comments }
] = await Promise.all([
  useFetch('/api/user'),
  useFetch('/api/posts'),
  useFetch('/api/comments')
])
// 总时间 = max(user, posts, comments)

// ✅ 或使用 useAsyncData 聚合
const { data } = await useAsyncData('page-data', async () => {
  const [user, posts, comments] = await Promise.all([
    $fetch('/api/user'),
    $fetch('/api/posts'),
    $fetch('/api/comments')
  ])
  return { user, posts, comments }
})
</script>

懒加载非关键数据

<script setup>
// 关键数据:阻塞渲染(SSR)
const { data: mainContent } = await useFetch('/api/main')

// 非关键数据:懒加载(不阻塞)
const { data: relatedPosts, pending: loadingRelated } = useFetch('/api/related', {
  lazy: true  // 不阻塞页面渲染
})

const { data: comments, pending: loadingComments } = useFetch('/api/comments', {
  lazy: true,
  server: false  // 完全在客户端获取
})
</script>

<template>
  <div>
    <!-- 主要内容立即显示 -->
    <main>{{ mainContent }}</main>
    
    <!-- 相关文章懒加载 -->
    <aside>
      <div v-if="loadingRelated">加载相关文章...</div>
      <RelatedPosts v-else :posts="relatedPosts" />
    </aside>
    
    <!-- 评论客户端加载 -->
    <section>
      <div v-if="loadingComments">加载评论...</div>
      <Comments v-else :comments="comments" />
    </section>
  </div>
</template>

常见问题解答

Q: useFetch 和 useAsyncData 的主要区别是什么? A: useFetchuseAsyncData + $fetch 的封装,专门用于 HTTP 请求。useAsyncData 更通用,可以用于任何异步操作。

Q: 为什么不能在事件处理器中使用 useFetch? A: useFetch 需要在组件 setup 阶段调用,以便进行 SSR 数据传输。事件处理器在用户交互时触发,此时应使用 $fetch

Q: 如何避免数据重复请求? A: Nuxt 会自动根据 key 去重。确保使用一致的 key,或让 Nuxt 自动生成(基于 URL 和参数)。

Q: 如何处理认证 token? A: 使用请求拦截器(onRequest)动态添加 Authorization 头,或创建自定义的 $fetch 实例。

总结

Nuxt 3 的数据获取体系强大而灵活:

  1. useFetch - 大多数 API 请求的首选
  2. useAsyncData - 复杂异步场景的选择
  3. $fetch - 事件处理和非组件代码

最佳实践:

  • 使用响应式参数实现自动刷新
  • 合理使用 lazy 和 server 选项
  • 通过 pick/transform 减少 payload
  • 使用 Promise.all 并行请求
  • 实现恰当的错误处理

参考资源