$fetch 与 BFF 接口层封装
useFetch背后的真正执行者是$fetch——一个基于ofetch库的全局 HTTP 客户端。但在真实项目中,你不会让每个页面直接调用裸 API:统一的错误处理、Token 注入、请求日志、响应格式转换……这些都需要一个 BFF(Backend For Frontend)接口层。本章从$fetch/ofetch底层讲起,到拦截器配置、Nitro 服务端安全实践,最终构建 AI 视频项目的完整接口层架构。
1. ofetch 库封装原理
1.1 $fetch 是什么
$fetch 是 Nuxt4 全局提供的 HTTP 客户端,基于 ofetch(unjs 生态):
// 基本用法
const data = await $fetch('/api/videos')
const video = await $fetch('/api/videos/123')
const result = await $fetch('/api/videos', {
method: 'POST',
body: { title: 'AI 视频教程' },
})1.2 $fetch vs fetch vs axios
| 特性 | $fetch (ofetch) | 原生 fetch | axios |
|---|---|---|---|
| JSON 自动解析 | ✅ 自动 | ❌ 需 .json() | ✅ 自动 |
| 错误自动抛出 | ✅ 非 2xx 抛错 | ❌ 只有网络错误才抛 | ✅ |
| SSR 直接调用 | ✅ 服务端跳过 HTTP | ❌ 需要完整 URL | ❌ 需要完整 URL |
| 请求/响应拦截 | ✅ onRequest / onResponse | ❌ | ✅ interceptors |
| 自动重试 | ✅ retry 选项 | ❌ | ❌ 需插件 |
| 体积 | 轻量(~2KB) | 原生 0KB | 较重(~13KB) |
| TypeScript | ✅ 原生支持 | 部分 | ✅ |
1.3 $fetch 的完整选项
const data = await $fetch<VideoResponse>('/api/videos', {
// 请求配置
method: 'GET', // GET | POST | PUT | PATCH | DELETE
query: { page: 1, limit: 20 }, // URL 查询参数
body: { title: '...' }, // 请求体(POST/PUT/PATCH)
headers: { 'X-Custom': 'value' },
// 认证
credentials: 'include', // 携带 Cookie
// 超时与重试
timeout: 10000, // 超时时间(毫秒)
retry: 3, // 失败重试次数
retryDelay: 1000, // 重试间隔
// 拦截器
onRequest({ request, options }) { ... },
onRequestError({ request, error }) { ... },
onResponse({ response }) { ... },
onResponseError({ response }) { ... },
// 响应格式
responseType: 'json', // json | text | blob | arrayBuffer
parseResponse: (text) => JSON.parse(text), // 自定义解析
})1.4 $fetch 在 SSR 中的智能行为
// 客户端:发送 HTTP 请求到 http://localhost:3000/api/videos
// 服务端:直接调用 server/api/videos.ts 的处理函数(不走网络)
const data = await $fetch('/api/videos')
// 调用外部 API 时,两端都走 HTTP
const external = await $fetch('https://api.openai.com/v1/chat', {
headers: { Authorization: `Bearer ${apiKey}` },
})2. 拦截器配置
2.1 创建自定义 $fetch 实例
// app/composables/useApi.ts
export function useApi() {
const config = useRuntimeConfig()
const { token } = useAuth()
const api = $fetch.create({
baseURL: config.public.apiBase,
// 请求拦截器
onRequest({ options }) {
// 自动注入 Token
if (token.value) {
options.headers.set('Authorization', `Bearer ${token.value}`)
}
// 请求日志
if (import.meta.dev) {
console.log(`[API] ${options.method || 'GET'} ${options.baseURL}`)
}
},
// 请求错误拦截器
onRequestError({ error }) {
console.error('[API] Request error:', error.message)
},
// 响应拦截器
onResponse({ response }) {
// 统一处理响应格式
// 如果后端返回 { code: 200, data: {...}, message: 'ok' }
// 可以在这里提取 data
},
// 响应错误拦截器
onResponseError({ response }) {
const status = response.status
if (status === 401) {
// Token 过期,清除认证状态并跳转登录
const { logout } = useAuth()
logout()
return
}
if (status === 403) {
throw createError({ statusCode: 403, message: '无权限访问' })
}
if (status >= 500) {
console.error('[API] Server error:', response._data)
}
},
})
return api
}2.2 在页面中使用
<script setup lang="ts">
const api = useApi()
// 使用自定义实例(自动带 Token、自动错误处理)
const videos = await api('/videos', { query: { page: 1 } })
const video = await api(`/videos/${id}`)
await api('/videos', { method: 'POST', body: formData })
</script>2.3 配合 useFetch 使用
// app/composables/useApiFetch.ts
export function useApiFetch<T>(url: string, options: any = {}) {
const config = useRuntimeConfig()
const { token } = useAuth()
return useFetch<T>(url, {
baseURL: config.public.apiBase,
headers: token.value
? { Authorization: `Bearer ${token.value}` }
: {},
...options,
})
}<script setup lang="ts">
// 使用封装后的 useApiFetch(兼具 useFetch 的 SSR 能力 + 自定义拦截)
const { data: videos } = await useApiFetch('/videos', {
query: { page: 1 },
pick: ['id', 'title', 'thumbnail'],
})
</script>3. server/ 调用外部 API 安全实践
3.1 BFF 架构模式
浏览器
↓ useFetch('/api/videos')
Nuxt Server (Nitro)
├── server/api/videos.ts ← BFF 层
│ ↓ $fetch(externalAPI) ← 服务端调用外部 API
│ ↓ 数据转换 + 裁剪 ← 只返回前端需要的字段
└── 返回精简数据给浏览器
为什么需要 BFF?
- 安全:API Key、Secret 等敏感信息只在服务端,不暴露给浏览器
- 性能:在服务端聚合多个 API 调用,减少客户端请求数
- 适配:后端 API 格式可能不适合前端,BFF 层做数据转换
3.2 API Key 安全管理
// .env(不要提交到 Git!)
OPENAI_API_KEY=sk-xxxxx
VIDEO_SERVICE_SECRET=secret-key
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// 仅服务端可访问(不会暴露给客户端)
openaiApiKey: '',
videoServiceSecret: '',
// 客户端也可访问
public: {
apiBase: '/api',
},
},
})// server/api/ai/generate.post.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const body = await readBody(event)
// API Key 只在服务端使用,浏览器永远看不到
const result = await $fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.openaiApiKey}`, // ✅ 安全
'Content-Type': 'application/json',
},
body: {
model: 'gpt-4',
messages: [{ role: 'user', content: body.prompt }],
},
})
// 只返回前端需要的数据
return {
text: result.choices[0].message.content,
usage: result.usage,
}
})3.3 服务端请求工具封装
// server/utils/api-client.ts
export function createExternalApi(baseURL: string, apiKey: string) {
return $fetch.create({
baseURL,
headers: { Authorization: `Bearer ${apiKey}` },
timeout: 30000,
retry: 2,
onResponseError({ response }) {
console.error(`[External API] ${response.status}:`, response._data)
},
})
}
// server/utils/video-service.ts
export function useVideoService() {
const config = useRuntimeConfig()
return createExternalApi(
'https://video-api.example.com/v1',
config.videoServiceSecret
)
}// server/api/videos/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const videoApi = useVideoService()
const result = await videoApi('/videos', {
query: {
page: query.page || 1,
limit: query.limit || 20,
},
})
// 数据转换:只返回前端需要的字段
return result.items.map((v: any) => ({
id: v.id,
title: v.title,
thumbnail: v.thumbnails?.medium,
duration: v.duration,
views: v.statistics?.viewCount,
createdAt: v.publishedAt,
}))
})4. Token 透传与鉴权
4.1 问题:SSR 时 Cookie 丢失
// ❌ 问题:SSR 时 useFetch 调用 /api/user
// 服务端直接调用处理函数,但没有浏览器的 Cookie
const { data: user } = await useFetch('/api/user')
// SSR 时:Cookie 丢失 → API 返回 4014.2 方案 1:useRequestHeaders 透传
// 将浏览器的 Cookie 透传给内部 API
const { data: user } = await useFetch('/api/user', {
headers: useRequestHeaders(['cookie']),
})
// SSR 时:浏览器 Cookie → Nuxt Server → /api/user 处理函数4.3 方案 2:全局封装透传
// app/composables/useApiFetch.ts
export function useApiFetch<T>(url: string, options: any = {}) {
return useFetch<T>(url, {
...options,
// SSR 时透传 Cookie
headers: {
...useRequestHeaders(['cookie']),
...options.headers,
},
})
}4.4 方案 3:服务端中间件统一处理
// server/middleware/auth.ts
export default defineEventHandler((event) => {
// 从请求头获取 Token
const authHeader = getRequestHeader(event, 'authorization')
const cookieToken = getCookie(event, 'auth-token')
const token = authHeader?.replace('Bearer ', '') || cookieToken
// 存储到 event.context 中,供后续处理函数使用
event.context.auth = { token }
})
// server/api/user.get.ts
export default defineEventHandler(async (event) => {
const { token } = event.context.auth
if (!token) {
throw createError({ statusCode: 401, message: '未登录' })
}
// 使用 token 调用外部用户服务
const user = await $fetch('https://auth-api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
})
return user
})4.5 完整鉴权流程
浏览器请求 → Nuxt SSR → server/middleware/auth.ts(提取 Token)
↓
server/api/user.get.ts(使用 Token 调用外部 API)
↓
返回用户数据 → 序列化到 Payload → 客户端复用
// server/utils/auth.ts — 服务端鉴权工具
export async function requireAuth(event: H3Event) {
const { token } = event.context.auth
if (!token) {
throw createError({ statusCode: 401, message: '请先登录' })
}
try {
const user = await verifyToken(token)
return user
} catch {
throw createError({ statusCode: 401, message: 'Token 已过期' })
}
}
export function requireRole(event: H3Event, role: string) {
const user = event.context.auth.user
if (user?.role !== role) {
throw createError({ statusCode: 403, message: '权限不足' })
}
}
// server/api/admin/users.get.ts
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
requireRole(event, 'admin')
// 只有管理员能访问
return await fetchAllUsers()
})本章小结
- $fetch:基于 ofetch 的全局 HTTP 客户端,SSR 时内部 API 直接调用(不走网络)
- 拦截器:
$fetch.create()创建实例,通过onRequest/onResponse拦截器统一处理 - BFF 架构:
server/api/作为中间层,隐藏 API Key、聚合请求、转换数据 - 安全实践:敏感信息放
runtimeConfig(非 public),只在 server/ 中使用 - Token 透传:
useRequestHeaders(['cookie'])在 SSR 时透传浏览器 Cookie - 服务端鉴权:server/middleware 提取 Token → server/utils 封装 requireAuth → API 路由使用
下一章讲数据缓存与请求去重——让应用更快、更省流量。