Skip to content

6.1 动态响应、数据持久化与鉴权流程 ​

基础 handler 返回的是静态常量,本章让 mock 变成一个"行为完整的假后端":动态生成数据、跨刷新持久化、完整登录授权,以及请求间的依赖关系。

动态响应:请求决定一切 ​

ts
http.get("*/api/users", ({ request }) => {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get("page") ?? 1);
  const pageSize = Number(url.searchParams.get("pageSize") ?? 20);
  const q = (url.searchParams.get("q") ?? "").toLowerCase();

  const filtered = db.users.filter(
    (u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
  );
  // 分页壳字段全仓库统一:data / page / pageSize / total / totalPages
  return HttpResponse.json(paginate(filtered, page, pageSize));
});

动态性可以来自请求的任意维度:路径参数、查询串、请求头、Cookie、请求体、甚至当前时间。

Faker.js 生成海量数据 ​

ts
import { faker } from "@faker-js/faker";

faker.seed(20261001); // 固定种子:每次初始化的数据稳定可复现(测试友好)

export function createFakeUser() {
  return {
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email().toLowerCase(),
    avatar: faker.image.avatar(),
    createdAt: faker.date.recent({ days: 90 }).toISOString(),
  };
}

本教程的假数据库预置了 24 个用户、48 篇文章,其中两个固定账号:

用户名密码角色
adminadmin1234admin
demodemo1234member
其余 Faker 用户password123member(用邮箱登录)

数据持久化:模拟数据库 CRUD ​

没有持久化的 mock 一刷新就"失忆"。本教程用 localStorage 仓库实现假数据库:

ts
const STORAGE_KEY = "msw-tutorial:db:v1";

class MockDb {
  users: User[] = [];
  posts: Post[] = [];

  constructor() {
    const saved = localStorage.getItem(STORAGE_KEY);
    if (saved) Object.assign(this, JSON.parse(saved));
    else this.seed();
    this.persist();
  }

  persist() {
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ users: this.users, posts: this.posts }));
  }

  createUser(input: CreateUserInput) { /* ... */ this.persist(); }
  updateUser(id: string, patch: unknown) { /* ... */ this.persist(); }
  deleteUser(id: string) { /* ... */ this.persist(); }
  reset() { localStorage.removeItem(STORAGE_KEY); /* 重新 seed */ }
}

要点:

  • key 带版本号(:v1),schema 变化时改 key 即可废弃旧数据;
  • 提供 POST /api/db/reset 端点,让"恢复初始状态"变成一个普通请求;
  • Node 测试里同一模块换成内存 Map 即可(本仓库通过环境差异隔离)。

CRUD 端点的完整状态机(都可以在 交互实验室 里试):

操作成功失败
POST /api/users(需登录)201 + Location400 非法 JSON / 401 未登录 / 409 邮箱重复 / 422 校验失败
PATCH /api/users/:id200401 / 404 / 422
DELETE /api/users/:id(需 admin)204401 / 403 普通成员 / 404 / 409 删除自己

鉴权流程:登录、令牌轮换、守卫 ​

完整模拟 JWT 风格的双令牌认证(access token 短期 + refresh token 轮换):

 登录 POST /api/auth/login {username,password}
      │ 成功 → { tokenPair: {accessToken, refreshToken, tokenType, expiresIn}, user }
      ▼
 业务请求 Authorization: Bearer <accessToken>
      │ access token 失效 → 401 + WWW-Authenticate
      ▼
 刷新 POST /api/auth/refresh {refreshToken}
      │ 成功 → 新的 tokenPair(refresh token 轮换,旧的立即失效)
      │ 重放旧 refreshToken → 401(模拟 reuse detection)
      ▼
 登出 POST /api/auth/logout → 200 {ok:true}

handler 侧的守卫就是一个读请求头的纯函数:

ts
function requireAuth(request: Request): AuthContext | HttpResponse {
  const header = request.headers.get("authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) {
    return HttpResponse.json(
      { error: { code: "UNAUTHENTICATED", message: "缺少 Bearer 令牌" } },
      { status: 401, headers: { "WWW-Authenticate": "Bearer" } },
    );
  }
  const payload = verifyToken(token); // 本教程签发的是可解析的 mock JWT
  if (!payload) {
    return HttpResponse.json({ error: { code: "INVALID_TOKEN" } }, { status: 401 });
  }
  const user = db.users.find((u) => u.id === payload.sub);
  if (!user) return HttpResponse.json({ error: { code: "USER_NOT_FOUND" } }, { status: 401 });
  return { userId: user.id, role: user.role, user };
}

// handler 里一行接入
http.patch("*/api/users/:id", async ({ request }) => {
  const auth = requireAuth(request);
  if (!("userId" in auth)) return auth; // 守卫返回的 401 响应直接透传给客户端
  // ...放行后的业务逻辑
});

场景控制台的"启用鉴权"开关还能演示另一种常见需求:全局关闭鉴权用于纯 UI 开发、一键开启做权限联调。

请求依赖关系 ​

请求 B 依赖请求 A 的结果,是真实应用的常态:

  1. 创建 → 读取:POST 返回 201 + Location,客户端直接 GET 该 Location;
  2. 登录 → 变更:GraphQL mutation 复用同一套 Bearer 守卫;
  3. 刷新 → 重试:客户端封装在 401 时自动 /api/auth/refresh 并重放原请求;
  4. 上传 → 下载:POST /api/upload 返回文件 id,GET /api/files/:id 取回元数据。

两个示例应用的 api/client.ts 实现了第 3 条的单例会话(React 用 useSyncExternalStore,Vue 用 Pinia store),可对照阅读。

错误状态模拟清单 ​

本教程覆盖的故障注入面:

  • 状态码:400 / 401 / 403 / 404 / 409 / 422 / 429 / 500 / 503 及任意 /api/status/:code;
  • 网络错误:HttpResponse.error()(fetch reject);
  • 慢响应:100–2000ms 滑块与 delay("infinite") 挂起;
  • 概率错误:25% 随机失败;
  • WS 异常关闭:服务端发 4001(业务关闭)/ 1011(内部错误)。

彩蛋:SSE 支持 ​

MSW 3 还从主入口导出了 sse(Server-Sent Events)命名空间,可以模拟事件流:

ts
import { sse } from "msw";

sse.link("*/api/events"); // 详细 API 见官方文档,本仓库以 WebSocket 为主线演示实时通信

下一节进入 Node 与单元测试 —— 你会发现这些 handler 一行不改就能在 Vitest 里跑。