跳至主要內容

前后端协作最佳实践:从接口设计到联调流程

郑天祺大约 14 分钟前端项目管理团队效率

前后端协作最佳实践:从接口设计到联调流程

前言

如果你经历过前后端联调时的"死亡问答"——"这个接口怎么还没好?""返回的字段和文档不一样啊""我这里明明是200,为什么算失败"——你就知道,一个好的协作流程有多重要。

前后端协作的本质是接口契约驱动的并行开发。本文系统梳理从接口设计、Mock、联调到上线的最佳实践,帮你告别反复扯皮的联调噩梦。


一、接口先行:API 文档驱动的协作模式

1.1 传统协作的痛点

传统流程(串行):
  后端开发接口 → 前端等接口 → 联调 → 改需求 → 重新联调
  时间:15 天(等待占了 7 天)

接口驱动流程(并行):
  定接口 → 后端开发(7天)+ 前端 Mock 开发(7天)→ 联调(1天)
  时间:8 天

1.2 接口设计阶段必须明确的事项

一份完整的接口文档应包含:

✅ 请求 URL:/api/v1/users/{id}
✅ 请求方法:GET
✅ 请求参数:Query / Path / RequestBody 所有字段及类型
✅ 请求头:Authorization、Content-Type 等
✅ 成功响应:状态码 + 返回体结构 + 字段说明
✅ 错误响应:各种错误码 + 错误信息格式
✅ 业务规则:校验规则、边界条件

✅ 示例:curl 命令 + 完整请求/响应示例

1.3 接口文档工具选择

工具类型优点缺点适合场景
Apifox设计+Mock+测试+文档一站式,中文友好团队付费中小团队
Swagger/OpenAPI代码生成文档Java 注解驱动,生态好UI 较丑Spring Boot 项目
YApi文档+Mock开源免费维护不活跃内部使用
Stoplight设计优先专业 API 设计价格较高企业级

1.4 推荐工作流:Apifox 为中心

1. 前后端 + 产品 开会讨论需求
2. 后端在 Apifox 中定义接口(或导入 Swagger 生成的 OpenAPI)
3. 前后端一起 Review 接口定义
4. 接口定稿后,前端启动 Mock,后端开始编码
5. 后端开发完成,更新 Apifox 中的"实现状态"
6. 前端切换到真实接口,联调
7. 联调通过,接口标记为"已完成"

二、Mock 方案

2.1 Apifox 智能 Mock

Apifox 可以根据接口定义自动生成符合规则的 Mock 数据:

// 接口定义:用户列表
{
  "code": 0,
  "message": "success",
  "data": {
    "records": [
      {
        "id": "@id",                    // 自动生成随机 ID
        "name": "@cname",               // 中文名
        "email": "@email",              // 邮箱
        "age": "@integer(18, 65)",      // 年龄 18-65
        "avatar": "@image('200x200')",  // 头像
        "createdAt": "@datetime"        // 日期
      }
    ],
    "total": "@integer(100, 9999)",   // 总条数
    "page": 1,
    "pageSize": 20
  }
}

// Apifox 的 Mock 规则支持:
// @name, @cname, @email, @url, @ip, @id
// @integer(min, max), @float(min, max, dmin, dmax)
// @string, @string('lower', 5), @string('number', 6)
// @datetime, @date, @time
// @boolean, @boolean(1, 9, true) // 1/9 概率为 true
// @image(size), @title, @sentence, @paragraph
// @pick(['a', 'b', 'c']) → 随机选一个

2.2 前端本地 Mock

// mock/handlers.js — 使用 MSW (Mock Service Worker)
import { http, HttpResponse } from 'msw';

export const handlers = [
  // 拦截 GET /api/users
  http.get('/api/users', ({ request }) => {
    const url = new URL(request.url);
    const page = url.searchParams.get('page') || 1;
    const pageSize = url.searchParams.get('pageSize') || 20;

    return HttpResponse.json({
      code: 0,
      message: 'success',
      data: {
        records: Array.from({ length: pageSize }, (_, i) => ({
          id: (page - 1) * pageSize + i + 1,
          name: `用户${(page - 1) * pageSize + i + 1}`,
          email: `user${(page - 1) * pageSize + i + 1}@example.com`,
          status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending',
          createdAt: new Date(Date.now() - i * 86400000).toISOString()
        })),
        total: 156,
        page: Number(page),
        pageSize: Number(pageSize)
      }
    });
  }),

  // 拦截 POST /api/users
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({
      code: 0,
      message: 'success',
      data: { id: Date.now(), ...body }
    }, { status: 201 });
  }),

  // 模拟延迟:部分接口故意慢
  http.get('/api/reports', async () => {
    await new Promise(resolve => setTimeout(resolve, 2000));
    return HttpResponse.json({
      code: 0,
      message: 'success',
      data: { /* ... */ }
    });
  }),

  // 模拟错误
  http.get('/api/users/999', () => {
    return HttpResponse.json({
      code: 10202,
      message: '用户不存在',
      data: null
    }, { status: 404 });
  })
];
// mock/browser.js
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);
// main.jsx — 仅在开发环境启用
import { worker } from './mock/browser';

async function startApp() {
  if (import.meta.env.DEV) {
    await worker.start({
      onUnhandledRequest: 'bypass', // 未拦截的请求正常发送
    });
  }
  // 启动 React 应用...
}

startApp();

2.3 Mock 策略选择

开发阶段:前端用 Apifox Mock 或 MSW 本地 Mock
  ↓
后端接口陆续完成:逐步切换到真实接口
  ↓
方案1:前端维护一个 "API Mode" 开关
  开发环境 → 可以按接口粒度切换 Mock/Real
  生产环境 → 始终走真实接口

方案2:API 网关层 Mock
  如果后端接口还没好,网关层直接返回 Mock 数据
  前端完全无感知,切换时只需改网关配置

三、登录态管理

3.1 Token 存储方案对比

方案安全性XSS 风险CSRF 风险适用场景
localStorage⚠️ 低❌ 易被 XSS 读取✅ 天然免疫快速开发、内部工具
sessionStorage⚠️ 低❌ 易被 XSS 读取✅ 天然免疫临时会话
Cookie (HttpOnly)✅ 高✅ JS 不可读⚠️ 需防护生产环境推荐
Cookie (HttpOnly + SameSite=Strict)✅ 最高✅ JS 不可读✅ 高版本浏览器生产环境最佳
内存变量✅ 最高✅ 不可访问✅ 天然免疫刷新即丢失
// 后端:登录成功,设置 HttpOnly Cookie
@PostMapping("/login")
public ResponseEntity<Result<LoginVO>> login(
        @RequestBody @Valid LoginRequest req, HttpServletResponse response) {

    LoginVO vo = authService.login(req);

    // JWT 存入 HttpOnly Cookie
    Cookie cookie = new Cookie("access_token", vo.getToken());
    cookie.setHttpOnly(true);      // JS 无法读取
    cookie.setSecure(true);         // 仅 HTTPS
    cookie.setSameSite("Strict");   // 防 CSRF
    cookie.setPath("/");
    cookie.setMaxAge(7200);         // 2小时
    response.addCookie(cookie);

    return ResponseEntity.ok(Result.success(vo.getUserInfo()));
}
// 后端:退出登录,清除 Cookie
@PostMapping("/logout")
public ResponseEntity<Result<Void>> logout(HttpServletResponse response) {
    Cookie cookie = new Cookie("access_token", "");
    cookie.setHttpOnly(true);
    cookie.setSecure(true);
    cookie.setSameSite("Strict");
    cookie.setPath("/");
    cookie.setMaxAge(0);
    response.addCookie(cookie);

    return ResponseEntity.ok(Result.success(null));
}
// 前端:不需要手动管理 Token,浏览器自动携带 Cookie
const response = await fetch('/api/users', {
  method: 'GET',
  credentials: 'include',  // 关键:携带 Cookie
  headers: { 'Content-Type': 'application/json' }
});

3.3 Token 刷新策略

方案1:Access Token(短)+ Refresh Token(长)
  Access Token: 15-30 分钟
  Refresh Token: 7-30 天

  流程:
    请求 → 401 → 用 Refresh Token 获取新 Access Token → 重试原请求

  实现关键:使用 axios 拦截器

方案2:滑动过期
  Token 过期时间固定为 2 小时
  每次请求时,如果剩余时间 < 30 分钟,自动续期
// axios 拦截器:自动刷新 Token
import axios from 'axios';

const api = axios.create({ baseURL: '/api' });

let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach(prom => {
    if (error) prom.reject(error);
    else prom.resolve(token);
  });
  failedQueue = [];
};

// 响应拦截器
api.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;

    // 401 且不是重试中的请求
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 已有刷新请求在进行中,排队等待
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(token => {
          originalRequest.headers['Authorization'] = `Bearer ${token}`;
          return api(originalRequest);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const { data } = await axios.post('/api/auth/refresh');
        const newToken = data.data.token;

        // 更新 axios 默认头
        api.defaults.headers['Authorization'] = `Bearer ${newToken}`;

        // 处理队列中的请求
        processQueue(null, newToken);

        // 重试原请求
        originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        // 刷新失败,跳转登录
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

四、BFF (Backend for Frontend) 模式

4.1 什么是 BFF

BFF 是一种架构模式,为每种前端(Web/H5/iOS/Android)提供专属的后端服务层

传统模式:                                BFF 模式:
  Web ─┐                                   Web ──→ BFF-Web ──┐
  H5  ─┤                                    H5  ──→ BFF-H5  ──┤
  iOS ─┼──→ 通用 API Gateway ──→ 微服务       iOS ──→ BFF-iOS ──┼──→ 微服务
  App ─┘                                    App ──→ BFF-App ──┘

4.2 为什么需要 BFF

问题:
  1. 不同端需要的字段不同:
     Web 需要完整用户信息
     H5 只需要昵称 + 头像(省流量)
     iOS 需要扁平结构(减少解析层数)

  2. 不同端的交互模式不同:
     Web:一次拿一整页数据
     Mobile:分步加载、省流量模式

  3. 后端通用接口设计往往偏重
     → 返回过多不需要的字段(浪费流量)
     → 或返回过少(N+1 查询)

4.3 简单 BFF 实现(Spring Boot)

// BFF 层控制器 - 为 Web 前端专用
@RestController
@RequestMapping("/bff/web")
public class WebBffController {

    @Autowired
    private UserServiceClient userService;
    @Autowired
    private OrderServiceClient orderService;
    @Autowired
    private ProductServiceClient productService;

    // 聚合多个微服务的数据 → 一个接口搞定
    @GetMapping("/dashboard")
    public Result<DashboardVO> dashboard(@AuthenticationPrincipal UserPrincipal user) {
        // 并行调用多个微服务
        CompletableFuture<UserVO> userFuture =
            CompletableFuture.supplyAsync(() -> userService.getUser(user.getId()));

        CompletableFuture<List<OrderVO>> ordersFuture =
            CompletableFuture.supplyAsync(() -> orderService.getRecentOrders(user.getId(), 10));

        CompletableFuture<List<ProductVO>> recommendFuture =
            CompletableFuture.supplyAsync(() -> productService.getRecommendations(user.getId()));

        // 等待所有结果
        CompletableFuture.allOf(userFuture, ordersFuture, recommendFuture).join();

        // 组装为前端需要的数据结构
        DashboardVO vo = new DashboardVO();
        vo.setUserInfo(userFuture.join());
        vo.setRecentOrders(ordersFuture.join());
        vo.setRecommendations(recommendFuture.join());

        return Result.success(vo);
    }
}

4.4 BFF 的适用条件

✅ 需要 BFF 的场景:
  - 多个前端平台(Web/iOS/Android/小程序)
  - 前端需要的数据来自多个微服务
  - 前端有特殊的性能/数据聚合需求

❌ 不需要 BFF 的场景:
  - 只有一个 Web 前端
  - 简单的 CRUD 应用
  - 后端已经是单体服务 → 直接加个接口就行

五、文件上传/下载协作要点

5.1 文件上传规范

// 后端:统一文件上传接口
@PostMapping("/files/upload")
public Result<FileUploadVO> upload(
        @RequestParam("file") MultipartFile file,
        @RequestParam(value = "type", defaultValue = "general") String type) {

    // 校验
    if (file.isEmpty()) {
        throw new BusinessException(ErrorCode.FILE_EMPTY, HttpStatus.BAD_REQUEST);
    }
    if (file.getSize() > 10 * 1024 * 1024) {  // 10MB
        throw new BusinessException(ErrorCode.FILE_TOO_LARGE, HttpStatus.BAD_REQUEST);
    }

    FileUploadVO result = fileService.upload(file, type);
    return Result.success(result);
}

// 返回体
@Data
public class FileUploadVO {
    private String fileId;       // 文件唯一 ID
    private String fileName;     // 原始文件名
    private String fileUrl;      // 访问 URL
    private Long fileSize;       // 文件大小(字节)
    private String mimeType;     // 文件类型
}
// 前端:文件上传组件
function FileUploader({ onSuccess }) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);

  const handleUpload = async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    // 前端校验
    if (file.size > 10 * 1024 * 1024) {
      alert('文件不能超过 10MB');
      return;
    }

    const formData = new FormData();
    formData.append('file', file);
    formData.append('type', 'general');

    setUploading(true);

    try {
      const xhr = new XMLHttpRequest();

      // 上传进度
      xhr.upload.addEventListener('progress', (e) => {
        if (e.lengthComputable) {
          setProgress(Math.round((e.loaded / e.total) * 100));
        }
      });

      xhr.addEventListener('load', () => {
        const result = JSON.parse(xhr.responseText);
        if (result.code === 0) {
          onSuccess(result.data);
        } else {
          alert('上传失败: ' + result.message);
        }
        setUploading(false);
      });

      xhr.open('POST', '/api/files/upload');
      xhr.send(formData);
    } catch (err) {
      alert('上传失败: ' + err.message);
      setUploading(false);
    }
  };

  return (
    <div>
      <input type="file" onChange={handleUpload} disabled={uploading} />
      {uploading && (
        <div>
          <progress value={progress} max="100" />
          <span>{progress}%</span>
        </div>
      )}
    </div>
  );
}

5.2 文件下载规范

// 后端:文件下载
@GetMapping("/files/{fileId}/download")
public ResponseEntity<Resource> download(@PathVariable String fileId) {
    FileInfo fileInfo = fileService.getFileInfo(fileId);
    Resource resource = fileService.loadAsResource(fileId);

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(fileInfo.getMimeType()))
        .header(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=\"" +
            URLEncoder.encode(fileInfo.getFileName(), StandardCharsets.UTF_8) + "\"")
        .header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000")
        .body(resource);
}
// 前端:文件下载
async function downloadFile(fileId, fileName) {
  try {
    const response = await fetch(`/api/files/${fileId}/download`);

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || '下载失败');
    }

    // 从 Response 创建 Blob 并触发下载
    const blob = await response.blob();
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = fileName;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    window.URL.revokeObjectURL(url);
  } catch (err) {
    alert('下载失败: ' + err.message);
  }
}

5.3 大文件分片上传

// 大文件分片上传
async function uploadLargeFile(file, chunkSize = 5 * 1024 * 1024) {
  const totalChunks = Math.ceil(file.size / chunkSize);
  const uploadId = await initUpload(file.name, totalChunks);

  // 并行上传分片
  const uploadPromises = [];
  for (let i = 0; i < totalChunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('uploadId', uploadId);
    formData.append('chunkIndex', i);

    uploadPromises.push(
      fetch('/api/files/chunk-upload', { method: 'POST', body: formData })
    );
  }

  await Promise.all(uploadPromises);

  // 合并分片
  const result = await fetch(`/api/files/merge?uploadId=${uploadId}`, {
    method: 'POST'
  });
  return result.json();
}

六、错误处理:前后端统一错误码

6.1 后端错误处理策略

// 业务异常
public class BusinessException extends RuntimeException {
    private final int code;
    private final int httpStatus;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.code = errorCode.getCode();
        this.httpStatus = mapToHttpStatus(errorCode);
    }

    // ...
}

// 全局异常处理器
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Result<Void>> handleBusiness(BusinessException e) {
        return ResponseEntity
            .status(e.getHttpStatus())
            .body(Result.error(e.getCode(), e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Result<List<ValidationError>>> handleValidation(
            MethodArgumentNotValidException e) {
        List<ValidationError> errors = e.getBindingResult()
            .getFieldErrors().stream()
            .map(fe -> new ValidationError(fe.getField(), fe.getDefaultMessage()))
            .toList();

        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(Result.error(ErrorCode.PARAM_ERROR.getCode(),
                               ErrorCode.PARAM_ERROR.getMessage(), errors));
    }

    // 兜底:未知错误
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Result<Void>> handleUnknown(Exception e) {
        log.error("Unexpected error", e);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(Result.error(ErrorCode.INTERNAL_ERROR));
    }
}

6.2 前端统一错误处理

// 统一错误码映射
const ERROR_MESSAGES = {
  401: '登录已过期,请重新登录',
  403: '您没有权限执行此操作',
  404: '请求的资源不存在',
  422: '输入数据有误,请检查',
  429: '操作太频繁,请稍后再试',
  500: '服务器繁忙,请稍后再试',
  502: '服务暂不可用',
};

// API 响应拦截器
api.interceptors.response.use(
  response => {
    const { code, message, data } = response.data;

    if (code !== 0) {
      // 业务错误码处理
      handleBusinessError(code, message);
      return Promise.reject(new ApiError(code, message));
    }

    return data; // 直接返回 data,组件中不用每次都 .data
  },
  error => {
    // HTTP 错误处理
    if (error.response) {
      const { status, data } = error.response;
      const message = data?.message || ERROR_MESSAGES[status] || '未知错误';

      handleHttpError(status, message);
      return Promise.reject(new ApiError(status, message));
    }

    // 网络错误
    handleNetworkError(error);
    return Promise.reject(error);
  }
);

function handleHttpError(status, message) {
  switch (status) {
    case 401:
      // 清除登录态,跳转登录页
      authStore.logout();
      router.push('/login');
      break;
    case 403:
      message.warning(message);
      break;
    case 500:
    case 502:
    case 503:
      message.error(message);
      break;
    default:
      message.warning(message);
  }
}

七、联调流程与效率工具

7.1 标准联调流程

Step 1:环境确认
  - 后端确认接口已自测(Postman/curl 验证过)
  - 前端确认 Mock 数据已跑通页面

Step 2:接口逐个打通
  - 按模块依次切换 Mock → 真实接口
  - 每切换一个,验证基本流程(增删改查)

Step 3:异常场景测试
  - 网络超时 → 前端是否有 loading + 重试
  - 401 → 是否正确跳转登录
  - 数据为空 → 空状态展示是否合理
  - 字段缺失 → 前端是否安全降级

Step 4:边界测试
  - 超长文本 → 前端是否截断或溢出
  - 特殊字符 → XSS 防护是否生效
  - 并发请求 → 是否有竞态问题

7.2 提效工具

工具用途说明
Apifox接口文档 + Mock + 自动化测试一站式,前后端必须对齐
Whistle代理抓包修改请求/响应,调试利器
Charles/FiddlerHTTP 抓包排查请求细节
Chrome DevTools前端调试Network 面板定位问题
Postman/curl后端自测确保接口在联调前是可用的
Swagger UI在线接口测试可以快速手动验证
React DevToolsReact 组件调试查看 State/Props/Context

7.3 Whistle 使用技巧

# 安装 Whistle
npm install -g whistle
w2 start

# Whistle Rules(在 UI 中配置)
# 将远程接口代理到本地
api.example.com/api 127.0.0.1:8080/api

# 修改响应(快速测试不同数据场景)
api.example.com/api/users resBody://{users-empty.json}
api.example.com/api/users resBody://{users-error.json}

# 模拟慢网络(测试 loading 效果)
api.example.com/api resDelay://3000

# 模拟接口挂掉
api.example.com/api statusCode://500

7.4 联调检查清单

## 联调检查清单

### 基础功能
- [ ] GET 列表:分页参数正常工作
- [ ] GET 详情:不存在的 ID 返回 404
- [ ] POST 创建:返回 201 + 创建后的完整数据
- [ ] PUT 更新:返回更新后的数据
- [ ] PATCH 部分更新:只更新传了的字段
- [ ] DELETE 删除:返回 204 或 200

### 数据一致性
- [ ] 日期格式统一(ISO 8601: 2024-06-01T10:00:00Z)
- [ ] 金额单位统一(分?元?)
- [ ] 布尔值统一(true/false,不是 0/1)
- [ ] 空值统一(null,不是 "" 或 undefined)

### 错误处理
- [ ] 参数校验失败的返回格式
- [ ] 401 时前端正确跳转
- [ ] 403 时给出明确提示
- [ ] 500 时有友好降级
- [ ] 网络错误时提示 + 重试按钮

### 性能
- [ ] 列表接口是否做了分页
- [ ] 图片等静态资源是否压缩
- [ ] 不必要的字段是否去掉了

八、前端请求后端时常见的性能坑

坑1:N+1 查询(前端版)

// ❌ 循环中发请求 → N+1 问题
function OrderList() {
  const [orders, setOrders] = useState([]);
  const [orderDetails, setOrderDetails] = useState({});

  useEffect(() => {
    fetch('/api/orders').then(res => res.json()).then(data => {
      setOrders(data);

      // 为每个订单单独请求详情!
      data.forEach(order => {
        fetch(`/api/orders/${order.id}/detail`).then(res => res.json()).then(detail => {
          setOrderDetails(prev => ({ ...prev, [order.id]: detail }));
        });
      });
    });
  }, []);

  // 20 个订单 = 21 个请求
}

// ✅ 解决方案:后端提供一个批量接口
// GET /api/orders/with-details?ids=1,2,3,4,5...

坑2:未防抖的搜索

// ❌ 每次键盘输入都发请求
function SearchBox() {
  const [query, setQuery] = useState('');

  useEffect(() => {
    fetch(`/api/search?q=${query}`); // 每次 query 变化都发
  }, [query]);

  return <input onChange={e => setQuery(e.target.value)} />;
  // 输入 "hello" → 5 个请求!且顺序无法保证
}

// ✅ 使用防抖
import { useDebounce } from 'use-debounce';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [debouncedQuery] = useDebounce(query, 300); // 300ms 防抖

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input onChange={e => setQuery(e.target.value)} />;
}

坑3:竞态条件(Race Condition)

// ❌ 快速切换 Tab 时,旧请求的响应可能覆盖新请求
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser); // 如果 userId 变了,这可能是旧数据!
  }, [userId]);
}

// ✅ 使用 AbortController 或 ignore flag
useEffect(() => {
  let ignore = false;

  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!ignore) setUser(data); // 只处理最新的
    });

  return () => { ignore = true; }; // 组件更新时抛弃旧请求
}, [userId]);

// ✅ 更好:用 useSWR 或 TanStack Query,自动处理竞态

坑4:不做请求缓存

// ❌ 每次导航到用户列表都重新请求
// /users → /users/123 → 返回 /users,又请求一次列表

// ✅ 使用 TanStack Query / SWR 自动缓存
import { useQuery } from '@tanstack/react-query';

function UserList() {
  const { data, isLoading } = useQuery({
    queryKey: ['users'],
    queryFn: () => api.get('/users'),
    staleTime: 5 * 60 * 1000, // 5 分钟内不重新请求
  });
  // ...
}

坑5:过度请求(轮询 vs WebSocket)

// ❌ 高频轮询
setInterval(() => {
  fetch('/api/notifications/unread-count');
}, 3000); // 每 3 秒一次,99% 是浪费

// ✅ WebSocket / SSE(Server-Sent Events)
const eventSource = new EventSource('/api/notifications/stream');
eventSource.onmessage = (event) => {
  const count = JSON.parse(event.data);
  setUnreadCount(count);
};

// Spring Boot SSE 实现
@GetMapping("/notifications/stream")
public Flux<SseEmitter> stream(HttpServletResponse response) {
    response.setHeader("Cache-Control", "no-store");
    return Flux.interval(Duration.ofSeconds(5))
        .map(seq -> {
            // 只有当有新通知时才推送
            Long count = notificationService.getUnreadCount();
            return SseEmitter.event().data(count).name("unread");
        });
}

九、总结

协作黄金法则

  1. 接口先行,文档先行:没有文档的联调就是灾难
  2. 后端自测再联调:curl 能跑通才交给前端
  3. 前端 Mock 并行开发:不等后端,基于文档 Mock
  4. 统一数据格式:日期、金额、布尔值、空值
  5. 统一错误码:前后端共用错误码枚举
  6. 保持沟通:接口变更要通知,不要"悄悄改"

高效团队的分工

阶段后端前端共同
设计数据结构设计UI 组件拆解接口契约
开发实现接口 + 自测Mock + 页面开发Review 接口变更
联调解决后端 Bug切换到真实接口问题排查
上线部署 + 监控构建 + 发布线上验收

前后端协作的本质不是"谁先谁后",而是建立在明确接口契约上的并行开发。好的协作流程可以节省 50% 以上的联调时间。

最后送上一句话:写好接口文档的后端,是前端最好的朋友。


本文体系适用于中大型前后端分离项目。

上次编辑于:
贡献者: zhengtianqi