API 封装与 BFF 实践
在实际前端开发中,与后端的通信交互是最常见的业务场景之一。随着前端应用复杂度提升,直接调用后端 API 会暴露出接口粒度过细、数据格式不匹配、多端适配困难等问题。BFF (Backend For Frontend) 模式和一套规范的 API 封装层,正是解决这些问题的关键手段。
本文将从前端视角出发,系统性地讨论 API 封装的最佳实践、BFF 层的设计与搭建,以及配套的工具链选型。
1. BFF(Backend For Frontend)概念
1.1 什么是 BFF,为什么需要 BFF
BFF 是由 Sam Newman 在《Building Microservices》中提出的模式:为每一种前端(Web / Mobile / Desktop)单独提供一个专用的后端服务层。
传统模式下,前端直接调用后端微服务 API,会遇到以下问题:
| 问题 | 场景说明 | 后果 |
|---|---|---|
| 接口粒度过粗 | 一个详情页需要调用 5~8 个微服务接口 | 页面加载慢,首屏性能差 |
| 数据过载 | 后端返回 30 个字段,页面只需要 5 个 | 带宽浪费,响应延迟 |
| 格式不匹配 | 后端返回 snake_case,前端需要 camelCase | 需要额外转换逻辑散落在各处 |
| 多端差异 | Web 端和 Mobile 端需要不同的数据结构 | 后端需要维护多套接口 |
| 安全暴露 | 微服务内部鉴权方式暴露给前端 | 安全风险增加 |
引入 BFF 层后,前端只与 BFF 通信,BFF 负责聚合数据、裁剪字段、格式转换和鉴权处理。
1.2 BFF 层职责
| 职责 | 说明 | 典型实现 |
|---|---|---|
| 数据聚合 | 合并多个后端接口的数据,提供一次前端调用 | GraphQL / BFF 聚合路由 |
| 格式转换 | snake_case 转 camelCase,时间格式统一 | 中间件拦截器 |
| 字段裁剪 | 只返回前端需要的字段,减少传输量 | 查询投影 / 选择性返回 |
| 鉴权代理 | 统一处理 Token 刷新、Cookie 透传 | 请求拦截器 |
| 缓存策略 | 对特定数据做服务端缓存 | Redis / CDN |
1.3 BFF 与 API Gateway 的区别
两者常被混淆,但职责有明显差异:
| 维度 | API Gateway | BFF |
|---|---|---|
| 定位 | 全局性入口,横切关注点 | 专为前端服务的适配层 |
| 主要职责 | 路由、限流、鉴权、日志 | 数据聚合、格式适配、字段裁剪 |
| 粒度 | 透传后端接口,不修改数据 | 对数据做加工和转换 |
| 数量 | 通常只有一个 | 每种前端各有一个(Web BFF / Mobile BFF) |
| 变更频率 | 低,基础设施级 | 高,随前端需求变更 |
| 维护团队 | 平台/基础设施团队 | 前端团队或全栈团队 |
最佳实践:API Gateway 在前,BFF 在后。请求链路:
Client -> API Gateway -> BFF -> Microservices。Gateway 负责横向通用能力,BFF 负责纵向业务适配。
2. 前端 API 层设计
没有 BFF 层时,前端直接调用后端接口。此时 API 层的设计质量,直接决定了代码的可维护性和开发效率。
2.1 API 模块按业务领域拆分
无论使用哪个框架,API 层都应遵循按业务领域拆分的原则,而非按 HTTP 方法或后端服务拆分。
✅ 推荐:按业务领域拆分
api/
user.ts # 用户相关接口
product.ts # 商品相关接口
order.ts # 订单相关接口
payment.ts # 支付相关接口
message.ts # 消息通知相关接口
❌ 不推荐:按后端服务拆分
api/
userService.ts # user-service 的所有接口
orderService.ts # order-service 的所有接口
...
理由:前端页面通常以业务领域为单位组织,按领域拆分更符合前端使用习惯,查找和维护更方便。
2.2 目录结构示例
Vue 3 项目 API 目录
src/
api/
request.ts # 请求实例(axios 封装)
types/
user.ts # 用户相关类型定义
product.ts # 商品相关类型定义
order.ts # 订单相关类型定义
user.ts # 用户 API 接口
product.ts # 商品 API 接口
order.ts # 订单 API 接口
index.ts # 统一导出
React 项目 API 目录
src/
api/
request.ts # 请求实例(axios/fetch 封装)
types/
user.ts
product.ts
order.ts
user.ts
product.ts
order.ts
index.ts
两个框架的 API 目录结构完全一致,区别在于组件中如何使用这些 API。
2.3 请求实例封装(request.ts)
请求实例封装是 API 层的基础设施,需要统一处理:超时配置、拦截器(请求/响应)、Token 注入、错误处理。
// src/api/request.ts — 通用请求封装
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/user' // Pinia(Vue)或 Zustand(React)
import { ElMessage } from 'element-plus' // Element Plus(Vue)或 antd message(React)
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
})
// ----- 请求拦截器 -----
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = useUserStore.getState().token // 从状态管理获取 Token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
// ----- 响应拦截器(防御式编程) -----
request.interceptors.response.use(
(response) => {
const { code, data, message } = response.data
// 永远不要信任后端返回的数据 — 防御式编程
if (code === undefined) {
// 后端没有返回 code 字段,可能不是标准响应格式
// 直接返回 response.data 让调用方自行处理
return response.data
}
if (code !== 200) {
// 业务错误
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message || '请求失败'))
}
return data // 只返回业务数据,统一拆包
},
(error: AxiosError) => {
// 网络错误 / HTTP 状态码错误
if (!error.response) {
ElMessage.error('网络异常,请检查网络连接')
return Promise.reject(error)
}
const status = error.response.status
const statusMessages: Record<number, string> = {
401: '登录已过期,请重新登录',
403: '没有访问权限',
404: '请求的资源不存在',
500: '服务器内部错误',
502: '网关错误',
503: '服务暂不可用',
}
ElMessage.error(statusMessages[status] || `请求失败 (${status})`)
return Promise.reject(error)
},
)
export default request
2.4 API 模块实现示例
Vue 3 Composition API + Pinia 中的 API 调用
// src/api/product.ts
import request from './request'
import type { Product, ProductQuery, PageResult } from './types/product'
/** 获取商品列表 */
export function getProductList(params: ProductQuery) {
return request.get<PageResult<Product>>('/products', { params })
}
/** 获取商品详情 */
export function getProductDetail(id: number) {
return request.get<Product>(`/products/${id}`)
}
/** 创建商品 */
export function createProduct(data: Omit<Product, 'id' | 'createdAt'>) {
return request.post<Product>('/products', data)
}
<!-- src/views/product/ProductList.vue — Vue 3 Composition API -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElTable, ElTableColumn, ElButton, ElPagination } from 'element-plus'
import { useRouter } from 'vue-router'
import { getProductList } from '@/api/product'
import type { Product, ProductQuery } from '@/api/types/product'
const router = useRouter()
const productList = ref<Product[]>([])
const loading = ref(false)
const total = ref(0)
const query = ref<ProductQuery>({ page: 1, pageSize: 10 })
async function fetchProducts() {
loading.value = true
try {
const res = await getProductList(query.value)
// 防御式编程:始终校验返回数据
productList.value = Array.isArray(res?.list) ? res.list : []
total.value = res?.total ?? 0
} catch (error) {
// 响应拦截器已处理 toast,这里无需重复处理
productList.value = []
} finally {
loading.value = false
}
}
function handleEdit(id: number) {
router.push(`/product/edit/${id}`)
}
onMounted(fetchProducts)
</script>
<template>
<div class="product-list">
<ElTable :data="productList" v-loading="loading">
<ElTableColumn prop="name" label="商品名称" />
<ElTableColumn prop="price" label="价格" />
<ElTableColumn prop="status" label="状态" />
<ElTableColumn label="操作">
<template #default="{ row }">
<ElButton type="primary" link @click="handleEdit(row.id)">编辑</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElPagination
v-model:current-page="query.page"
:page-size="query.pageSize"
:total="total"
layout="prev, pager, next"
@current-change="fetchProducts"
/>
</div>
</template>
React Hooks + Zustand 中的 API 调用
// src/api/product.ts(React 项目中完全相同的 API 定义)
import request from './request'
import type { Product, ProductQuery, PageResult } from './types/product'
export function getProductList(params: ProductQuery) {
return request.get<PageResult<Product>>('/products', { params })
}
export function getProductDetail(id: number) {
return request.get<Product>(`/products/${id}`)
}
export function createProduct(data: Omit<Product, 'id' | 'createdAt'>) {
return request.post<Product>('/products', data)
}
// src/pages/product/ProductList.tsx — React Hooks
import { useState, useEffect } from 'react'
import { Table, Button, Pagination, message } from 'antd'
import { useNavigate } from 'react-router-dom'
import { getProductList } from '@/api/product'
import type { Product, ProductQuery } from '@/api/types/product'
const ProductList: React.FC = () => {
const navigate = useNavigate()
const [productList, setProductList] = useState<Product[]>([])
const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0)
const [query, setQuery] = useState<ProductQuery>({ page: 1, pageSize: 10 })
const fetchProducts = async () => {
setLoading(true)
try {
const res = await getProductList(query)
// 防御式编程:始终校验返回数据
setProductList(Array.isArray(res?.list) ? res.list : [])
setTotal(res?.total ?? 0)
} catch {
setProductList([])
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchProducts()
}, [query])
const columns = [
{ title: '商品名称', dataIndex: 'name', key: 'name' },
{ title: '价格', dataIndex: 'price', key: 'price' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{
title: '操作',
key: 'action',
render: (_: unknown, record: Product) => (
<Button type="primary" link onClick={() => navigate(`/product/edit/${record.id}`)}>
编辑
</Button>
),
},
]
return (
<div>
<Table
dataSource={productList}
columns={columns}
loading={loading}
rowKey="id"
pagination={{
current: query.page,
pageSize: query.pageSize,
total,
onChange: (page) => setQuery((prev) => ({ ...prev, page })),
}}
/>
</div>
)
}
export default ProductList
2.5 API 层设计原则与踩坑点
✅ 推荐实践
| 原则 | 说明 |
|---|---|
| 单一职责 | 每个函数只对应一个接口,不要在一个函数里做两次请求 |
| 类型约束 | 所有请求参数和响应都定义明确的 TypeScript 类型 |
| 统一入参 | 参数优先使用对象形式,便于扩展 |
| 错误兜底 | 即使类型声明了字段,使用时也要做空值判断 |
| 命名规范 | getXxx / createXxx / updateXxx / deleteXxx 统一前缀 |
❌ 常见踩坑
- 直接在组件中写请求逻辑 —— 不可复用、难以测试
- 手动拼接 URL 参数 —— 用 axios 的
params替代 - 请求超时不处理 —— 必须设置超时时间
- 响应数据不做校验 —— 后端字段名、类型可能随时变更
3. 类型安全
前后端类型不一致是前端开发中最常见的 bug 来源之一。TypeScript 可以大幅减少这类问题,但前提是类型定义必须与后端接口保持同步。
3.1 接口响应类型定义
定义响应类型时需要注意防御式编程原则:后端可能返回空值、缺少字段、甚至完全不同的结构。
// src/api/types/common.ts — 通用响应类型
/** 统一分页响应结构 */
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
// 防御式编程:某些后端可能返回 totalCount、count 而非 total
// 如果后端不统一,需要在 API 层适配,而不是在组件中兼容
}
/** 通用业务响应(如果后端使用统一包裹格式) */
export interface ApiResponse<T> {
code: number
message: string
data: T
}
/** 可选字段标记:后端可能缺失的字段 */
export type Nullable<T> = T | null | undefined
// src/api/types/user.ts — 业务类型定义
export interface User {
id: number
username: string
nickname: string
email: string
phone: string
avatar: string
/** 防御式编程:后端可能返回字符串或数字 */
status: number | string
/** 时间字段统一声明为 string,前端自行格式化 */
createdAt: string
updatedAt: string
}
export interface UserQuery {
page?: number
pageSize?: number
keyword?: string
status?: number | string
}
export interface CreateUserParams {
username: string
nickname: string
email: string
phone: string
password: string
}
3.2 请求参数类型定义
// src/api/types/product.ts
export interface Product {
id: number
name: string
price: number
categoryId: number
categoryName: string
status: ProductStatus
images: string[]
description: string
createdAt: string
updatedAt: string
}
export enum ProductStatus {
DRAFT = 0,
ON_SALE = 1,
OFF_SHELF = 2,
}
export interface ProductQuery {
page: number
pageSize: number
keyword?: string
categoryId?: number
status?: ProductStatus
minPrice?: number
maxPrice?: number
}
3.3 OpenAPI/Swagger → TypeScript 类型自动生成
手动维护类型定义会导致类型滞后问题。推荐使用 openapi-typescript 工具从后端 Swagger 文档自动生成 TypeScript 类型。
安装与配置
# 安装 CLI 工具
pnpm add -D openapi-typescript @openapitools/openapi-generator-cli
生成命令
# 从 Swagger JSON 生成类型
npx openapi-typescript https://api.example.com/swagger/v1/swagger.json -o src/api/types/generated.ts
生成的类型使用示例
// src/api/types/generated.ts(自动生成,禁止手动修改)
export interface paths {
'/api/products': {
get: {
parameters: {
query: {
page?: number
pageSize?: number
keyword?: string
}
}
responses: {
200: {
content: {
'application/json': {
code: number
data: {
list: components['schemas']['Product'][]
total: number
}
}
}
}
}
}
}
}
export interface components {
schemas: {
Product: {
id: number
name: string
price: number
/** @description 0-草稿 1-上架 2-下架 */
status: number
created_at: string
updated_at: string
}
}
}
结合自定义类型
// src/api/types/product.ts — 手动补充的类型(基于自动生成类型)
import type { components } from './generated'
// 直接从生成文件中引用
export type ProductDTO = components['schemas']['Product']
// 前端业务类型:将 DTO 转换为前端友好格式
export interface Product {
id: number
name: string
price: number
status: ProductStatus
statusLabel: string // 前端额外需要的字段
createdAt: string
updatedAt: string
}
// 转换函数(DTO → 前端 Model)
export function toProduct(dto: ProductDTO): Product {
return {
id: dto.id,
name: dto.name,
price: dto.price,
status: dto.status as ProductStatus,
statusLabel: dto.status === 1 ? '上架' : dto.status === 0 ? '草稿' : '下架',
createdAt: dto.created_at,
updatedAt: dto.updated_at,
}
}
3.4 前后端类型共享(Monorepo)
在 Monorepo 架构下,前后端可以共享类型定义,从源头解决类型不一致问题。
目录结构
monorepo/
packages/
shared/ # 共享包
src/
types/
user.ts # 前后端共用的用户类型
product.ts # 前后端共用的商品类型
api.ts # API 路径与参数类型
package.json # 共享包的 package.json
web/ # 前端项目
server/ # 后端项目
共享包定义
// packages/shared/src/types/product.ts
// 后端和前段共同使用的类型定义
export enum ProductStatus {
DRAFT = 0,
ON_SALE = 1,
OFF_SHELF = 2,
}
export interface Product {
id: number
name: string
price: number
status: ProductStatus
createdAt: string
updatedAt: string
}
// API 请求和响应的类型
export interface ProductQuery {
page: number
pageSize: number
keyword?: string
status?: ProductStatus
}
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
前端使用共享类型
// web/src/api/product.ts
import type { Product, ProductQuery, PageResult } from '@myapp/shared'
import request from './request'
export function getProductList(params: ProductQuery) {
return request.get<PageResult<Product>>('/products', { params })
}
类型共享方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 手动维护 | 简单直接 | 易过时,维护成本高 | 小型项目,或后端不稳定 |
| openapi-typescript | 自动化,与 Swagger 同步 | 需要后端维护好 Swagger | 有标准 OpenAPI 规范的项目 |
| Monorepo 共享 | 强一致性,零延迟 | 需要 Monorepo 架构 | 中大型项目、全栈团队 |
| protobuf + buf | 语言无关,跨平台 | 引入成本高 | 多语言、微服务架构 |
3.5 类型安全踩坑点
- 枚举值变更 —— 后端新增枚举值后,前端类型未同步导致界面异常。解决方案:API 层做防御式转换,对未知枚举值兜底处理。
- null vs undefined —— 后端可能返回
null,前端类型声明为string会导致运行时错误。解决方案:使用??运算符做默认值处理。 - 数字精度丢失 —— 大数字(如雪花 ID)在 JSON 解析时可能丢失精度。解决方案:后端返回字符串类型的大数字。
- 日期格式不统一 —— 不同后端服务可能返回
yyyy-MM-dd、ISO8601、时间戳。解决方案:在 API 层统一处理时间解析与格式化。
4. API 版本管理
4.1 版本策略对比
| 策略 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径 | /api/v1/products |
直观、路由层次清晰 | URL 冗余,版本变更需要改路径 |
| Header | Accept: application/vnd.company.v1+json |
URL 干净 | 调试不方便,文档不直观 |
| Query 参数 | /api/products?version=1 |
简单 | 容易和业务参数混淆 |
| 域名 | v1.api.example.com |
完全隔离 | 运维成本高 |
推荐:URL 路径版本号是最常用的方案,兼顾直观性和可维护性。
4.2 前端多版本共存策略
当某些旧接口切换到新版本后,前端可能需要同时对接 v1 和 v2 两个版本。
// src/api/request.ts — 支持多版本的基础请求封装
import axios from 'axios'
// 创建不同版本的实例
export const requestV1 = axios.create({
baseURL: `${import.meta.env.VITE_API_BASE_URL}/api/v1`,
timeout: 10000,
})
export const requestV2 = axios.create({
baseURL: `${import.meta.env.VITE_API_BASE_URL}/api/v2`,
timeout: 10000,
})
// 统一拦截器逻辑
function setupInterceptors(instance: ReturnType<typeof axios.create>) {
instance.interceptors.request.use(/* ... */)
instance.interceptors.response.use(/* ... */)
}
setupInterceptors(requestV1)
setupInterceptors(requestV2)
// src/api/product.ts — 不同版本调用不同实例
import { requestV1, requestV2 } from './request'
import type { Product } from './types/product'
// v1 接口(老版本,新页面不推荐使用)
export function getProductListV1() {
return requestV1.get<Product[]>('/products')
}
// v2 接口(新版本,支持分批查询)
export function getProductListV2(params: { page: number; pageSize: number }) {
return requestV2.get<Product[]>('/products', { params })
}
4.3 版本废弃与迁移策略
| 阶段 | 状态 | 说明 | 前端操作 |
|---|---|---|---|
| 开发中 | alpha |
测试接口,不稳定 | 手动添加版本号测试 |
| 稳定期 | v1 |
稳定版本,长期维护 | 默认使用 |
| 替代期 | v1-deprecated |
新版本 v2 已上线 | 逐步迁移到 v2 |
| 下线期 | v1-removed |
旧版本已下线 | 需要确保所有引用已迁移 |
原则:旧版本下线前至少提前一个版本周期(通常 3~6 个月)标注
deprecated,并在响应头中加入X-Deprecated: true提示前端。
5. Mock 方案
开发时后端接口尚未就绪是常态,Mock 方案的选择直接影响开发效率。
5.1 方案对比
| 方案 | 原理 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| MSW | 在 Service Worker 层拦截请求 | 不影响代码,真实度最高 | 配置稍复杂 | ⭐⭐⭐⭐⭐ |
| vite-plugin-mock | Vite 插件,本地起 mock server | 零配置,快速 | 只适用于 Vite 项目 | ⭐⭐⭐⭐ |
| Rap2 / YApi | 独立 mock 平台 | 团队协作 | 需要部署 | ⭐⭐⭐ |
| json-server | 本地 json 文件服务 | 简单 | 不支持复杂逻辑 | ⭐⭐⭐ |
| 代码层拦截 | axios mock adapter | 灵活 | 侵入代码 | ⭐⭐ |
5.2 MSW(Mock Service Worker)推荐
MSW 的核心优势在于:它不会修改你的请求代码,而是在浏览器 Service Worker 层面拦截请求并返回 Mock 数据,开发环境和正式环境互不干扰。
安装与初始化
pnpm add -D msw
npx msw init public/ --save
Mock 数据定义
// src/mocks/handlers/product.ts
import { http, HttpResponse } from 'msw'
export const productHandlers = [
// 模拟 GET 请求
http.get('/api/v1/products', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page')) || 1
const pageSize = Number(url.searchParams.get('pageSize')) || 10
return HttpResponse.json({
code: 200,
message: 'success',
data: {
list: Array.from({ length: pageSize }, (_, i) => ({
id: (page - 1) * pageSize + i + 1,
name: `商品 ${(page - 1) * pageSize + i + 1}`,
price: Math.floor(Math.random() * 10000) / 100,
status: Math.floor(Math.random() * 3),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})),
total: 156,
page,
pageSize,
},
})
}),
// 模拟 POST 请求
http.post('/api/v1/products', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
code: 200,
message: '创建成功',
data: { id: Date.now(), ...(body as object), createdAt: new Date().toISOString() },
})
}),
]
注册 MSW
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { productHandlers } from './handlers/product'
import { userHandlers } from './handlers/user'
import { orderHandlers } from './handlers/order'
export const worker = setupWorker(
...productHandlers,
...userHandlers,
...orderHandlers,
)
// src/main.ts(Vue)或 src/index.tsx(React)— 入口文件
async function bootstrap() {
// 开发环境启用 Mock
if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCK === 'true') {
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: 'bypass', // 未匹配的请求直接放行
})
}
// 挂载应用
const app = createApp(App)
app.use(router)
// ...
}
bootstrap()
5.3 vite-plugin-mock(轻量方案)
pnpm add -D vite-plugin-mock
// vite.config.ts
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [
vue(),
viteMockServe({
mockPath: 'mock', // mock 文件目录
enable: true, // 开发环境启用
watchFiles: true, // 监听文件修改自动刷新
}),
],
})
// mock/product.ts
export default [
{
url: '/api/v1/products',
method: 'get',
response: ({ query }) => {
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 10
return {
code: 200,
message: 'success',
data: {
list: Array.from({ length: pageSize }, (_, i) => ({
id: `${page}-${i + 1}`,
name: `Mock 商品 ${i + 1}`,
})),
total: 100,
},
}
},
},
]
5.4 Mock 数据管理最佳实践
| 实践 | 说明 |
|---|---|
| 独立目录 | Mock 数据放在 mock/ 或 src/mocks/ 目录,不与业务代码混合 |
| 场景覆盖 | 覆盖正常、空数据、异常、加载中四种场景 |
| 环境开关 | 通过环境变量控制 Mock 启用,不应影响构建产物 |
| Mock 类型 | Mock 数据也要有 TypeScript 类型,与真实接口一致 |
| 过期清理 | 后端接口就绪后及时清理对应 Mock |
6. BFF 自建实践(Node.js)
当前端需要聚合多个后端接口的数据,或者对数据做大量裁剪/转换时,搭建一层 BFF 是最优选择。
6.1 技术选型对比
| 方案 | 生态 | 适用框架 | 部署方式 | 学习成本 |
|---|---|---|---|---|
| Nuxt / Nitro | Vue | Vue 3 | Serverless / Node | 低(Vue 开发者友好) |
| Next.js API Routes | React | React | Serverless / Node | 低(React 开发者友好) |
| Express / Koa | 通用 | 任意 | Node 服务 | 中 |
| GraphQL | 通用 | 任意 | Node 服务 | 高 |
6.2 Vue 生态:Nitro(Nuxt 服务端引擎)
Nitro 是 Nuxt 3 底层使用的服务端引擎,可以独立于 Nuxt 使用,适合搭建 BFF 层。
Nitro BFF 示例
// nitro/routes/api/v1/products/index.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 10
try {
// 1. 调用后端商品服务
const productRes = await $fetch('http://backend:8080/api/products', {
params: { page, pageSize },
})
// 2. 调用后端分类服务
const categoryRes = await $fetch('http://backend:8080/api/categories')
// 3. 数据聚合与裁剪
const products = productRes.data.list.map((item) => ({
id: item.id,
name: item.name,
price: item.price,
categoryName: categoryRes.data.find((c) => c.id === item.categoryId)?.name ?? '未知',
status: item.status === 1 ? '上架' : item.status === 0 ? '草稿' : '下架',
createdAt: item.created_at,
}))
return {
code: 200,
data: {
list: products,
total: productRes.data.total,
page,
pageSize,
},
}
} catch (error) {
// BFF 层必须做好错误兜底,不能将后端错误直接暴露给前端
console.error('BFF error:', error)
throw createError({
statusCode: 502,
statusMessage: '后端请求失败',
})
}
})
6.3 React 生态:Next.js Route Handlers
Next.js App Router 的 Route Handlers 可以直接充当前端项目的 BFF 层。
// app/api/products/route.ts — Next.js Route Handler
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = Number(searchParams.get('page')) || 1
const pageSize = Number(searchParams.get('pageSize')) || 10
try {
// 1. 调用多个后端服务
const [productsRes, categoriesRes] = await Promise.all([
fetch(`http://backend:8080/api/products?page=${page}&pageSize=${pageSize}`),
fetch('http://backend:8080/api/categories'),
])
if (!productsRes.ok || !categoriesRes.ok) {
return NextResponse.json(
{ code: 502, message: '后端服务异常' },
{ status: 502 },
)
}
const productsData = await productsRes.json()
const categoriesData = await categoriesRes.json()
// 2. 聚合与转换
const products = productsData.data.list.map((item: any) => ({
id: item.id,
name: item.name,
price: item.price,
categoryName: categoriesData.data.find((c: any) => c.id === item.categoryId)?.name ?? '未知',
// 只返回前端需要的字段,裁剪无用数据
}))
return NextResponse.json({
code: 200,
data: { list: products, total: productsData.data.total, page, pageSize },
})
} catch (error) {
console.error('BFF error:', error)
return NextResponse.json(
{ code: 500, message: '内部错误' },
{ status: 500 },
)
}
}
6.4 数据聚合示例:一次 BFF 请求替代多次前端请求
以下场景直观说明 BFF 的价值:一个商品详情页面,前端需要在 BFF 介入前发起 5 次请求,BFF 介入后只需 1 次。
BFF 介入前(前端发起 5 次请求)
Frontend Backend
| |
|--- GET /products/123 |
|--- GET /categories/5 |
|--- GET /shops/10 |
|--- GET /reviews?pid=123 |
|--- GET /stock?pid=123 |
| |
|← 5 次响应,总传输 ~50KB |
| |
BFF 介入后(前端发起 1 次请求)
Frontend BFF Backend
| | |
|--- GET /bff/products/123 |
| |--- GET /products/123 |
| |--- GET /categories/5 |
| |--- GET /shops/10 |
| |--- GET /reviews?pid=123 |
| |--- GET /stock?pid=123 |
| | |
| |← 5 次响应,BFF 聚合裁剪 |
|← 1 次响应,总传输 ~15KB |
| |
BFF 聚合实现
// nitro/routes/bff/products/[id].ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
// 并行请求多个后端服务
const [product, category, shop, reviews, stock] = await Promise.all([
$fetch(`http://backend:8080/api/products/${id}`),
$fetch(`http://backend:8080/api/products/${id}/category`).catch(() => null),
$fetch(`http://backend:8080/api/products/${id}/shop`).catch(() => null),
$fetch(`http://backend:8080/api/reviews`, { params: { productId: id, limit: 10 } }).catch(() => []),
$fetch(`http://backend:8080/api/stock/${id}`).catch(() => ({ quantity: 0 })),
])
// 防御式编程:任何后端服务不可用都不应该导致整页挂掉
return {
id: product.id,
name: product.name,
price: product.price,
images: product.images?.slice(0, 5) ?? [], // 只返回前 5 张图片
category: category ? { id: category.id, name: category.name } : null,
shop: shop ? { id: shop.id, name: shop.name, logo: shop.logo } : null,
reviews: Array.isArray(reviews) ? reviews.slice(0, 10) : [],
stock: stock.quantity ?? 0,
}
})
6.5 BFF 层的防御式编程
| 场景 | 策略 |
|---|---|
| 后端服务不可用 | BFF 不应将后端错误直接暴露给前端,应返回友好错误或降级数据 |
| 后端数据缺失 | 使用 ?? 提供默认值,不要直接抛 NPE |
| 单个服务超时 | 不要因为一个服务超时导致整体请求失败,设置合理超时并降级处理 |
| 响应过大 | 对列表数据做分页截断,对图片列表做数量限制 |
7. GraphQL 作为 BFF 的替代方案
7.1 概念介绍
GraphQL 由 Facebook 于 2012 年开发,2015 年开源。它提供了一种声明式的数据查询语言,客户端可以精确指定需要哪些字段,服务端按需返回。
7.2 GraphQL vs BFF 对比
| 维度 | BFF(REST) | GraphQL |
|---|---|---|
| 数据获取 | 服务端预定义返回结构 | 客户端指定查询字段 |
| 接口数量 | 需要为每个页面场景定义接口 | 单一端点,按需查询 |
| 数据聚合 | 代码聚合(Promise.all) | Schema 自动关联查询 |
| 类型安全 | 需要手动或工具生成类型 | Schema 驱动的强类型 |
| 学习成本 | 低(传统 REST) | 中高(Query / Mutation / Subscription) |
| 缓存 | HTTP 缓存天然支持 | 需要手动处理缓存策略 |
| 调试工具 | 浏览器 DevTools / Postman | GraphiQL / Apollo DevTools |
| 文件上传 | 原生支持 multipart | 需要额外实现 |
| 适用场景 | 稳定接口 / 标准 CRUD | 复杂数据关联 / 多端适配 |
7.3 何时选择 GraphQL
推荐 GraphQL 的场景:
├── ✅ 多端产品(Web + iOS + Android),各端数据需求差异大
├── ✅ 数据关联复杂,嵌套深度大的场景(社交图谱、CMS)
├── ✅ 前端团队对数据需求有较多控制权
└── ✅ 产品迭代频繁,数据结构变化快
推荐 BFF(REST)的场景:
├── ✅ 标准 CRUD 应用(后台管理、报表系统)
├── ✅ 团队对 GraphQL 不熟悉,学习成本高
├── ✅ 对 HTTP 缓存有强依赖
└── ✅ 需要对接大量外部 REST 服务
7.4 简化示例(供对比)
# Schema 定义
type Product {
id: ID!
name: String!
price: Float!
category: Category
reviews(first: Int): [Review!]!
stock: Int
}
type Category {
id: ID!
name: String!
}
type Review {
id: ID!
content: String!
rating: Int!
user: User!
}
type Query {
product(id: ID!): Product
products(page: Int, pageSize: Int): [Product!]!
}
# 前端查询(精确指定需要的字段)
query ProductDetail($id: ID!) {
product(id: $id) {
id
name
price
category { id name }
stock
reviews(first: 10) {
content
rating
user { nickname }
}
}
}
注意:GraphQL 不是 BFF 的替代品,而是另一种实现思路。实际上,GraphQL 也可以作为 BFF 层内部的查询协议使用。
总结
API 封装与 BFF 实践的核心理念可以归纳为以下几条:
- 分层解耦:前端 -> BFF -> 后端微服务,每一层各司其职,互不侵入
- 类型安全:通过 openapi-typescript 自动生成或 Monorepo 共享类型,消灭前后端类型不一致
- 防御式编程:永远不信任后端返回的数据,每一层都做好空值和异常兜底
- 按需裁剪:BFF 层负责字段裁剪和数据聚合,让前端拿到刚刚好的数据
- Mock 先行:使用 MSW 在 Service Worker 层做 Mock,开发效率与接口同步解耦
最终目标是:前端专注于 UI 和交互,数据获取的复杂度交给 API 层和 BFF 层。
| 维度 | 推荐方案 | 备选方案 |
|---|---|---|
| 请求封装 | axios(request.ts) | ofetch / ky |
| 类型生成 | openapi-typescript | graphql-codegen |
| Mock | MSW | vite-plugin-mock |
| BFF(Vue) | Nuxt / Nitro | Express |
| BFF(React) | Next.js Route Handlers | Express |
| 替代方案 | BFF(REST) | GraphQL(Apollo / urql) |