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 教科书要求:

但实际项目里,按应剑锋自定义要求 + 大多数国内后端习惯:动词路径/user/create / /user/delete/1)也常见。好处:nginx access.log 里一眼看出是什么操作。

原则接口命名风格统一就行,前后端对齐一套规则。in4vue 的规则(见 CLAUDE.md):


2. 接口契约:谁来定?

新项目起步时最容易吵:前端按 Swagger 写,后端按自己理解写,对不上

推荐流程

  1. 后端(熟悉数据结构的人)主导写 OpenAPI / Swagger 文档
  2. 前后端一起 review 一次(重点:字段命名、枚举值、错误码)
  3. 后端按契约实现 → 前端按契约调用
  4. 改契约要两边同步改,别偷偷动

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" 版本,因为:

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 }

选择建议


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 })

设计时同时提供单个和批量

前端根据场景选——单删操作日志更清晰,批量性能更好。


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...)

约定:列表接口不返大字段contentrawData 等可能几十 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 }),
}

好处

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 就是双方的共同语言——约定得好,联调无痛。


小练习

  1. 给 in4vue 设计一个 noteApi 完整实现(TypeScript + 分页 + CRUD)
  2. openapi-typescript 从 Swagger JSON 生成类型(如果后端有的话)
  3. 实现一个带幂等键的"创建订单"前端逻辑
  4. 处理一个返回 snake_case 的接口,自动转驼峰
  5. 写一个 AbortController 版本的搜索(防竞态)

延伸阅读