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和首屏速度至关重要。它返回的是一个响应式对象,包含data、pending、error等状态,非常适合在模板中直接使用。$fetch:它来自于Nuxt3内置的ofetch库,是一个纯粹的、基于Promise的HTTP客户端函数。它可以在任何地方(服务端、客户端、甚至Nuxt插件或中间件里)调用,但它本身不具备SSR的“魔法”。如果你在组件的setup里直接调用$fetch,它只会在客户端执行。
那么,在我们的封装里,应该以谁为基础呢?答案是:在可能进行SSR的页面或组件数据获取时,优先使用useFetch。因为我们的封装要服务于页面渲染,必须保证SSR正常工作。我们会基于useFetch来构建。
2.2 动手封装useApiFetch函数
理论懂了,我们来写代码。我习惯在项目根目录下创建一个utils或composables文件夹,里面放一个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
}
这段代码有几个关键点,是我踩过坑才总结出来的:
- 参数处理逻辑:这是封装的核心细节。
useFetch底层使用ofetch,对于GET请求,参数需要通过query传递;对于POST等请求,参数通过body传递。我们的封装要自动帮调用者完成这个转换,让他们只用关心一个data字段。 server: true:这个选项确保了在服务端渲染时,请求一定会在服务端执行。这是实现SSR数据预获取的关键。- 类型安全:使用TypeScript定义了
ApiOptions接口,让调用时有完善的代码提示和类型检查,减少低级错误。 - 错误统一处理:在
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>
看到没?页面逻辑变得非常干净。我们不再关心请求的细节(比如baseURL、headers),只需要调用语义化的API函数,并用useAsyncData包裹起来。useAsyncData会确保这个异步函数在服务端渲染时执行,并将结果注入到组件的初始状态中。
4. 第三步:验证你的SSR是否真正生效
封装写好了,页面也调用了,但你怎么知道数据真的是在服务端渲染的呢?如果SSR没生效,那我们的封装就失去了一半的意义。这里我教你几个简单又实用的验证方法,都是我平时调试时必用的。
4.1 方法一:查看页面源代码(最直接)
这是最铁证如山的方法。
- 在浏览器中打开你的Nuxt3应用页面(比如首页)。
- 在页面任意位置右键 -> 查看网页源代码(或者按
Ctrl+U)。 - 在弹出的源代码窗口中,搜索你的页面中应该通过接口渲染的数据内容。例如,如果你的页面应该显示课程名“Vue3入门实战”,那么就在源代码里搜索“Vue3入门实战”。
如果找到了:恭喜你!这说明数据已经被成功获取并直接写入了服务端返回的HTML中,SSR工作正常。搜索引擎爬虫和禁用JavaScript的用户都能看到这些内容。
如果没找到:只看到{{ course.name }}这样的Vue模板语法或者空的<div>,那就说明请求是在客户端执行的,SSR没有生效。你需要回头检查,是否在正确的生命周期(应使用useAsyncData或useFetch)中发起的请求,或者请求函数是否在服务端环境被正确调用。
4.2 方法二:利用Nuxt DevTools的SSR面板(最方便)
如果你在开发模式(npm run dev)下,Nuxt DevTools是一个神器。
- 打开你的页面。
- 按
F12打开开发者工具,找到Nuxt或Vue这个标签页(需要安装Nuxt DevTools模块)。 - 里面通常有一个
SSR或Server面板。在这个面板里,你可以清晰地看到哪些组件是在服务端渲染的,以及它们当时的状态数据。你能直接看到useAsyncData在服务端执行后返回的data,这比看源代码更直观。
4.3 方法三:观察网络请求与页面加载过程
- 打开浏览器开发者工具的
网络(Network)选项卡。 - 刷新页面(注意禁用缓存)。
- 查看第一个文档请求(通常是你的页面URL)的
响应(Response)。和查看源代码类似,你应该能在响应体里看到渲染好的数据。 - 同时,观察是否有额外的、针对页面初始数据的API请求。在一个完美的SSR场景下,你不应该在客户端网络记录里看到获取首页课程列表、用户信息等初始数据的API请求,因为这些数据已经包含在最初的HTML里了。如果你看到了,说明这些请求又在客户端重复执行了一次,这可能是因为你没有使用
useAsyncData/useFetch,或者在useAsyncData外又触发了响应式数据的变化。
通过以上方法,你就能牢牢掌握SSR的生效情况。确保SSR生效,是提升应用性能和SEO的基础。
5. 第四步:攻克SSR的“幽灵”——水合不匹配错误
这是Nuxt3 SSR开发中最常见,也最让人头疼的警告之一:Hydration completed but contains mismatches.。别怕,理解了原理,解决起来就有方向了。
5.1 为什么会发生水合错误?
简单来说,这个过程分两步:
- 服务端渲染:Node.js服务器执行你的Vue组件代码,调用
useAsyncData获取数据,生成包含真实数据的完整HTML字符串,发送给浏览器。 - 客户端水合:浏览器收到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 更复杂的场景:有条件并发与依赖请求
实际项目中,请求之间可能有依赖关系。例如,必须先拿到用户信息,根据用户角色再去请求不同的菜单列表。这时,我们可以将useAsyncData与async/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、处理文件上传、管理请求缓存等更深入的需求,那时可以基于这个基础框架继续扩展。最重要的是,理解了每一步背后的“为什么”,你就能灵活地应对各种变化。

2308

被折叠的 条评论
为什么被折叠?



