RESTful API 对接最佳实践
1. REST 的一次快速复习
写 Java 你熟悉的概念,前端同样适用:
| HTTP 方法 | 语义 | 幂等 | 典型路径 |
|---|---|---|---|
| GET | 查询 | ✅ | /user/1 |
| POST | 创建 | ❌ | /user/create |
| PUT | 整体替换 | ✅ | /user/update/1 |
| PATCH | 部分更新 | ❌ | /user/patch/1 |
| DELETE | 删除 | ✅ | /user/delete/1 |
幂等:同一个请求发 N 次结果一样。重要意义:网络不稳时可以安全重试。
1.1 "严格 REST" vs 实用主义
RESTful 教科书要求:
POST /user创建PUT /user/1更新DELETE /user/1删除
但实际项目里,按应剑锋自定义要求 + 大多数国内后端习惯:动词路径(/user/create / /user/delete/1)也常见。好处:nginx access.log 里一眼看出是什么操作。
原则:接口命名风格统一就行,前后端对齐一套规则。in4vue 的规则(见 CLAUDE.md):
- 驼峰命名:
/ledgerAdjustment/page,不用短横线 - 功能前缀:
/note/create//note/page//note/delete/1
2. 接口契约:谁来定?
新项目起步时最容易吵:前端按 Swagger 写,后端按自己理解写,对不上。
推荐流程:
- 后端(熟悉数据结构的人)主导写 OpenAPI / Swagger 文档
- 前后端一起 review 一次(重点:字段命名、枚举值、错误码)
- 后端按契约实现 → 前端按契约调用
- 改契约要两边同步改,别偷偷动
Java 对照:Spring Boot 装 springdoc-openapi-starter-webmvc-ui,自动生成 /swagger-ui.html。前端工具(openapi-typescript)能从 OpenAPI 生成 TS 类型:
pnpm dlx openapi-typescript http://localhost:8080/v3/api-docs -o src/types/api.ts
生成类似:
export interface Note {
id: number
title: string
status: 'draft' | 'published'
createdAt: string
}
export type NotePageResponse = {
list: Note[]
total: number
}
效果:改接口时 TS 编译报错提示,不用靠运行时崩来发现。
3. 统一响应结构
国内很多后端用这个:
{
"code": 0,
"message": "success",
"data": { ... }
}
code 自定义业务码(0 = 成功),data 放实际数据。
另一种派:HTTP 状态码直接表达 + 裸对象:
// HTTP 200
{ "id": 1, "title": "..." }
// HTTP 400
{ "error": "用户名已存在" }
两派各有拥趸,团队一致即可。in4vue 用"code + data" 版本,因为:
- 后端习惯(大厂多这么玩)
- 业务错误("余额不足")用
code表达比"200 里塞错误"清晰 - 前端拦截器统一处理
code !== 0
3.1 前端如何让 data 解包
axios 响应拦截器里(见 Axios 笔记):
service.interceptors.response.use((response) => {
const res = response.data
if (res.code !== 0) {
throw new BizError(res.code, res.message)
}
return res.data // 业务层直接拿到解包后的数据
})
配合 TypeScript:
declare module 'axios' {
interface AxiosInstance {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
}
}
业务代码:
const note = await request.get<Note>('/note/1') // note 已经是解包后的 Note
4. 字段命名:驼峰 vs 下划线
Java 后端默认驼峰(Lombok + Jackson):
public class Note {
private Long id;
private String createdAt; // Jackson 会序列化成 createdAt
}
Python / 有些老后端默认下划线:created_at。
in4vue 约定:接口字段统一驼峰(createdAt),路径也驼峰(/ledgerAdjustment/page)。和前端 TS 命名保持一致,省得前端到处转换。
4.1 后端下划线怎么办?
如果对接的后端返的是 snake_case,在 axios 响应拦截器里一次性转换:
import { camelCase } from 'lodash-es'
function camelizeKeys(obj: any): any {
if (Array.isArray(obj)) return obj.map(camelizeKeys)
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [camelCase(k), camelizeKeys(v)])
)
}
return obj
}
service.interceptors.response.use((response) => {
return camelizeKeys(response.data)
})
发请求时反向操作:
import { snakeCase } from 'lodash-es'
function snakeizeKeys(obj: any): any { /* 对称实现 */ }
service.interceptors.request.use((config) => {
if (config.data) config.data = snakeizeKeys(config.data)
return config
})
只在必要时用——如果能影响后端改成驼峰,不要自己绕。
5. 分页约定
响应结构(in4vue 约定):
interface PageResult<T> {
list: T[]
total: number
page: number // 当前页(从 1 开始)
pageSize: number
}
请求参数:
GET /note/page?page=1&pageSize=20&keyword=vue
其他见过的风格:
| 风格 | 请求 | 响应 |
|---|---|---|
| PageHelper(本项目) | page=1&pageSize=20 |
{ list, total, page, pageSize } |
| offset/limit | offset=0&limit=20 |
{ data, total } |
| cursor-based | cursor=abc&limit=20 |
{ data, nextCursor } |
| JSON:API | page[number]=1&page[size]=20 |
{ data, links, meta } |
选择建议:
- 常规后台列表 → PageHelper 风格(前端直接
:total绑 el-pagination) - 大数据 / 实时更新(如消息流) → cursor-based(
loadMore要上一个游标,避免分页时数据错位)
6. 时间字段:前后端最容易错位的地方
后端返什么:
| 格式 | 例子 | 评价 |
|---|---|---|
| ISO 8601 字符串 | "2026-05-12T14:30:00Z" |
✅ 强烈推荐 |
| Unix 毫秒戳 | 1747060200000 |
🟡 JS 易用但可读性差 |
2026-05-12 14:30:00 |
形式友好 | ❌ 无时区,易错 |
java.util.Date 序列化 |
{"date":[2026,5,12]} |
❌ 见到就让后端改 |
6.1 ISO 8601 为什么最好
const iso = "2026-05-12T14:30:00Z"
// JS 直接识别
const d = new Date(iso)
// Intl 格式化(自动用用户时区)
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(d)
// "2026/5/12 22:30" (如果用户在东 8 区,自动转)
带 Z 后缀 = UTC。浏览器根据用户本地时区显示。跨时区应用必备。
6.2 Day.js 处理复杂需求
pnpm add dayjs
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
dayjs('2026-05-12T14:30:00Z').format('YYYY-MM-DD HH:mm') // "2026-05-12 22:30"
dayjs('2026-05-12T14:30:00Z').fromNow() // "6 小时前"
dayjs().add(1, 'week').format('YYYY-MM-DD') // 一周后
为什么不用 moment:moment 300KB 太胖。dayjs 2KB,API 几乎一致。
6.3 时区的坑
// 用户在北京时间 23:30 点提交
const localTime = new Date() // 本地时间
// ❌ 直接 toISOString 会转成 UTC,日期可能跨天
localTime.toISOString() // "2026-05-12T15:30:00.000Z" ← 后端看到的是 5/12
// ✅ 约定好传哪种时区
// 方案 A: 前端传 ISO,后端用 UTC 存储,显示时各自转本地
// 方案 B: 前端传带时区信息的 ISO: "2026-05-12T23:30:00+08:00"
强烈推荐:后端全程存 UTC。前端显示时 new Date(iso).toLocaleString() 自动转用户时区。
7. 错误码约定
推荐分段:
0 成功
1xx 业务错误(比如库存不足、优惠券过期)
2xx 认证/鉴权错误(未登录、无权限)
3xx 参数/校验错误
9xx 系统错误
实际例子:
{ "code": 4001, "message": "笔记已被删除" }
{ "code": 4002, "message": "标题重复" }
{ "code": 2001, "message": "Token 已过期" }
7.1 前端文案 vs 后端文案
推荐:后端返错误码和可直接显示的 message,前端拦截器默认显示 message。特殊码需要跳转时前端根据 code 特殊处理。
if (res.code !== 0) {
if (res.code === 4001) {
router.push('/notes') // 笔记没了,跳列表
return
}
ElMessage.error(res.message)
throw new BizError(res.code, res.message)
}
7.2 字段级错误(表单校验)
后端返回字段级错误:
{
"code": 3001,
"message": "参数错误",
"errors": {
"username": "用户名已被占用",
"email": "邮箱格式不正确"
}
}
前端把 errors 映射到表单字段(见表单校验笔记 §12)。
8. 幂等性:网络不稳的保险
网络抖一下,用户点了两次"提交订单"——重复下单吗?
幂等的两层保护:
8.1 前端防重
按钮 loading 锁定(简单粗暴):
<el-button :loading="submitting" @click="onSubmit">提交</el-button>
<script setup lang="ts">
const submitting = ref(false)
async function onSubmit() {
if (submitting.value) return
submitting.value = true
try {
await createOrder(...)
} finally {
submitting.value = false
}
}
</script>
8.2 后端幂等键
创建订单等敏感操作,前端传幂等键(idempotency-key):
import { v4 as uuidv4 } from 'uuid'
const idempotencyKey = uuidv4()
await request.post('/order/create', form, {
headers: { 'Idempotency-Key': idempotencyKey },
})
后端:拿到相同 key 的请求,直接返回上次的结果,不重复创建。Stripe / 银行类接口都这么干。
9. 请求取消:搜索竞态问题
用户快速敲字搜索:"v" → "vu" → "vue" 三个请求同时发。先返回的是"v"的结果——覆盖了最新的"vue"结果。竞态发生。
解法:发新请求时取消上一个:
let abortController: AbortController | null = null
async function search(keyword: string) {
abortController?.abort() // 取消上次
abortController = new AbortController()
try {
const result = await request.get('/search', {
params: { keyword },
signal: abortController.signal,
})
return result
} catch (err: any) {
if (err.name === 'CanceledError') {
return // 被取消,不报错
}
throw err
}
}
VueUse 的 useFetch 自带这个能力,手写才需要操心。
10. 批量接口的设计
差的方式:for 循环调 N 次单个接口。
// ❌ 100 个请求,N+1 灾难
for (const id of ids) {
await deleteNote(id)
}
好的方式:一个批量接口。
// ✅ 一次请求
await request.post('/note/batchDelete', { ids })
设计时同时提供单个和批量:
DELETE /note/:id—— 单删POST /note/batchDelete—— 批量
前端根据场景选——单删操作日志更清晰,批量性能更好。
11. 大响应体:按需字段
某个列表接口返了 50 个字段,前端只用 5 个?可以:
11.1 fields 参数(后端支持时)
GET /note/page?fields=id,title,status
11.2 区分 list 和 detail 接口
GET /note/page → 精简字段(id, title, summary, status)
GET /note/:id → 完整字段(含 content, tags, relatedIds...)
约定:列表接口不返大字段(content、rawData 等可能几十 KB)。
12. API 层的封装
in4vue 的 src/api/ 组织:
src/api/
├── request.ts ← axios 实例 + 拦截器
├── auth.ts ← 鉴权相关
├── note.ts ← 笔记 CRUD
├── category.ts
└── upload.ts
每个模块的模式:
// src/api/note.ts
import { request } from './request'
// 类型定义就近放
export interface Note {
id: number
title: string
content: string
category: string
status: 'draft' | 'published'
createdAt: string
updatedAt: string
}
export interface NotePageParams {
page: number
pageSize: number
keyword?: string
}
// 导出 API 对象
export const noteApi = {
page: (params: NotePageParams) => request.get<PageResult<Note>>('/note/page', { params }),
get: (id: number) => request.get<Note>(`/note/${id}`),
create: (data: Partial<Note>) => request.post<Note>('/note/create', data),
update: (id: number, data: Partial<Note>) => request.put<Note>(`/note/update/${id}`, data),
remove: (id: number) => request.delete(`/note/delete/${id}`),
batchRemove: (ids: number[]) => request.post('/note/batchDelete', { ids }),
}
好处:
- 类型、路径、方法集中管理
- 改后端路径 → 只改这一个文件
- 搜索"noteApi"就能找到所有笔记相关请求
Java 对照:类似 FeignClient 按服务拆分。
13. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
接口返 null 前端崩 |
访问 null.xxx |
用可选链 data?.user?.name |
列表返空是 null 不是 [] |
后端序列化坑 | 前端兜底 list ?? [] 或让后端改 |
| 时间显示错乱 | 时区混用 | 统一用 UTC ISO 字符串 |
| 分页到最后一页还能翻 | 前端没校验 total | disabled="page * pageSize >= total" |
| 重复提交重复下单 | 没防重 + 没幂等键 | 按钮 loading + Idempotency-Key |
| 搜索结果闪烁(竞态) | 前一个请求后返回 | AbortController 取消 |
| 金额计算不对 | 浮点数精度 | 后端用分/小数最低位存,前端展示时除 |
14. 心智模型
后端视角: 接口是"服务"
前端视角: 接口是"数据源"
前端的核心工作:
请求参数准备 → 发请求 → 处理响应 → 映射到视图
前后端协作的黄金三条:
1. 接口契约先定,别各写各的
2. 字段命名、时间格式、错误码 — 全局约定
3. 改接口一定通知,或 CI 里自动生成类型防漏
Java 对照:后端写 @RestController 关心的是"逻辑正确",前端调用关心的是"把数据渲染好"。中间的 DTO/VO 就是双方的共同语言——约定得好,联调无痛。
小练习
- 给 in4vue 设计一个
noteApi完整实现(TypeScript + 分页 + CRUD) - 用
openapi-typescript从 Swagger JSON 生成类型(如果后端有的话) - 实现一个带幂等键的"创建订单"前端逻辑
- 处理一个返回
snake_case的接口,自动转驼峰 - 写一个
AbortController版本的搜索(防竞态)