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 篇文章,其中两个固定账号:
| 用户名 | 密码 | 角色 |
|---|---|---|
admin | admin1234 | admin |
demo | demo1234 | member |
| 其余 Faker 用户 | password123 | member(用邮箱登录) |
数据持久化:模拟数据库 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 + Location | 400 非法 JSON / 401 未登录 / 409 邮箱重复 / 422 校验失败 |
| PATCH /api/users/:id | 200 | 401 / 404 / 422 |
| DELETE /api/users/:id(需 admin) | 204 | 401 / 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 的结果,是真实应用的常态:
- 创建 → 读取:POST 返回 201 + Location,客户端直接 GET 该 Location;
- 登录 → 变更:GraphQL mutation 复用同一套 Bearer 守卫;
- 刷新 → 重试:客户端封装在 401 时自动
/api/auth/refresh并重放原请求; - 上传 → 下载:
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 里跑。