Nuxt3 SSR 接口请求封装实战:从基础封装到多接口并发处理

1. 为什么我们需要封装Nuxt3的接口请求?

如果你刚开始用Nuxt3做项目,可能会觉得直接用useFetch或者$fetch来请求接口也挺方便的,一行代码就能拿到数据。我刚开始也是这么想的,直到项目越做越大,接口越来越多,问题就开始一个个冒出来了。

想象一下这个场景:你的首页需要同时请求用户信息、通知列表和推荐内容三个接口。如果每个页面都直接写三个useFetch,你会发现代码里到处都是重复的baseURL、重复的headers设置(比如那个烦人的token),而且万一后端接口地址变了,或者认证方式改了,你就得把所有页面翻个底朝天去修改,这简直就是一场噩梦。更头疼的是,在服务端渲染(SSR)的环境下,如果你请求的方式不对,页面很容易出现“水合错误”,就是那种控制台一片红,告诉你服务端渲染的HTML和客户端渲染的DOM对不上的警告,直接影响用户体验和SEO效果。

所以,封装接口请求绝对不是“过度设计”,而是Nuxt3 SSR项目走向规范和可维护的必经之路。一个好的封装方案,至少要帮我们解决四个核心问题:统一管理请求配置(比如域名、请求头)、集中处理接口定义(让API像调用函数一样清晰)、完美适配SSR(确保数据在服务端和客户端都能正确获取和渲染),以及优雅处理复杂场景(比如多个接口并发请求)。接下来,我就结合自己踩过的坑和实战经验,带你从零开始,一步步搭建一个既健壮又易用的请求封装体系。

2. 第一步:打造你的基础请求“发动机”

万事开头难,我们先从最核心的请求函数封装开始。这里的目标是创建一个“万能”的请求器,它要能智能地在服务端和客户端运行,自动处理好URL、参数和响应。

2.1 理解Nuxt3的请求“双雄”:useFetch$fetch

在动手之前,得先搞清楚Nuxt3给我们提供的两把利器有什么区别,这决定了我们封装的基础。

  • useFetch:这是Nuxt3的“亲儿子”,专为组合式API和SSR设计。它的最大特点是在服务端渲染时,会自动在服务端执行请求,并将数据“脱水”到HTML中发送给客户端。客户端接收到后直接渲染,无需再次请求,这对SEO和首屏速度至关重要。它返回的是一个响应式对象,包含datapendingerror等状态,非常适合在模板中直接使用。
  • $fetch:它来自于Nuxt3内置的ofetch库,是一个纯粹的、基于Promise的HTTP客户端函数。它可以在任何地方(服务端、客户端、甚至Nuxt插件或中间件里)调用,但它本身不具备SSR的“魔法”。如果你在组件的setup里直接调用$fetch,它只会在客户端执行。

那么,在我们的封装里,应该以谁为基础呢?答案是:在可能进行SSR的页面或组件数据获取时,优先使用useFetch。因为我们的封装要服务于页面渲染,必须保证SSR正常工作。我们会基于useFetch来构建。

2.2 动手封装useApiFetch函数

理论懂了,我们来写代码。我习惯在项目根目录下创建一个utilscomposables文件夹,里面放一个request.ts文件。

// utils/request.ts
import { useFetch, UseFetchOptions } from 'nuxt/app'

// 定义你的后端API基础地址,可以从环境变量读取,更灵活
const baseURL = process.env.API_BASE_URL || 'https://api.your-domain.com'

// 定义我们封装的请求函数参数类型
interface ApiOptions extends UseFetchOptions<any> {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
  data?: any // 请求体数据
}

export function useApiFetch<T = any>(url: string, options: ApiOptions = {}) {
  // 1. 准备默认配置
  const defaultOptions: UseFetchOptions<any> = {
    baseURL,
    // 初始化headers,这里可以设置一些全局头部,如Content-Type
    headers: {
      'Content-Type': 'application/json',
    } as HeadersInit,
    // 默认开启服务端请求(这对SSR很重要)
    server: true,
    // 可以在这里统一处理响应错误,比如token过期跳登录页
    onResponseError({ response }) {
      console.error('请求错误:', response.status, response._data)
      // 例如: if (response.status === 401) { navigateTo('/login') }
    },
  }

  // 2. 智能处理请求参数
  // 根据请求方法,将数据放到正确的位置。这是很多新手容易出错的地方。
  const method = (options.method || 'GET').toUpperCase()
  const finalOptions = { ...defaultOptions, ...options, method }

  // 如果是GET请求,参数应该放在`query`或`params`(`useFetch`内部使用`query`)
  // 如果是其他方法,参数放在`body`里
  if (method === 'GET' || method === 'HEAD') {
    // 将传入的`data`合并到`query`参数中
    finalOptions.query = { ...finalOptions.query, ...options.data }
    // 确保删除可能错误传递的`body`或`data`字段,避免冲突
    delete finalOptions.body
    delete (finalOptions as any).data
  } else {
    // 对于POST等,将数据设置为请求体
    finalOptions.body = options.data
    // 同样清理可能存在的`query`字段,避免干扰
    delete finalOptions.query
  }

  // 3. 发起请求
  // 使用Nuxt3的useFetch,它会根据运行环境自动处理SSR
  const result = useFetch<T>(url, finalOptions as UseFetchOptions<T>)

  // 4. 返回处理后的结果
  // 我们直接返回result,让调用方可以访问data, pending, error等状态。
  // 也可以在这里做统一的数据提取,比如 return result.data.value
  return result
}

这段代码有几个关键点,是我踩过坑才总结出来的:

  1. 参数处理逻辑:这是封装的核心细节。useFetch底层使用ofetch,对于GET请求,参数需要通过query传递;对于POST等请求,参数通过body传递。我们的封装要自动帮调用者完成这个转换,让他们只用关心一个data字段。
  2. server: true:这个选项确保了在服务端渲染时,请求一定会在服务端执行。这是实现SSR数据预获取的关键。
  3. 类型安全:使用TypeScript定义了ApiOptions接口,让调用时有完善的代码提示和类型检查,减少低级错误。
  4. 错误统一处理:在onResponseError钩子里,我们可以集中处理网络错误、401未认证、403无权限等通用逻辑,比如弹出提示或跳转登录页。

有了这个“发动机”,项目里所有需要SSR的请求就有了一个可靠、统一的起点。

3. 第二步:让接口管理井然有序——模块化设计

基础请求器做好了,但直接在页面里写useApiFetch(‘/user/profile’, { method: ‘GET’ })还是很原始。接口路径散落在各处,难以维护。下一步,我们要像整理书架一样,把接口分门别类地管理起来。

3.1 创建API模块

我推荐按功能模块来划分API文件。比如,创建一个api目录,里面存放各个模块。

// api/auth.ts - 认证相关接口
import { useApiFetch } from '@/utils/request'

export const authApi = {
  // 登录
  login(credentials: { username: string; password: string }) {
    return useApiFetch<{ token: string; userInfo: any }>('/auth/login', {
      method: 'POST',
      data: credentials,
    })
  },

  // 获取用户信息
  getProfile() {
    return useApiFetch<{ username: string; avatar: string }>('/auth/profile', {
      method: 'GET',
    })
  },

  // 退出登录
  logout() {
    return useApiFetch('/auth/logout', { method: 'POST' })
  },
}
// api/content.ts - 内容相关接口
import { useApiFetch } from '@/utils/request'

export const contentApi = {
  // 获取首页课程列表
  getCourseList(params?: { page: number; size: number }) {
    return useApiFetch<{
      list: Array<{ id: number; name: string }>;
      total: number;
    }>('/course/list', {
      method: 'GET',
      data: params, // 这里传入的data,在useApiFetch中会被智能处理为query参数
    })
  },

  // 获取轮播图
  getBannerList() {
    return useApiFetch<Array<{ id: number; imageUrl: string; link: string }>>('/banner', {
      method: 'GET',
    })
  },
}
// api/index.ts - 统一导出入口
export * from './auth'
export * from './content'
// ... 导出其他所有模块

这样做的好处太明显了:

  • 可读性极强:在页面里调用contentApi.getCourseList(),一看就知道是做什么的,不用去猜URL是什么。
  • 维护成本低:如果后端接口路径从/course/list改成了/api/v2/course/list,你只需要修改content.ts这一个文件。
  • 类型提示完善:每个接口都定义了返回数据的类型<T>,调用时data.value就有完整的智能提示,开发体验飞起。
  • 参数结构清晰:每个接口需要什么参数,在函数定义里一目了然。

3.2 在页面中优雅调用

封装好了API模块,在页面组件里使用就变得非常清爽。

<!-- pages/index.vue -->
<template>
  <div>
    <h1>欢迎回来,{{ userInfo?.username }}</h1>
    <div v-if="pending">加载课程中...</div>
    <div v-else>
      <div v-for="course in courseList" :key="course.id">
        {{ course.name }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
// 导入统一的API
import { authApi, contentApi } from '@/api'

// 使用useAsyncData配合我们的API调用,这是SSR的黄金搭档
// 注意:这里给返回的数据起了一个别名`userInfo`,方便模板使用
const { data: userInfo, pending: userPending } = useAsyncData(
  'user-profile', // 一个唯一的key,用于Nuxt内部缓存
  async () => {
    const res = await authApi.getProfile()
    // 注意:useApiFetch返回的是一个包含响应式ref的对象,我们需要访问`.data.value`
    // 但在useAsyncData的fn里,我们直接await拿到Promise的结果
    return res.data.value
  }
)

const { data: courseList, pending: coursePending } = useAsyncData(
  'course-list',
  async () => {
    const res = await contentApi.getCourseList({ page: 1, size: 10 })
    return res.data.value?.list || []
  }
)
</script>

看到没?页面逻辑变得非常干净。我们不再关心请求的细节(比如baseURLheaders),只需要调用语义化的API函数,并用useAsyncData包裹起来。useAsyncData会确保这个异步函数在服务端渲染时执行,并将结果注入到组件的初始状态中。

4. 第三步:验证你的SSR是否真正生效

封装写好了,页面也调用了,但你怎么知道数据真的是在服务端渲染的呢?如果SSR没生效,那我们的封装就失去了一半的意义。这里我教你几个简单又实用的验证方法,都是我平时调试时必用的。

4.1 方法一:查看页面源代码(最直接)

这是最铁证如山的方法。

  1. 在浏览器中打开你的Nuxt3应用页面(比如首页)。
  2. 在页面任意位置右键 -> 查看网页源代码(或者按Ctrl+U)。
  3. 在弹出的源代码窗口中,搜索你的页面中应该通过接口渲染的数据内容。例如,如果你的页面应该显示课程名“Vue3入门实战”,那么就在源代码里搜索“Vue3入门实战”。

如果找到了:恭喜你!这说明数据已经被成功获取并直接写入了服务端返回的HTML中,SSR工作正常。搜索引擎爬虫和禁用JavaScript的用户都能看到这些内容。

如果没找到:只看到{{ course.name }}这样的Vue模板语法或者空的<div>,那就说明请求是在客户端执行的,SSR没有生效。你需要回头检查,是否在正确的生命周期(应使用useAsyncDatauseFetch)中发起的请求,或者请求函数是否在服务端环境被正确调用。

4.2 方法二:利用Nuxt DevTools的SSR面板(最方便)

如果你在开发模式(npm run dev)下,Nuxt DevTools是一个神器。

  1. 打开你的页面。
  2. F12打开开发者工具,找到NuxtVue这个标签页(需要安装Nuxt DevTools模块)。
  3. 里面通常有一个SSRServer面板。在这个面板里,你可以清晰地看到哪些组件是在服务端渲染的,以及它们当时的状态数据。你能直接看到useAsyncData在服务端执行后返回的data,这比看源代码更直观。

4.3 方法三:观察网络请求与页面加载过程

  1. 打开浏览器开发者工具的网络(Network)选项卡。
  2. 刷新页面(注意禁用缓存)。
  3. 查看第一个文档请求(通常是你的页面URL)的响应(Response)。和查看源代码类似,你应该能在响应体里看到渲染好的数据。
  4. 同时,观察是否有额外的、针对页面初始数据的API请求。在一个完美的SSR场景下,你不应该在客户端网络记录里看到获取首页课程列表、用户信息等初始数据的API请求,因为这些数据已经包含在最初的HTML里了。如果你看到了,说明这些请求又在客户端重复执行了一次,这可能是因为你没有使用useAsyncData/useFetch,或者在useAsyncData外又触发了响应式数据的变化。

通过以上方法,你就能牢牢掌握SSR的生效情况。确保SSR生效,是提升应用性能和SEO的基础。

5. 第四步:攻克SSR的“幽灵”——水合不匹配错误

这是Nuxt3 SSR开发中最常见,也最让人头疼的警告之一:Hydration completed but contains mismatches.。别怕,理解了原理,解决起来就有方向了。

5.1 为什么会发生水合错误?

简单来说,这个过程分两步:

  1. 服务端渲染:Node.js服务器执行你的Vue组件代码,调用useAsyncData获取数据,生成包含真实数据的完整HTML字符串,发送给浏览器。
  2. 客户端水合:浏览器收到HTML并展示(这就是你能瞬间看到内容的原因)。然后,Vue的客户端脚本开始执行,它会重新创建Vue组件实例,并期望将组件“挂载”到现有的DOM节点上。这个过程就叫“水合”。如果客户端Vue实例计算出的初始虚拟DOM,与服务端发送过来的HTML结构不一致,水合就会失败,产生错误。

导致不一致的常见元凶,往往就是异步数据!

5.2 错误示例 vs 正确姿势

让我们看一个典型的错误用法和它的修正方案。

错误示例:在onMounted或异步函数中赋值

<script setup lang="ts">
import { contentApi } from '@/api'
const courseList = ref([]) // 初始化为空数组

// 错误!onMounted只在客户端执行
onMounted(async () => {
  const res = await contentApi.getCourseList()
  courseList.value = res.data.value?.list || []
})
</script>

问题分析:服务端渲染时,onMounted不会执行,所以courseList保持空数组[],渲染出的HTML是空的。到了客户端,onMounted执行了,数据获取到后courseList变成了[{id:1, name:‘Vue’}, ...]。Vue客户端试图水合时,发现虚拟DOM的列表有内容,但服务端给的HTML里列表是空的,结构对不上,于是就报错了。

正确姿势:使用useAsyncData

<script setup lang="ts">
import { contentApi } from '@/api'

// 正确!useAsyncData会在服务端和客户端都确保数据获取
const { data: courseList, pending } = useAsyncData(
  'course-list-key',
  async () => {
    const res = await contentApi.getCourseList()
    // 注意:在useAsyncData的fn里,我们await的是useApiFetch返回的整个结果
    // 然后通过res.data.value拿到实际数据
    return res.data.value?.list || []
  }
)
</script>

<template>
  <div v-if="pending">加载中...</div>
  <div v-else>
    <div v-for="course in courseList" :key="course.id">
      {{ course.name }}
    </div>
  </div>
</template>

为什么这样是对的? useAsyncData是Nuxt3为SSR量身定制的组合函数。在服务端,它会阻塞渲染直到async函数执行完毕,将结果courseList直接嵌入HTML。在客户端,它会首先复用服务端注入的数据,而不会重新执行函数(除非你设置了lazy: true或手动刷新),从而保证了两端数据的一致性,完美避开水合错误。

记住这个黄金法则:在Nuxt3中,任何用于组件初始渲染的异步数据,都应该放在useAsyncData(或直接使用useFetch)中获取。

6. 第五步:进阶实战——优雅处理多接口并发请求

一个复杂的页面,比如首页,经常需要同时加载用户信息、横幅广告、内容列表、通知等多个模块的数据。如果一个个串行请求,页面加载时间就是所有接口耗时的总和,太慢了。我们必须让它们并发执行。

6.1 使用Promise.all进行并发请求

我们的API模块化设计和useAsyncData让并发变得非常简单。核心思路就是在useAsyncData的回调函数里,使用Promise.all同时发起多个请求。

<!-- pages/index.vue -->
<script setup lang="ts">
import { authApi, contentApi, systemApi } from '@/api'

// 使用一个useAsyncData并发获取所有首页所需数据
const { data: pageData, pending, refresh } = useAsyncData(
  'home-page-data',
  async () => {
    // 使用Promise.all同时发起三个请求
    const [userRes, courseRes, bannerRes] = await Promise.all([
      authApi.getProfile(),
      contentApi.getCourseList({ page: 1, size: 5 }),
      contentApi.getBannerList(),
    ])

    // 返回一个整合好的对象,方便模板使用
    return {
      userInfo: userRes.data.value,
      courses: courseRes.data.value?.list || [],
      banners: bannerRes.data.value || [],
    }
  }
)

// 现在,在模板中可以通过pageData来访问所有数据
// pageData.value.userInfo
// pageData.value.courses
</script>

<template>
  <div>
    <header>欢迎,{{ pageData?.userInfo?.username }}</header>
    <section>
      <h2>热门课程</h2>
      <div v-for="course in pageData?.courses" :key="course.id">{{ course.name }}</div>
    </section>
    <section>
      <h2>轮播图</h2>
      <img v-for="banner in pageData?.banners" :key="banner.id" :src="banner.imageUrl" />
    </section>
  </div>
</template>

这样做的好处:

  • 极致的性能:三个接口并行请求,总耗时约等于最慢的那个接口的耗时,而不是三者之和。
  • 单一数据源:页面状态由唯一的pageData管理,逻辑清晰。
  • 统一的加载状态:一个pending状态可以控制整个页面的加载动画,用户体验更连贯。

6.2 处理并发请求中的错误

并发请求有一个问题:如果其中一个接口失败了,Promise.all会整体失败,导致整个pageData获取失败。有时候我们可能希望部分失败不影响其他数据的展示。这时可以用Promise.allSettled

const { data: pageData, pending } = useAsyncData('home-page-data', async () => {
  const [userResult, courseResult, bannerResult] = await Promise.allSettled([
    authApi.getProfile(),
    contentApi.getCourseList({ page: 1, size: 5 }),
    contentApi.getBannerList(),
  ])

  // 手动处理每个Promise的结果
  return {
    userInfo: userResult.status === 'fulfilled' ? userResult.value.data.value : null,
    courses: courseResult.status === 'fulfilled' ? courseResult.value.data.value?.list || [] : [],
    banners: bannerResult.status === 'fulfilled' ? bannerResult.value.data.value || [] : [],
    // 你还可以在这里收集错误信息,用于界面提示
    errors: {
      user: userResult.status === 'rejected' ? userResult.reason : null,
      course: courseResult.status === 'rejected' ? courseResult.reason : null,
      banner: bannerResult.status === 'rejected' ? bannerResult.reason : null,
    }
  }
})

6.3 更复杂的场景:有条件并发与依赖请求

实际项目中,请求之间可能有依赖关系。例如,必须先拿到用户信息,根据用户角色再去请求不同的菜单列表。这时,我们可以将useAsyncDataasync/await的自然流程结合。

const { data: complexData } = useAsyncData('complex-data', async () => {
  // 1. 先获取用户信息
  const userRes = await authApi.getProfile()
  const user = userRes.data.value

  // 2. 根据用户角色,并发获取其他信息
  let roleSpecificData = null
  if (user?.role === 'admin') {
    const [adminStats, auditLogs] = await Promise.all([
      systemApi.getAdminStats(),
      systemApi.getAuditLogs(),
    ])
    roleSpecificData = { stats: adminStats.data.value, logs: auditLogs.data.value }
  } else {
    const userMessages = await systemApi.getUserMessages(user.id)
    roleSpecificData = { messages: userMessages.data.value }
  }

  // 返回整合数据
  return {
    user,
    ...roleSpecificData
  }
})

这种模式既保持了部分请求的并发性,又满足了业务逻辑的先后顺序,非常灵活。

走到这里,你已经掌握了从基础封装、模块化管理、SSR验证与问题排查,到复杂并发处理的全套流程。这套架构在我经历过的多个中大型Nuxt3项目中都得到了验证,它能显著提升开发效率、降低维护成本,并确保应用拥有优秀的性能和SEO表现。记住,好的封装不是一蹴而就的,在你的项目实践中,可能会遇到需要自动刷新Token、处理文件上传、管理请求缓存等更深入的需求,那时可以基于这个基础框架继续扩展。最重要的是,理解了每一步背后的“为什么”,你就能灵活地应对各种变化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值