本节摘要:Ajax 是一场双边协作,前端改了请求方式,服务端也必须跟着改:从渲染页面变成输出数据接口。本节站在前后端交界处,讲清接口设计的四块工程约定——统一响应结构与错误码、无状态鉴权、分页与过滤参数规范、以及服务端如何配合 CORS——并给出可直接落地的约定模板与常见反面案例。
一个 midway 项目的真实混乱。同一个产品里,用户模块、订单模块、消息模块分别由三个人开发,前端同学拿到三套风格迥异的接口:
{"code": 0, "data": {...}},失败返回 {"code": 401, "msg": "未登录"},HTTP 状态码永远是 200。{"success": true, "result": [...]},失败返回 {"success": false, "error": {...}}。前端的统一请求封装形同虚设:每接一个模块就要写一套判断逻辑;错误提示一会儿是弹窗、一会儿是控制台静默;一个"未登录"要跳登录页的通用逻辑,得写三遍。
这就是没有接口约定的代价。Ajax 模式下,接口是前后端之间唯一的对话渠道——渠道上没有共同语言,协作成本就会以 bug 和扯皮的形式逐月复利。本节讲的就是怎么把这门共同语言定下来。
传统服务端渲染架构里,后端的输出是整份 HTML——模板引擎把数据填进布局,浏览器拿到即可显示。这个模式下前后端代码物理上纠缠在模板文件里,"改个按钮颜色要动后端仓库"是日常。
Ajax 普及后,服务端的职责收窄为数据接口:接收请求、执行业务、返回结构化数据(JSON)。带来三个直接变化:
一是模板层消失或后移。接口不关心布局与样式,只关心业务数据的形状。渲染职责整体转移到浏览器,后端代码量与变更频率双降——改版式不再需要后端发版。
二是接口成为正式契约。既然是契约,就需要文档、版本与变更纪律。接口一变,前端就崩,"随手改个字段名"的事故代价被放大,于是接口版本化(URL 里带版本号)与变更评审成为标配。
三是多端复用成为可能。同一套接口可以同时喂网页、手机 App、小程序、第三方调用者。这正是接口设计值得投入的经济学理由:一次设计,多端受益。
一个数据接口的最小形态(以常见的服务端框架风格示意):
// 服务端:一个"查文章详情"的接口 app.get('/api/v1/articles/:id', async (req, res) => { const id = req.params.id; if (!isValidId(id)) { return res.status(400).json({ code: 'INVALID_PARAM', message: '文章编号格式错误' }); } const article = await db.findArticle(id); if (!article) { return res.status(404).json({ code: 'NOT_FOUND', message: '文章不存在' }); } res.json({ code: 0, data: article }); });
麻雀虽小,里面已经体现了几项关键约定:路径带版本、参数先校验、错误有统一结构、成功有统一包裹。下面逐块展开。
接口返回结构有两大流派,先摆出来再谈选择。
流派 A:HTTP 状态码即真相。业务成败完全用 HTTP 状态码表达:200 携带数据、401 未登录、403 无权限、404 不存在、422 参数错误、500 服务器错误。响应体只在成功时放数据、失败时放错误说明。
流派 B:HTTP 一律 200,业务码放体里。所有响应 HTTP 层都是 200,体里用 code 字段区分成败(0 或 20000 表成功),data 放数据、message 放错误描述。
两派都能工作,真正的工程灾难是混用——开篇的三套接口就是混用的受害者。两派的实质差异在于谁负责第一层分流:
| 维度 | 流派 A:HTTP 状态码 | 流派 B:业务码包裹 |
|---|---|---|
| 网关与监控 | 天然可按状态码告警统计 | 需要解析响应体才知道失败 |
| 缓存与重试语义 | 4xx/5xx 明确 | 全 200 缓存层与重试策略要额外约定 |
| 前端判断 | res.ok 一步分流 |
先判 code 再取 data 两步 |
| 与浏览器机制的一致性 | 401/403 语义被通用组件理解 | 需要自建映射 |
| 历史包袱 | 老网关可能对非 200 做拦截 | 部分代理会重写错误响应 |
我个人的倾向是接近 A 的混合方案:HTTP 状态码负责传输层与通用语义(401 未认证、403 无权限、404 不存在、5xx 服务器错误),业务细节错误(余额不足、库存不够)用 200 加业务码。理由是通用组件(网关、监控、浏览器)都按状态码工作,放弃这层信号太可惜;而"余额不足"这类业务语义,硬塞进 HTTP 数字反而别扭。无论选哪派,写进接口文档并用代码检查器强制,比选哪派重要十倍。
统一包裹结构的推荐模板:
// 成功 { "code": 0, "message": "ok", "data": { "id": 42, "title": "周报" } } // 失败 { "code": 40012, "message": "评论内容包含敏感词", "data": null }
要点:code 用数字且分段(1xxxx 用户类、2xxxx 订单类……),message 面向开发者、可含上下文;给用户的提示文案由前端决定,不要把 message 直接弹给用户——它是排障信息不是产品文案。
HTTP 本身是无状态协议——服务器默认不记得"上一个请求是谁发的"。传统页面靠 Cookie 维持会话:服务器 Set-Cookie,浏览器自动在后续请求带上。这套机制对 Ajax 同样有效,但有两个 Ajax 特有的注意点。
跨域下的 Cookie 需要双方声明。同源场景浏览器自动带 Cookie;跨域时默认不带,前端要显式声明 credentials: 'include'(Fetch)或 withCredentials = true(XHR),且服务端必须在 CORS 响应里指定具体源并允许凭证——通配符与凭证不兼容。漏配的表现很有迷惑性:请求 200,但服务器认为你没登录。
Token 方案在 Ajax 时代成为主流。登录后服务端签发访问令牌,前端存下,之后每个请求放在 Authorization 头里携带。与 Cookie 相比:不自动发送(跨站请求伪造的攻击面天然更小)、不绑定域名(App、小程序、第三方都能用同一套)、可以携带权限声明。代价是前端要自己管理令牌的存储、注入与刷新——这个"注入"动作正是 5.1 节拦截器的典型用途:
// 请求发出前统一注入令牌 演示拦截思路 const token = getStoredToken(); if (token) { xhr.setRequestHeader('Authorization', 'Bearer ' + token); }
令牌过期的处理约定也要提前定好:返回 401 加特定业务码时,前端静默用刷新令牌换新令牌并重放刚才失败的请求——用户完全无感。这套逻辑写在统一封装里(3.5 节的错误分流正好挂这一层)。
列表类接口是 Ajax 的高频消费者,参数规范不统一会持续制造小摩擦。约定模板:
GET /api/v1/orders?page=2&pageSize=20&status=paid&sort=createdAt:desc
响应配套返回总数或下一页游标。总数放包裹结构的 meta 里还是响应头里是个小争议:放头里(自定义头如 X-Total-Count)省体积,但跨域读取需要服务端暴露该头(2.2 节的坑);放体里最省心。没有强约束时,选体里。
一个容易被服务端忽视的点:空数组与 null 要分清。"没有订单"应返回空数组而不是 null——前端对空数组可以直接渲染空态,对 null 得先判一遍,漏判就是"页面白了"。这类细节写进约定,能省下无数空值判断代码。
跨域是前后端必须一起解决的问题(4.2 节讲完整机制,这里先列服务端义务清单):
Access-Control-Allow-Origin(具体源或按白名单回显 Origin),需要带 Cookie 时同时带 Access-Control-Allow-Credentials: true,且源不能是通配符。Access-Control-Expose-Headers,否则前端读不到分页总数这类自定义头。服务端框架一般都有 CORS 中间件,把允许的源配成白名单列表即可。生产环境禁止无脑通配符加凭证全开——那等于把接口向任意网站敞开。
约定不落成文档等于没有。最低限度的接口文档应包含:路径与方法、请求参数表(名、类型、必填、说明)、响应结构与各字段语义、错误码表、以及至少一对请求响应示例。现在更常见的做法是服务端代码里写注解、由工具自动生成在线文档,保持文档与代码同步演化。
联调阶段的两条纪律值得坚持:先定契约再动手——前端按文档用 Mock 数据并行开发,等服务就绪再切换;变更要打招呼——改字段、改结构必须提前通知并在文档留痕,"顺手优化"是联调期事故的第一来源。前端同学也可以在网络面板里保存请求响应样本,作为接口的"活文档"。
中小团队与常规业务,REST 风格(资源名加标准方法)够用且生态好;动作密集的业务(如"审批""转账")不必硬凑资源语义,动词路径也可接受。风格是工具不是信仰,团队内统一比选哪种重要。
后台管理类、数据相对静止的场景用页码(可跳页);feed 流、消息流这类持续追加的场景用游标(不重复不遗漏)。用页码做 feed 是"刷新后丢两条消息"的经典根因。
双精度浮点数装不下 64 位整数,超过安全范围后前端拿到的 ID 末位会静默变零,查库查不到、去重去不掉。2.3 节讲过精度细节,这是服务端侧的对应义务。
把本节内容压成一页可以贴进团队文档的约定模板,新项目拿去改名字就能用。其一,路径规范:全部接口以版本号开头,资源名用复数名词、小写连字符;参数用查询串传过滤与分页,路径段只放资源标识。其二,响应规范:统一包裹三字段(业务码、消息、数据);业务码分段管理,每段一个模块;消息面向开发者,用户文案由前端维护一份映射表。其三,错误规范:字段级错误返回字段到文案的映射,非字段错误给统一结构;HTTP 层至少正确使用 401、403、404、429、5xx,别全 200。其四,鉴权规范:访问令牌放 Authorization 头,有效期与刷新机制写明;过期返回 401 加特定业务码,前端据此静默刷新重放。其五,分页规范:管理列表用页码、流式内容用游标,二选一后全线统一;空结果返回空集合不返回 null。其六,跨域规范:允许源白名单制、凭证必须显式、预检缓存十分钟起步、自定义响应头逐个暴露。
模板的使用说明比模板本身更重要:约定一旦冻结,执行靠工具不靠自觉——代码评审清单里放对应检查项、接口测试里断言响应结构、持续集成里跑契约测试。见过太多团队的约定文档写得漂亮,三个月后没人记得,问题都出在"靠自觉"三个字上。工具化的约定才是活的约定。