写在前面
API 设计是软件工程中的一门艺术。好的 API 像一个设计精良的工具:用起来顺手,意图清晰,不容易用错。糟糕的 API 则像一个布满隐患的黑盒:调用者不知道该传什么,不知道返回什么,出了问题也不知道是谁的责任。
本篇从 API 设计的哲学出发,深入 RESTful 的理论基础与实践边界,分析 GraphQL 解决了什么问题又带来了什么新问题,介绍 BFF(Backend for Frontend)模式如何消解前端的 API 痛点,最后从领域驱动设计(DDD)的视角看 API 的组织方式。这些思考,不只是"怎么写 api/user.js",而是"如何设计一个前后端都满意的 API 层"。
目录
- [1. API 设计的哲学:契约优先思维](#1. API 设计的哲学:契约优先思维)
- [2. RESTful 深度解析:六大约束的真正含义](#2. RESTful 深度解析:六大约束的真正含义)
- [3. REST 的实践边界:什么时候 REST 不够用](#3. REST 的实践边界:什么时候 REST 不够用)
- [4. GraphQL:声明式数据获取的革命](#4. GraphQL:声明式数据获取的革命)
- [5. tRPC:端到端类型安全的新范式](#5. tRPC:端到端类型安全的新范式)
- [6. BFF 模式:为前端量身定制的 API 层](#6. BFF 模式:为前端量身定制的 API 层)
- [7. API 版本策略:如何优雅地演进接口](#7. API 版本策略:如何优雅地演进接口)
- [8. 领域驱动设计(DDD)视角下的 API 组织](#8. 领域驱动设计(DDD)视角下的 API 组织)
- [9. 契约测试:前后端联调的工程保障](#9. 契约测试:前后端联调的工程保障)
- [10. 前端 API 层的完整工程实现](#10. 前端 API 层的完整工程实现)
- [11. 接口安全设计](#11. 接口安全设计)
- [12. 常见坑深度解析](#12. 常见坑深度解析)
- 小结
1. API 设计的哲学:契约优先思维
API 是什么
API(Application Programming Interface,应用程序编程接口)的本质是模块间的契约(Contract)。这个契约规定:
- 我接受什么输入(请求参数)
- 我保证什么输出(响应格式)
- 我在什么情况下会失败(错误码)
契约一旦建立,双方都不能单方面违反。后端改了响应字段名,前端代码立刻出 bug;前端传了错误参数,后端应该给出清晰的错误说明,而不是静默失败。
契约优先(Contract First)vs 代码优先(Code First)
代码优先(Code First):先写后端代码,再从代码生成文档。
javascript
// 后端写了这个接口
app.get('/users', async (ctx) => {
const users = await db.findAll()
ctx.body = { code: 200, data: users }
})
// 然后前端来问:这个接口返回什么格式?
// 后端:你自己看代码吧......或者等我写文档
契约优先(Contract First):先用 OpenAPI/GraphQL Schema 等描述语言定义接口契约,前后端都基于契约实现。
yaml
# openapi.yaml - 先定义契约
paths:
/users:
get:
summary: 获取用户列表
parameters:
- name: page
in: query
schema: { type: integer, minimum: 1 }
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/UserListResponse'
契约优先的好处:
- 前后端并行开发(前端 Mock + 契约,不用等后端)
- 接口文档和实现永远一致(由工具保证)
- 变更有明确的破坏性变更检测
好 API 的四个标准
1. 一致性(Consistency):命名规则、响应格式、错误码在所有接口中保持一致,减少认知负担。
2. 可发现性(Discoverability) :通过 API 名称就能猜到用法,不需要查文档。getUserList 比 fetchData 更好,因为前者可发现。
3. 正向激励(Pit of Success):API 的"显然用法"应该是正确的用法,要把用户"引导"进好的实践,而不是"陷阱"。
4. 向后兼容(Backward Compatibility):新版本接口不应该破坏已有的调用方,变更需要有策略地进行。
2. RESTful 深度解析:六大约束的真正含义
Roy Fielding 在 2000 年的博士论文中定义了 REST 架构,它有六大约束(不是六大"最佳实践"):
约束 1:客户端-服务器(Client-Server)
关注点分离:
客户端负责 UI 和用户体验
服务端负责数据存储和业务逻辑
两者通过统一接口通信,互不依赖内部实现
意义:
前端可以独立演进(换 React、换 Vue),不影响后端
后端可以独立扩展(换数据库、换语言),不影响前端
约束 2:无状态(Stateless)
每个请求必须包含处理所需的全部信息,服务器不保存客户端状态
意义:
服务器无需维护会话,可以水平扩展(任意节点都能处理请求)
负载均衡不需要"粘性会话"(Sticky Session)
实践:
使用 JWT(自包含用户信息)而非 Session(服务器需要存储)
翻页参数由客户端携带(page=1&pageSize=20),不由服务器记录"当前页"
约束 3:可缓存(Cacheable)
响应必须标注是否可缓存,允许客户端/中间代理缓存响应
实践:
GET /users/list → 可缓存(数据不经常变化)
Cache-Control: public, max-age=60, stale-while-revalidate=30
POST /users → 不可缓存(写操作,结果不确定)
Cache-Control: no-store
GET /users/123 → 有条件缓存(资源可能被修改)
ETag: "abc123" ← 资源的指纹
Last-Modified: Mon, 01 Jan 2024 00:00:00 GMT
客户端条件请求:
If-None-Match: "abc123" → 如果 ETag 匹配,返回 304 Not Modified(节省带宽)
If-Modified-Since: ... → 如果没有修改,返回 304
约束 4:统一接口(Uniform Interface)
这是 REST 最核心的约束,包含四个子约束:
资源标识:每个资源有唯一 URI
/users/123 ← 用户 123
/users/123/orders ← 用户 123 的所有订单
/users/123/orders/456 ← 用户 123 的订单 456
通过表现层操纵资源:客户端通过获取资源的"表现"(JSON/XML/HTML)来理解和操纵资源
GET /users/123 → { id: 123, name: 'Alice', _links: { orders: '/users/123/orders' } }
// _links 是 HATEOAS(超媒体)的体现,告诉客户端"下一步可以做什么"
自描述消息:每个消息包含足够信息来描述如何处理
Content-Type: application/json; charset=utf-8
// 告诉接收方如何解析消息体
HATEOAS(超媒体作为应用状态引擎):
json
// 响应中包含相关操作的链接,客户端通过链接导航
{
"userId": "123",
"userName": "Alice",
"_links": {
"self": { "href": "/users/123" },
"orders": { "href": "/users/123/orders" },
"delete": { "href": "/users/123", "method": "DELETE" }
}
}
// 实践中很少完全实现 HATEOAS,但"接口中包含关联资源链接"的思想是有价值的
约束 5-6:分层系统 & 按需代码
分层系统:客户端不需要知道是直接与服务器通信,还是通过代理/网关
意义:CDN、API Gateway、负载均衡器对客户端透明
按需代码(可选):服务器可以下发可执行代码(如 JavaScript)扩展客户端功能
实践:WebAssembly 下发、Service Worker 更新
3. REST 的实践边界:什么时候 REST 不够用
REST 并不是万能的,理解它的局限有助于在复杂场景做出正确选择:
问题 1:Over-fetching(过度获取)
场景:用户列表页只需要显示 name 和 avatar,
但 GET /users 返回了每个用户完整的 50 个字段
→ 浪费带宽,前端还要过滤数据
问题 2:Under-fetching(获取不足,N+1 问题)
场景:显示用户列表,每行显示用户名和其所在部门名称
REST 解决方案:
1. GET /users → 100 个用户
2. GET /departments/1 → 部门 1 名称
3. GET /departments/2 → 部门 2 名称
......
101. GET /departments/100 → 部门 100 名称
共 101 个请求!这就是 N+1 问题。
解决 N+1 的 REST 方案:
方案 1:后端做 JOIN
GET /users?include=department → 用户 + 部门数据一次返回
问题:不通用,每种组合都要专门的接口
方案 2:前端批量请求
GET /departments?ids=1,2,3,4...
问题:URL 长度有限制(2000 字符左右),IDs 太多会报错
问题 3:版本管理复杂
REST API 演进时,破坏性变更(删除字段、改字段类型)如何处理?
/v1/users → 保持兼容,长期维护
/v2/users → 新版本
同时维护多个版本,维护成本随时间线性增长。
这些问题催生了 GraphQL 的诞生。
4. GraphQL:声明式数据获取的革命
GraphQL 的核心思想
GraphQL 由 Facebook 工程师在 2012 年设计,2015 年开源。它的核心是一个强类型的查询语言,让客户端精确指定需要什么数据:
graphql
# 客户端精确描述需要的数据
query GetUserList {
users(page: 1, pageSize: 10) {
id
name ← 只要名字
avatar ← 只要头像
department { ← 一次性获取关联的部门
name ← 只要部门名字
}
# 不需要 email、phone、address 等字段,就不写
}
}
服务器只返回查询中指定的字段:
json
{
"data": {
"users": [
{
"id": "1",
"name": "Alice",
"avatar": "https://...",
"department": { "name": "工程部" }
}
]
}
}
GraphQL vs REST 的核心差异
| 维度 | REST | GraphQL |
|---|---|---|
| 端点数量 | 多个(每个资源一个) | 通常只有一个(/graphql) |
| 数据获取 | 服务端决定返回什么 | 客户端决定需要什么 |
| Over/Under-fetching | 常见问题 | 天然解决 |
| 类型系统 | 无(依赖文档) | 强类型 Schema |
| 实时数据 | 需要 Polling/SSE/WebSocket | 内置 Subscription |
| N+1 问题 | 需要手动优化 | DataLoader 解决 |
| 学习曲线 | 低 | 中高 |
| 后端复杂度 | 低 | 高(Resolver 实现) |
GraphQL 的三种操作
graphql
# 1. Query(查询,类似 GET)
query {
user(id: "123") {
name
email
}
}
# 2. Mutation(修改,类似 POST/PUT/DELETE)
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
# 3. Subscription(订阅,实时推送)
subscription OnUserUpdated {
userUpdated {
id
name
updatedAt
}
}
# WebSocket 连接,服务器有变更时主动推送
Vue 中使用 GraphQL(Apollo Client)
javascript
// main.js
import { ApolloClient, InMemoryCache } from '@apollo/client/core'
import { provideApolloClient } from '@vue/apollo-composable'
const apolloClient = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache(),
headers: { Authorization: `Bearer ${token}` }
})
provideApolloClient(apolloClient)
// 组件中使用
import { useQuery, useMutation } from '@vue/apollo-composable'
import gql from 'graphql-tag'
const GET_USERS = gql`
query GetUsers($page: Int!) {
users(page: $page) {
id
name
department { name }
}
}
`
const { result, loading, error } = useQuery(GET_USERS, { page: 1 })
// result.value.users → 用户列表(自动更新)
GraphQL 的适用场景判断
适合 GraphQL 的场景:
✅ 数据关系复杂(多对多、深层嵌套)
✅ 多端(Web/App/小程序)共用同一 API,数据需求差异大
✅ 前端团队需要更多的数据获取自主权
✅ 产品迭代快,数据需求频繁变化
不适合 GraphQL 的场景:
❌ 简单的 CRUD 后台(REST 足够,GraphQL 过于复杂)
❌ 文件上传(GraphQL 需要特殊处理 multipart)
❌ 团队对 GraphQL 不熟悉,学习成本难以接受
❌ 后端不愿意重新实现 Resolver
本课程项目:REST 足够,不引入 GraphQL
5. tRPC:端到端类型安全的新范式
tRPC 是一个让前后端共享 TypeScript 类型的 RPC 框架,无需 schema 定义(像 GraphQL 的 Schema),类型从代码自动推断:
typescript
// 后端定义(server/router.ts)
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
export const appRouter = t.router({
getUserList: t.procedure
.input(z.object({ page: z.number(), pageSize: z.number() }))
.query(async ({ input }) => {
return await db.users.findMany({ skip: (input.page - 1) * input.pageSize })
// 返回类型:User[] (TypeScript 自动推断)
}),
createUser: t.procedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return await db.users.create({ data: input })
})
})
export type AppRouter = typeof appRouter // 导出类型(给前端用)
typescript
// 前端使用(不需要手写 API 方法!)
import { createTRPCClient } from '@trpc/client'
import type { AppRouter } from '../server/router' // 导入后端类型!
const trpc = createTRPCClient<AppRouter>({
links: [/* 配置 */]
})
// 类型完全推断!IDE 有完整提示
const users = await trpc.getUserList.query({ page: 1, pageSize: 10 })
// ^^^^^ 类型:User[],从后端自动推断,无需手写
await trpc.createUser.mutate({ name: 'Alice', email: 'alice@example.com' })
// 如果传了错误的字段,TypeScript 编译时就会报错,不需要等到运行时
tRPC 的核心价值:消灭前后端接口类型不同步的问题。后端改了返回类型,前端立刻得到编译错误,而不是等到运行时。
适用条件:前后端都是 TypeScript,且在同一个代码库(Monorepo)中。
6. BFF 模式:为前端量身定制的 API 层
BFF 是什么
BFF(Backend For Frontend,面向前端的后端)是一种架构模式:在后端微服务和前端之间,增加一个专门为前端服务的聚合层。
没有 BFF 的架构:
前端 → 直接调用 N 个微服务
微服务 A(用户服务)
微服务 B(订单服务)
微服务 C(库存服务)
问题:
- 前端需要知道哪些微服务提供什么数据
- 前端需要聚合多个微服务的数据(发多个请求)
- 不同前端(Web/App)对同一数据有不同格式需求
- 前端直接暴露后端微服务地址,安全问题
有 BFF 的架构:
Web 前端 → Web BFF → 微服务 A、B、C
App 前端 → App BFF → 微服务 A、B、C
BFF 的职责:
- 聚合多个微服务的数据(1 次调用替代 N 次)
- 为特定前端裁剪数据格式
- 认证鉴权(统一 Token 验证)
- 限流熔断(保护下游微服务)
BFF 解决的具体问题
问题 1:用户详情页聚合
javascript
// 没有 BFF:前端需要发 3 个请求
const [user, orders, permissions] = await Promise.all([
userService.getUser(userId),
orderService.getUserOrders(userId),
permissionService.getUserPermissions(userId)
])
// 有 BFF:前端只发 1 个请求
const userDetail = await bff.getUserDetail(userId)
// BFF 内部:并行调用 3 个微服务,聚合数据返回
问题 2:移动端数据裁剪
javascript
// Web 端需要完整用户数据(展示所有字段)
web-bff: GET /users/123 → { id, name, email, phone, address, bio, avatar, ... }
// App 端只需要关键信息(屏幕小,减少流量)
app-bff: GET /users/123 → { id, name, avatar }
// 同样的微服务,不同的 BFF 返回不同的数据结构
什么时候需要 BFF
适合引入 BFF 的情况:
✅ 微服务架构,前端需要聚合多个服务的数据
✅ 多端(Web/App/小程序)对数据格式有差异化需求
✅ 需要前端特定的认证/鉴权逻辑
✅ 需要在前后端之间增加缓存层
单体应用/本课程项目:
❌ 不需要 BFF,一个后端服务足够
直接用 Axios + API 模块即可
7. API 版本策略:如何优雅地演进接口
为什么 API 需要版本
API 演进的困境:
你有 100 个客户端使用 GET /users 接口
你需要修改响应格式(把 userName 字段改名为 name)
如果直接改:
→ 所有旧客户端立刻出 bug
需要版本化:
→ GET /v1/users → 返回 { userName: 'Alice' }(维持旧格式)
→ GET /v2/users → 返回 { name: 'Alice' }(新格式)
→ 客户端按需迁移,新客户端直接用 v2
四种版本化策略
策略 1:URL 路径版本(最常见)
GET /api/v1/users
GET /api/v2/users
优点:直观,版本在 URL 中一目了然,易于调试
缺点:URL 设计纯粹主义者认为 URL 应该是资源地址,版本不是资源的一部分
适用:大多数场景,包括本课程项目
策略 2:Query 参数版本
GET /api/users?version=1
GET /api/users?version=2
优点:URL 路径干净
缺点:版本容易被忽略,缓存策略复杂
策略 3:Header 版本(语义纯粹)
GET /api/users
Accept: application/vnd.company.v2+json
优点:URL 不包含版本,符合 REST 精神
缺点:浏览器无法直接测试,调试不方便
适用:追求 REST 纯粹性的场景(如公共 API)
策略 4:自动兼容(理想方案,实现难)
javascript
// 后端:在同一接口中同时支持旧字段和新字段
{
"id": "123",
"name": "Alice", // 新字段
"userName": "Alice" // 旧字段(废弃,但保留一段时间)
}
// 同时支持旧客户端(用 userName)和新客户端(用 name)
// 优点:无需维护多版本
// 缺点:API 会越来越"臃肿"
破坏性变更的判定
破坏性变更(需要版本升级):
❌ 删除字段
❌ 重命名字段
❌ 改变字段类型(string → number)
❌ 改变 URL 路径
❌ 改变 HTTP 方法(GET → POST)
❌ 改变状态码含义
非破坏性变更(可以不升版本):
✅ 新增字段(旧客户端忽略未知字段)
✅ 废弃字段(保留字段,只是文档中标注 deprecated)
✅ 新增可选参数
✅ 性能优化(响应更快,但格式相同)
8. 领域驱动设计(DDD)视角下的 API 组织
DDD 的核心思想与 API 的关系
领域驱动设计(Domain-Driven Design,DDD)由 Eric Evans 在 2003 年的同名书籍中提出。其核心思想是:软件的复杂度来自于领域的复杂度,代码应该映射领域中的概念。
有界上下文(Bounded Context):DDD 中把复杂业务域划分为若干独立的"上下文",每个上下文内部有统一的语言(Ubiquitous Language)和模型。
典型后台系统的有界上下文划分:
用户上下文(User Context):
- 用户管理(CRUD)
- 角色权限管理
- 部门组织结构
订单上下文(Order Context):
- 订单创建、支付、退款
- 订单状态流转
库存上下文(Inventory Context):
- 商品管理
- 库存变动
审批上下文(Approval Context):
- 请假申请、审批流
- 工作流引擎
有界上下文 → API 模块的映射:
src/api/
├── user/ # 用户上下文
│ ├── user.api.ts # 用户 CRUD
│ ├── role.api.ts # 角色管理
│ └── dept.api.ts # 部门管理
│
├── order/ # 订单上下文
│ ├── order.api.ts
│ └── payment.api.ts
│
└── approval/ # 审批上下文
├── application.api.ts
└── workflow.api.ts
为什么按有界上下文而不是按页面组织 API?
按页面组织(❌ 反模式):
api/userListPage.ts
api/userDetailPage.ts
api/orderListPage.ts
问题:页面变了,API 文件也要重命名;
多个页面共用同一接口时,放哪里?
按领域上下文组织(✅ 推荐):
api/user/user.api.ts (用户领域的所有用户相关接口)
api/order/order.api.ts
好处:接口聚合清晰,领域内聚,跨域依赖关系明显
9. 契约测试:前后端联调的工程保障
为什么需要契约测试
传统联调方式的问题:
1. 后端接口就绪前,前端无法测试
2. 前端靠 Mock 数据开发,联调时发现实际格式不一致
3. 后端修改了字段,没有通知前端,前端出 bug
4. "这个接口没问题,肯定是你前端的问题"的扯皮
契约测试(Contract Testing):
基于双方约定的契约(API 描述文档)自动验证实现
后端每次部署前自动验证:实现是否符合契约?
前端每次打包前自动验证:Mock 数据是否符合契约?
MSW(Mock Service Worker):现代前端 Mock 方案
typescript
// mocks/handlers.ts - 定义 Mock 规则
import { http, HttpResponse } from 'msw'
import type { User } from '@/types/user'
export const handlers = [
http.get('/api/users/list', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? 1)
return HttpResponse.json({
code: 200,
data: {
list: generateMockUsers(10),
total: 100,
page,
pageSize: 10
}
})
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json() as Partial<User>
// 验证必填字段(契约验证)
if (!body.userName || !body.email) {
return HttpResponse.json(
{ code: 400001, message: '用户名和邮箱不能为空' },
{ status: 400 }
)
}
return HttpResponse.json({
code: 200,
data: { ...body, userId: crypto.randomUUID() }
})
}),
]
// mocks/browser.ts - 浏览器环境启动
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
typescript
// main.ts
if (import.meta.env.VITE_USE_MOCK === 'true') {
const { worker } = await import('./mocks/browser')
await worker.start({ onUnhandledRequest: 'bypass' })
}
MSW 的优势:工作在 Service Worker 层,真实拦截网络请求,比 Axios Mock 更接近真实网络行为,切换真实 API 时不需要改代码。
10. 前端 API 层的完整工程实现
目录结构(按 DDD 有界上下文组织)
src/api/
├── types/ # 类型定义
│ ├── common.ts # 通用类型(分页、响应格式)
│ ├── user.ts # 用户领域类型
│ └── order.ts # 订单领域类型
│
├── user/
│ ├── user.api.ts # 用户 CRUD
│ └── dept.api.ts # 部门管理
│
├── order/
│ └── order.api.ts
│
└── index.ts # 统一导出
types/common.ts
typescript
// 统一响应类型
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
}
// 分页响应
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
// 分页查询参数基类
export interface PageQuery {
page: number
pageSize: number
}
types/user.ts
typescript
// 用户领域类型(严格对应后端响应)
export interface User {
userId: string
userName: string
email: string
phone?: string
avatar?: string
status: 'active' | 'inactive'
roleIds: string[]
deptId: string
createTime: string
updateTime: string
}
// 创建用户 DTO(Data Transfer Object)
export interface CreateUserDto {
userName: string
password: string
email: string
phone?: string
roleIds: string[]
deptId: string
}
// 更新用户 DTO(大部分字段可选)
export type UpdateUserDto = Partial<Omit<CreateUserDto, 'password'>>
// 用户列表查询参数
export interface UserListQuery extends PageQuery {
userName?: string
status?: User['status']
deptId?: string
roleId?: string
}
api/user/user.api.ts
typescript
import request from '@/utils/request'
import type { User, CreateUserDto, UpdateUserDto, UserListQuery } from '@/api/types/user'
import type { PageResult } from '@/api/types/common'
const BASE = '/users'
/**
* 用户管理 API
*
* 命名规范:动词 + 名词(Pascal Case)
* getUserList → 查询列表(总是 Query 而非 Fetch)
* getUserDetail → 查询详情
* createUser → 新增
* updateUser → 全量更新(对应 PUT)
* patchUser → 部分更新(对应 PATCH)
* deleteUser → 删除
*/
export function getUserList(query: UserListQuery): Promise<PageResult<User>> {
return request({ url: `${BASE}/list`, method: 'get', params: query })
}
export function getUserDetail(userId: string): Promise<User> {
return request({ url: `${BASE}/${userId}`, method: 'get' })
}
export function createUser(dto: CreateUserDto): Promise<User> {
return request({ url: BASE, method: 'post', data: dto })
}
export function updateUser(userId: string, dto: UpdateUserDto): Promise<User> {
return request({ url: `${BASE}/${userId}`, method: 'put', data: dto })
}
export function deleteUser(userId: string): Promise<void> {
return request({ url: `${BASE}/${userId}`, method: 'delete' })
}
export function batchDeleteUsers(userIds: string[]): Promise<void> {
return request({ url: `${BASE}/batch`, method: 'delete', data: { userIds } })
}
// 文件上传示例
export function importUsers(file: File): Promise<{ imported: number; failed: number }> {
const formData = new FormData()
formData.append('file', file)
return request({
url: `${BASE}/import`,
method: 'post',
data: formData
// 不设置 Content-Type,让 Axios 自动设置 multipart/form-data + boundary
})
}
// 文件下载示例
export async function exportUsers(query: Partial<UserListQuery>): Promise<void> {
const response = await request({
url: `${BASE}/export`,
method: 'get',
params: query,
responseType: 'blob'
})
// 触发浏览器下载
const filename = decodeURIComponent(
response.headers['content-disposition']?.split('filename=')[1] || '用户列表.xlsx'
)
const url = URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
11. 接口安全设计
常见的 API 安全威胁
SQL 注入(后端问题,但前端有义务了解):
javascript
// ❌ 用户输入直接拼接到 SQL
`SELECT * FROM users WHERE name = '${userInput}'`
// 如果 userInput = "' OR 1=1 --",SQL 会返回所有用户
// 前端防护:参数化传递,不在前端拼接 SQL
// 后端防护:使用 ORM、参数化查询
XSS(跨站脚本攻击):
javascript
// 危险:把用户输入直接渲染为 HTML
element.innerHTML = userInput // ❌
// 如果 userInput = "<script>steal(document.cookie)</script>"
// 就是 XSS 攻击
// 防护:使用 textContent 而非 innerHTML(或用 DOMPurify 清理)
element.textContent = userInput // ✅
// Vue 的 {{ }} 语法默认是安全的(不会执行 HTML),只有 v-html 需要小心
IDOR(不安全的直接对象引用):
javascript
// ❌ 前端直接用 URL 参数引用资源,后端不验证所有权
GET /api/users/123 // 如果用户 A 把 123 改成 456,能否看到用户 456 的数据?
// 后端防护:每个查询都验证"当前用户是否有权访问该资源"
// 不只是"是否存在",还要验证"是否属于你"
12. 常见坑深度解析
坑 1:接口地址硬编码散落在组件中
javascript
// ❌ URL 字符串直接写在组件
async fetchUsers() {
const res = await axios.get('/api/user/list') // 还是 /users/list?
}
// 后果:接口路径改了,需要全局搜索替换;拼写错误在运行时才发现
// ✅ 所有 URL 只出现在 API 模块中
// api/user/user.api.ts
export const getUserList = (query) => request({ url: '/users/list', params: query })
// 组件:只用函数名,不涉及 URL
import { getUserList } from '@/api/user/user.api'
const { list } = await getUserList({ page: 1 })
坑 2:大数字精度丢失
javascript
// MongoDB 的 ObjectId 是 24 位十六进制,转为十进制可能超过 JS 安全整数范围
// 示例:{ "orderId": 9007199254740993 }
// JavaScript 接收后:9007199254740992(最后一位丢失!)
// 根本原因:IEEE 754 双精度浮点数只有 53 位精度,
// 最大安全整数 Number.MAX_SAFE_INTEGER = 2^53 - 1 = 9007199254740991
// 解决方案 1(推荐):后端改为返回字符串 ID
{ "orderId": "9007199254740993" }
// 解决方案 2(前端):引入 json-bigint 处理大数
import JSONbig from 'json-bigint'
service.defaults.transformResponse = [
data => {
try { return JSONbig({ storeAsString: true }).parse(data) }
catch { return JSON.parse(data) }
}
]
坑 3:并发请求的错误处理
javascript
// ❌ 其中一个失败,其他请求的结果也丢失
try {
const [users, depts] = await Promise.all([getUserList(), getDeptList()])
} catch {
// users 和 depts 都拿不到了
}
// ✅ 用 Promise.allSettled,分别处理每个请求的成功/失败
const [usersResult, deptsResult] = await Promise.allSettled([
getUserList(),
getDeptList()
])
const users = usersResult.status === 'fulfilled'
? usersResult.value.list
: []
const depts = deptsResult.status === 'fulfilled'
? deptsResult.value.list
: []
小结
API 层是前后端之间的契约界面,好的 API 层设计包含三个维度:
设计维度(How to design):
RESTful 是主流但有边界,GraphQL 解决 over/under-fetching,tRPC 提供端到端类型安全
BFF 是微服务和多端场景的架构答案
API 版本策略保证向后兼容
DDD 有界上下文指导 API 的组织划分
工程维度(How to implement):
按领域(有界上下文)而非按页面组织 API 文件
TypeScript 类型约束请求参数和响应格式
契约测试(MSW Mock + OpenAPI 验证)保障联调质量
运维维度(How to maintain):
接口文档(Swagger/Apifox)是前后端协作的基础设施
版本化接口让迭代不破坏现有功能
接口安全(XSS、IDOR、大数精度)不能只靠前端
整个 HTTP 通信层的完整视图:
组件
→ API 函数(语义化的业务操作)
→ Axios 实例(拦截器:认证/错误处理)
→ HTTP 协议(GET/POST/PUT/DELETE)
→ 代理层(开发:Vite proxy,生产:Nginx)
→ 后端服务(认证 → 业务逻辑 → 数据库)
→ 响应返回
→ 响应拦截器(数据解包/错误处理)
→ API 函数(数据格式化)
→ 组件(纯净的业务数据)