$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)原生 fetchaxios
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 透传与鉴权

// ❌ 问题:SSR 时 useFetch 调用 /api/user
// 服务端直接调用处理函数,但没有浏览器的 Cookie
const { data: user } = await useFetch('/api/user')
// SSR 时:Cookie 丢失 → API 返回 401

4.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 路由使用

下一章讲数据缓存与请求去重——让应用更快、更省流量。