Skip to content

4.1 请求拦截:匹配规则与读取请求 ​

本章解决两个问题:让 handler 精确命中目标请求,以及从请求里取出路径参数、查询串、请求头、Cookie 和表单。

路径匹配 ​

三种 predicate ​

ts
// 1) 字符串(精确路径,可用通配源 *)
http.get("*/api/users", listUsers);

// 2) 路径模板(Path-to-RegExp 风格,:param 命名参数)
http.get("*/api/users/:userId", getUser);
http.get("*/api/users/:userId/posts/:postId", getPost);

// 3) 正则(应对动态性最强的场景)
http.get(/\/api\/users\/\w+\/avatar$/, getAvatar);

// 通配 *:匹配任意层级
http.get("*/api/files/*", anyFile);

源(origin)部分:

  • * 匹配任意源,浏览器与 Node 通用,本教程一律推荐;
  • 写死 https://api.example.com/users 可严格限定源;
  • 相对路径 /users 只在浏览器有效(Node 无当前 origin)。

匹配只针对路径与源,查询参数(?q=...)不参与匹配 —— 查询串在 resolver 里解析。

路径参数 ​

ts
http.get("*/api/users/:userId", ({ params }) => {
  const userId = params.userId; // 注意:开启 noUncheckedIndexedAccess 时类型是 string | undefined
  // ...
});

查询参数 ​

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") ?? "";      // 全文搜索
  const role = url.searchParams.get("role");      // ?role=admin
  // ...
});

亲手试:下面两个请求只差查询串,返回结果完全不同(模拟数据库里有 24 个用户)。

查询参数:分页 + 搜索

换个关键字

请求方法与请求体 ​

ts
http.post("*/api/posts", async ({ request }) => {
  // JSON
  const body = (await request.json()) as CreatePostInput;

  // 文本
  const raw = await request.text();

  // FormData(multipart 上传)
  const form = await request.formData();
  const file = form.get("file"); // File | null —— 字段名约定为 file
});

请求体只读取一次(Request.body 是流);需要多次读取时先 await request.clone().json()。

FormData 提交 ​

本教程的上传 handler 只认字段名 file,缺字段返回 422:

ts
http.post("*/api/upload", async ({ request }) => {
  const form = await request.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return HttpResponse.json(
      { error: { code: "MISSING_FILE", message: "multipart 字段 file 必填" } },
      { status: 422 },
    );
  }
  return HttpResponse.json(
    { id: crypto.randomUUID(), filename: file.name, mediaType: file.type, size: file.size },
    { status: 201 },
  );
});

浏览器 fetch 传 FormData 时不要手动设置 Content-Type,浏览器会自动带上 boundary。

请求头校验 ​

ts
http.get("*/api/require-header", ({ request }) => {
  const requestId = request.headers.get("x-request-id");
  if (!requestId) {
    return HttpResponse.json(
      { error: { code: "REQUEST_ID_REQUIRED" } },
      { status: 400 },
    );
  }
  const accept = request.headers.get("accept") ?? "";
  if (!accept.includes("application/json") && !accept.includes("*/*")) {
    return HttpResponse.json(
      { error: { code: "NOT_ACCEPTABLE" } },
      { status: 406 },
    );
  }
  return HttpResponse.json({ echoed: requestId });
});

用控制台分别发"带头"和"不带头"的请求对比:

请求头校验(400 / 406 / 200)

把请求头改成 {} 再发 → 400;保留 x-request-id 但把 Accept 改成 text/plain → 406。

ts
http.get("*/api/me", ({ cookies }) => {
  const session = cookies["session_id"];
  if (!session) {
    const res = HttpResponse.json({ error: { code: "NO_SESSION" } }, { status: 401 });
    res.headers.append("Set-Cookie", "session_id=; Max-Age=0");
    return res;
  }
  // ...
});

CORS 模拟 ​

应用与 API 不同源时,MSW 同样运行在应用页面的 worker 中 —— 请求"看起来"跨域,但响应由同源 worker 合成,浏览器不会发起真实预检。 如需模拟跨域响应头,直接在 HttpResponse 上加:

ts
HttpResponse.json(data, {
  headers: {
    "Access-Control-Allow-Origin": "https://app.example.com",
    "Access-Control-Allow-Credentials": "true",
  },
});

passthrough:部分放行 ​

真实接口已就绪、只想 mock 个别端点时,让其余请求穿透:

ts
import { http, passthrough } from "msw";

export const handlers = [
  http.get("*/api/users/*", () => passthrough()), // 用户接口走真实后端
  http.get("*/api/posts", mockPosts),             // 文章继续 mock
];

本教程示例还演示了对 https://api.github.com/* 的 passthrough —— mock 与真实 API 可以混用。

小练习 ​

写操作需要 Bearer 令牌(下一章会细讲鉴权模拟),所以分两步:

第一步:登录 admin / admin1234,从响应的 tokenPair.accessToken 复制令牌:

① 登录获取令牌

第二步:把令牌粘进请求头,然后尝试:

  1. 创建用户(body 给 name/email/role)→ 观察 201 + Location;
  2. 用同一个 email 再发一次 → 409 CONFLICT;
  3. body 改成 {} → 422 校验错误;
  4. 删掉请求头 → 401。

② 携带 Bearer 令牌创建用户

下载示例文件 ​