做前端这些年,我几乎每隔一段时间就要换一套接口 Mock 方案。从最早手写 JSON 文件、到 JSON Server、再到 webpack-dev-server 的接口拦截、最后到 Charles 抓包改数据,每一套方案都有让人难受的地方。直到我遇到 Mock Service Worker(后面统称 MSW),才真正觉得“接口 Mock 这件事终于做对了”。这篇文章我想把 MSW 的原理、用法和我在实际项目里的踩坑经验完整梳理一遍,希望能帮你少走弯路。
MSW 不是一个简单的数据模拟工具,它的核心思路非常特别:不是去劫持 HTTP 请求,也不是去改打包配置,而是直接在浏览器层面拦截网络请求,在真实网络到达服务器之前把 mock 数据返回给页面。这意味着你不需要改业务代码、不需要起额外的 mock 服务、不需要改代理配置,业务代码里发请求的样子和线上环境完全一致。它适用于前端开发调试、自动化测试、组件开发、接口联调等多个场景,无论你是刚入门的前端新人,还是带团队的技术负责人,都值得认真了解一下。
1. 内容整体设计与思路拆解
1.1 为什么需要 MSW:传统 Mock 方案的痛点
在讲 MSW 的架构之前,我先说说之前那些方案到底哪里让人难受。
JSON 文件模拟是最早的做法,直接在项目里放一个 data.json,业务代码里判断环境变量决定走真实接口还是读 JSON。这种方案的问题很明显,它会污染业务代码。为了 Mock 而写的 if-else 逻辑最后往往会被误带到线上环境,而且每次改接口字段都要改业务代码,手忙脚乱。
后来有人用 JSON Server 起一个真实的本地服务器,这个方案的好处是不用改业务代码,接口地址指向本地就行。但它需要额外启动一个 Node 进程,团队成员都要装依赖、配启动命令,而且 JSON Server 的数据是静态的,要模拟“登录失败”“列表为空”“接口超时”这种动态场景还是得写一堆代码。
再往后是代理转发方案,比如在 webpack-dev-server 里配置 proxy,把某些路径转发到 mock 服务。这个方案的问题在于配置链路太长了,改一个 mock 数据可能要同时动 dev-server 配置、mock 服务代码、代理规则,调试成本很高。而且它只对开发环境有效,等到了测试环境、CI 环境、自动化测试环境,这套配置就完全失灵了。
MSW 彻底改变了这个思路。它不关心你用什么构建工具、什么框架、什么请求库,它只关心一个问题:浏览器里发出的网络请求,能不能在到达服务器之前被人拦截下来?能的话,我就在拦截点返回我准备好的数据。这就是它的设计哲学:Mock 不应该是一套与真实代码平行的“影子系统”,而应该是网络层的“透明拦截器”。
1.2 MSW 的架构设计:Service Worker 作为网络代理
MSW 的核心技术基础是 Service Worker。Service Worker 是浏览器提供的一种特殊 JavaScript 线程,它独立于页面运行,可以拦截页面发出的网络请求。这是 PWA 的核心技术之一,原本是用来做离线缓存的。MSW 的聪明之处在于它把 Service Worker 的能力用在了开发场景上:既然 Service Worker 能拦截请求,那为什么不直接在拦截点返回 mock 数据?
当你在浏览器里启动 MSW 时,它会在你的域名下注册一个 Service Worker 文件(通常是 mockServiceWorker.js)。注册成功之后,页面上所有符合规则的网络请求都会先经过这个 Service Worker。MSW 在 Service Worker 内部维护了一套请求匹配逻辑,如果请求的 URL 和方法命中了你在 worker 端注册的 handler,就直接返回 handler 里定义的数据;如果没有命中,Service Worker 就放行请求,让它正常到达服务器。
这个设计带来的最大好处是:mock 发生在网络层,而不是代码层。页面代码根本感知不到 mock 的存在。你的 axios 请求、fetch 请求、XMLHttpRequest 请求,看到的结果都是“服务器返回的数据”。哪怕你用的是 WebSocket,MSW 也有办法模拟(不过这个场景用得少,我后面会提)。
另外,MSW 同时支持浏览器和 Node.js 两种运行环境。浏览器端依赖 Service Worker,Node.js 端则通过拦截 http/https 模块内部的请求处理,实现同样的效果。也就是说,在 Node.js 环境里跑自动化测试、单元测试,你也用同一套 handler 定义 mock 数据,完全不需要为测试单独写一套 mock 逻辑。这个“一套 Mock 走天下”的特性,是我目前最推荐它的原因。
1.3 适用场景:MSW 到底能解决哪些问题
我觉得用 MSW 最划算的场景有四个,你如果正好命中其中一个,就可以认真考虑接入。
第一是前后端并行开发。后端接口还没写好,前端已经可以根据接口文档用 MSW 定义 mock 数据先行开发。等后端就绪后,只需要把 handler 里的数据换成真实字段,或者直接关闭 mock,业务代码一行不用改,接口就能无缝切换。
第二是本地调试与演示。产品经理要看效果、设计师要验收样式、测试人员要提前进入测试流程,这些场景都可以通过 MSW 快速拉起一套带数据的页面。MSW 的响应是完全动态的,模拟登录态、模拟异常返回、模拟分页,状态切换非常快,比起一个真实的 mock 服务器要省心得多。
第三是自动化测试。MSW 可以接入 Vitest、Jest、Playwright、Cypress 等测试框架。在测试环境里,你可以精确控制每个接口的返回内容,从而稳定测试组件的各种状态。以前测试中常见的“依赖后端环境”“偶发网络超时”“测试数据污染”,在 MSW 下都不复存在。
第四是 Storybook 组件开发。你在写 Storybook 的时候,每个组件都需要数据支撑,过去你会传一堆静态数据画 props,但组件内部如果自己发请求的话,Storybook 里就很难处理。用 MSW 可以直接拦截 Storybook 内的请求,效果和真实环境几乎一致。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 安装与初始化:从零到第一个拦截
MSW 的安装非常简单,npm 或 yarn 都能装。核心依赖只有一个 msw 包。装完之后你会需要做一件事:初始化 Service Worker 文件。
bash复制npm install msw --save-dev
# 或者
yarn add msw --dev
初始化命令在 MSW 1.x 和 2.x 里稍有不同。如果你用的是 1.x,命令是 npx msw init public/。如果你用的是 2.x,命令是 npx msw init public/ --save。这条命令会在你的静态资源目录(public 目录)下生成一个名为 mockServiceWorker.js 的文件。这个文件在开发环境下会被浏览器当作 Service Worker 注册,所以它必须能被页面的根路径访问到。如果你的项目 public 目录不是根路径,比如部署在子路径下,记得要在 init 命令后手动确认路径。
初始化完文件之后,接着定义一个 handler 文件。我通常会在项目根目录下建一个 mocks 文件夹,里面放 browser.ts、server.ts、handlers.ts 三个文件。
typescript复制// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: 1,
name: '张三',
email: 'zhangsan@example.com',
role: 'admin',
});
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.username === 'admin' && body.password === '123456') {
return HttpResponse.json({
token: 'mock-token-123456',
code: 0,
message: 'ok',
});
}
return HttpResponse.json(
{ code: 10001, message: '用户名或密码错误' },
{ status: 401 }
);
}),
];
http.get 和 http.post 是 MSW 2.x 里最常用的请求匹配 API。它接收两个参数:第一个是 URL 匹配规则,可以用完整的 https://api.example.com/user,也可以用相对路径 /api/user,MSW 会自动把它解析成当前页面域名下的地址。第二个参数是响应解析器,函数里返回 HttpResponse.json() 就代表返回 JSON 数据,也支持返回 HttpResponse.text()、HttpResponse.arrayBuffer() 等不同的响应格式。
2.2 浏览器端启动:让 mock 在开发环境跑起来
handler 定义好之后,接下来要在应用入口文件里启动 MSW。一般在 main.tsx 或 main.js 里加一个条件判断,只有开发环境才启动 mock。
typescript复制// mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
然后在应用入口文件里:
typescript复制// main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') {
return;
}
const { worker } = await import('./mocks/browser');
return worker.start();
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
});
这里的 worker.start() 是一个异步操作,它要做的事情包括:检查 Service Worker 文件是否存在、注册 Service Worker、与 Service Worker 建立通信、确认拦截生效。所以在 start() 完成之后再渲染应用,可以避免应用启动时发出的第一批请求没有被 mock 到。这个“先启动 mock 再挂载应用”的顺序非常关键,我见过不少人偷懒写成并行加载,结果页面首屏的请求打到了真实接口上。
有一个值得说的点:MSW 的 Service Worker 并不是一旦注册就永久生效的。它只对“当前已注册的客户端页面”生效。如果你打开了两个标签页,只注册了第一个标签页,那么第二个标签页的请求不会被拦截。另外 Service Worker 的生命周期和页面不一样,有安装、激活、更新几个阶段。MSW 在 worker.start() 内部已经处理了大部分生命周期问题,但偶尔你改了 handler 文件,会发现 mock 数据没有更新,这时候多半是 Service Worker 缓存了旧的 worker 脚本。解决办法是硬刷新页面、或者注销 Service Worker 后重新注册。
typescript复制// 强制更新 Service Worker 的方法(浏览器 Console 里执行)
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.update());
});
2.3 Node.js 端启动:让测试环境用同一套 mock
浏览器端只是 MSW 的一半,另外一半是 Node.js 环境。这个环境主要用于测试。在 Jest、Vitest 或 Playwright 里,你不需要启动浏览器,只需要在 Node 环境里注册一个 mock server 来拦截测试代码发出的 HTTP 请求。
typescript复制// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
在测试文件里,你需要做三件套:beforeAll 里启动服务器、afterEach 里重置 handler、afterAll 里关闭服务器。
typescript复制import { beforeAll, afterEach, afterAll, test, expect } from 'vitest';
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('获取用户信息', async () => {
const res = await fetch('/api/user');
const data = await res.json();
expect(data.name).toBe('张三');
});
注意 server.resetHandlers() 这个函数,它会清除你在测试过程中用 server.use() 临时添加的 handler,恢复到 handlers.ts 里定义的默认状态。这样才能保证测试用例之间互不干扰。我最初写测试的时候没有调用这个函数,结果第一个用例里用 server.use() 覆盖了一个接口,第二个用例还在用被覆盖后的数据,排查了半天才找到原因。
3. 实操过程与核心环节实现
3.1 使用场景一:登录态与鉴权流程
实际项目里最常见的 mock 需求就是模拟登录和鉴权。后端接口没写好,前端要联调登录流程,或者产品要看带鉴权信息的页面效果。这种情况下,MSW 的优势特别明显:它可以精确模拟登录成功、登录失败、token 过期、无权限访问四种状态,而且切换状态不用改业务代码。
我一般会在 handler 里定义几个全局变量来模拟“当前登录状态”。
typescript复制// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
let currentUser: { id: number; name: string; role: string } | null = null;
export const handlers = [
http.post('/api/login', async ({ request }) => {
const { username, password } = await request.json();
if (username === 'admin' && password === '123456') {
currentUser = { id: 1, name: '管理员', role: 'admin' };
return HttpResponse.json({
code: 0,
data: { token: 'mock-token', user: currentUser },
});
}
return HttpResponse.json(
{ code: 10001, message: '用户名或密码错误' },
{ status: 401 }
);
}),
http.get('/api/profile', () => {
if (!currentUser) {
return HttpResponse.json(
{ code: 401, message: '未登录' },
{ status: 401 }
);
}
return HttpResponse.json({ code: 0, data: currentUser });
}),
http.post('/api/logout', () => {
currentUser = null;
return HttpResponse.json({ code: 0, message: '退出成功' });
}),
];
这里的关键是那个 currentUser 变量。它保存在 Node/浏览器的内存里,模拟了一个“会话状态”。登录接口给它赋值,获取用户信息的接口读取它,退出接口清空它。这样你可以在页面上真实地走一遍登录、刷新页面、退出登录的完整流程。注意,页面刷新的时候这个变量会丢失,因为它是内存态。如果你需要刷新后依然保持登录,可以把 token 存在 localStorage 里,然后在 handler 里读取。
这种做法在联调阶段非常好用。比如你负责的页面只有登录用户才能访问,而你在开发登录页时后端接口还没通,那你就用 MSW handle 住 /api/login、/api/profile 两个接口,整个登录链路就能完整跑通。后端接口好了之后,只需关闭 MSW,代码里不需要做任何调整。
3.2 使用场景二:分页列表与复杂查询条件
分页列表是另一个非常高频的 mock 场景。很多前端组件(El-Pagination、Antd Table)都依赖后端返回的 total 字段做分页控制。如果你 mock 的 total 写死成 100,而实际只生成了 10 条数据,会导致点击第 2 页的时候拿不到数据。所以我一般建议在 mock 一个列表接口时,先生成足够多的模拟数据,再按请求参数进行切片。
typescript复制// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
// 造 50 条模拟用户数据
const mockUsers = Array.from({ length: 50 }, (_, index) => ({
id: index + 1,
name: `用户${index + 1}`,
age: 20 + (index % 30),
email: `user${index + 1}@example.com`,
}));
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') || 10);
const keyword = url.searchParams.get('keyword') || '';
let filtered = mockUsers;
if (keyword) {
filtered = filtered.filter((user) => user.name.includes(keyword));
}
const start = (page - 1) * pageSize;
const end = start + pageSize;
const data = filtered.slice(start, end);
return HttpResponse.json({
code: 0,
data: {
list: data,
total: filtered.length,
page,
pageSize,
},
});
});
这里面的 new URL(request.url) 是一个关键操作。MSW 传给 handler 的 request 是一个标准的 Request 对象,你可以用 request.url 拿到完整请求地址,再用 URL 构造函数解析出查询参数。这样 mock 出来的行为才能和真实后端一致,分页组件才能正常工作。
我在真实项目里用这个模式 mock 过几十个列表接口,从客户列表、订单列表到消息列表,全部是这个套路。唯一的教训是:数据量别生成太少。至少 50 条起步,不然分页组件后面的页码会显示不出来,你还要在 UI 上反复确认是组件 bug 还是数据不够。
3.3 使用场景三:延迟与异常场景模拟
前端代码里最难处理的不是“接口返回成功”,而是“接口返回失败”“请求超时”“断网重连”这些异常情况。传统 mock 方案很难模拟这些场景,但 MSW 可以轻松做到。
模拟接口延迟非常简单,在 handler 里加一个 await delay() 就行:
typescript复制import { http, HttpResponse, delay } from 'msw';
export const handlers = [
http.get('/api/slow-request', async () => {
// 延迟 3 秒再返回,模拟网络慢或者后端处理慢
await delay(3000);
return HttpResponse.json({ data: '终于返回了' });
}),
];
模拟接口错误更是家常便饭。你可以让接口返回 500、502、404,也可以在响应体里写一个业务错误码(比如 code: 50001)。
typescript复制http.get('/api/error-demo', () => {
return HttpResponse.json(
{ code: 50001, message: '服务器开小差了,请稍后重试' },
{ status: 500 }
);
});
这种能力在测试全局错误拦截器、统一错误提示组件的时候特别有用。你不需要把后端“打死”来模拟错误,只需在 mock 里改一行返回状态码。而且 MSW 支持你针对同一个 URL 写多个 handler,在测试用例中通过 server.use() 临时覆盖:
typescript复制import { server } from './mocks/server';
import { http, HttpResponse } from 'msw';
test('接口超时显示 loading', async () => {
server.use(
http.get('/api/user', async () => {
await delay(5000);
return HttpResponse.json({});
})
);
// 渲染组件,断言 loading 状态
});
test('接口失败显示错误提示', async () => {
server.use(
http.get('/api/user', () => {
return HttpResponse.json(
{ message: '服务器错误' },
{ status: 500 }
);
})
);
// 渲染组件,断言错误提示出现
});
这就是 MSW 在测试中的杀招:测试代码里完全不需要模拟 fetch 或 axios,也不需要改组件 props,直接通过 server.use() 覆盖接口返回结果,组件内部代码一行都不用动。
3.4 使用场景四:GraphQL 接口模拟
除了 REST API,MSW 还内置了对 GraphQL 的支持。如果你项目用的是 GraphQL,那 MSW 也能给你一套很优雅的 mock 方案。
typescript复制import { graphql, HttpResponse } from 'msw';
export const handlers = [
graphql.query('GetUser', () => {
return HttpResponse.json({
data: {
user: {
id: 1,
name: '张三',
email: 'zhangsan@example.com',
},
},
});
}),
graphql.mutation('UpdateUser', ({ variables }) => {
return HttpResponse.json({
data: {
updateUser: {
id: variables.id,
name: variables.name,
success: true,
},
},
});
}),
];
graphql.query 和 graphql.mutation 是 MSW 提供的两个 GraphQL 匹配器,第一个参数是操作名称(Operation Name),第二个参数是处理函数。处理函数里的 variables 可以拿到客户端传来的变量。
GraphQL 的 mock 有一个天然难点:一个查询可能嵌套很多层,你需要把整个返回结构写完整。这个问题没有太好的办法,只能老老实实把 schema 里的字段都填上。不过好在 MSW 可以配合 GraphQL Code Generator 生成类型,这样 mock 数据就能受到 TypeScript 类型检查,不容易遗漏字段。
3.5 请求体验证与动态响应
实际项目中,往往需要根据请求体里的参数动态返回不同数据。MSW 的 handler 第二个参数能拿到 request 对象,可以安全地读取请求体。
typescript复制import { http, HttpResponse } from 'msw';
export const handlers = [
http.post('/api/order', async ({ request }) => {
const body = await request.json();
const { productId, quantity } = body;
if (!productId || !quantity) {
return HttpResponse.json(
{ code: 40001, message: '参数缺失' },
{ status: 400 }
);
}
if (quantity > 10) {
return HttpResponse.json(
{ code: 40002, message: '单次购买数量不能超过10' },
{ status: 400 }
);
}
return HttpResponse.json(
{
code: 0,
data: {
orderId: Date.now(),
productId,
quantity,
totalPrice: quantity * 100,
},
},
{ status: 201 }
);
}),
];
这里需要特别注意的是 request.json() 是异步的,所以在真实项目里,我看到不少人习惯写成:
typescript复制http.post('/api/order', ({ request }) => {
const body = request.json(); // 忘了 await,返回的是 Promise
return HttpResponse.json({ got: body.quantity }); // 拿不到值
});
这种错误特别隐蔽,因为不报错,只是拿到的值是 undefined。正确做法是一定要 await request.json(),并且这个 handler 函数要标记为 async。
另外,MSW 2.x 的响应解析器除了能返回 HttpResponse.json(),还能返回 new Response() 这个标准 Web API 对象。这意味着你可以在 mock 里设置响应头:
typescript复制http.get('/api/file', () => {
return new Response('文件内容', {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename="readme.txt"',
},
});
});
如果你要 mock 的是文件下载、图片返回、流式内容等场景,这套能力会很有用。但日常开发中 90% 的接口都是 JSON,所以 HttpResponse.json() 足够了。
4. 常见问题与排查技巧实录
4.1 Service Worker 注册失败
这是 MSW 新手最常遇到的问题。表现是:页面启动了,也能正常渲染,但所有请求都打到了真实接口,mock 完全不生效。浏览器 Console 里通常会有一条 [MSW] Failed to register the Service Worker 之类的错误提示。
最常见的原因是 mockServiceWorker.js 文件放错了位置。这个文件必须放在网站的根路径下。比如你在 http://localhost:3000 开发,那文件就需要被访问到时返回 JS 内容。如果你的项目 public 目录不在根路径,或者你的服务托管在子路径(比如 http://localhost:3000/app/),那 init 命令生成的默认路径可能就不对了。
解决办法:确认项目实际路径结构,把 mockServiceWorker.js 放到正确的静态目录里,然后 worker.start({ serviceWorker: { url: '/app/mockServiceWorker.js' } }) 显式指定路径。
另一种情况是使用了 HTTP 而不是 HTTPS。Service Worker 在非 localhost 的 HTTP 环境下会被浏览器拒绝注册。不过 localhost 是个例外,可以在 HTTP 下工作。如果你用了局域网 IP 地址访问页面(比如 http://192.168.1.100:3000),那注册可能被浏览器拦下。解决办法是用 localhost 访问,或者给开发服务器配 HTTPS 证书。
4.2 handler 没命中或命中了但返回不对
如果你确认 Service Worker 已启动,但某个请求没有被 mock,第一反应应该是检查 handler 的 URL 匹配规则。MSW 的 URL 匹配默认是“完整匹配”,不是“前缀匹配”。也就是说,http.get('/api/user') 只会拦截 /api/user,不会拦截 /api/user/list。如果你想拦截所有 /api/ 开头的请求,可以写成 http.get('/api/*'),具体的匹配语法在 MSW 文档里叫“path-to-regexp”,支持冒号参数(:id)和通配符(*)。
typescript复制// 路径参数示例
http.get('/api/user/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({ id, name: `用户${id}` });
});
// 通配符示例
http.get('/api/*', () => {
return HttpResponse.json({ message: 'fallback' });
});
当多个 handler 匹配同一个请求时,MSW 默认使用第一个匹配的 handler。如果你在 server.use() 里覆盖了一个 handler,那它会排在最前面,优先命中。调试时可以用 debug() 模式。MSW 在 worker.start({ onUnhandledRequest: 'warn' }) 默认配置下,如果请求没有被任何 handler 匹配,会有 warn 日志。如果你配置的是 'bypass',那请求会被静默放行。我建议在开发环境下保持默认的 warn 模式,这样很容易发现“请求没被 mock 住”的问题。
4.3 跨域请求的 mock 处理
MSW 能不能 mock 跨域请求?答案是能。你可以在 handler 里写完整的 URL:
typescript复制http.get('https://api.github.com/repos/octocat/hello-world', () => {
return HttpResponse.json({ full_name: 'octocat/hello-world' });
});
这个不需要后端开启 CORS,因为 Service Worker 拦截发生在浏览器内部,根本不会有真正的跨域网络请求发出。所以在开发环境下,你可以很轻松地 mock 第三方平台接口(比如微信支付、地图服务、分析平台)。这是我觉得 MSW 和传统代理方案相比的巨大优势:不需要为 mock 的域配置代理、不需要修改后端 CORS 配置。
但有个坑要注意:如果你的业务代码在请求时带了某些自定义 header,比如 Authorization: Bearer xxx,MSW 的 handler 默认也会正常处理这个 header,不影响 mock 结果。不过如果你 mock 的是跨域请求,浏览器会认为这个请求是跨域的,业务代码里如果设置了 credentials: 'include'(发送 cookies),这个请求在 mock 场景下没有真实跨域,所以不会有 cookie 附加。这种情况比较少见,但遇到了要知道不是 MSW 的问题。
4.4 与前端框架的集成问题(React/Vue)
MSW 是请求层面的库,理论上与任何框架兼容,但实际接入时有一些细节需要注意。
在 React 里,最需要注意的问题是严格模式(StrictMode)下 useEffect 会执行两次。如果你的应用启动时请求用户信息,并且你把 MSW 的 worker.start() 放在模块顶层,这通常没问题。但如果你把 worker.start() 放在 useEffect 里,就可能导致重复注册。正确做法是像前面写的那样,在 main.tsx 里启动 MSW 之后再渲染根组件,而不是在某个页面组件里启动。
在 Vue 里,你通常在 main.ts 里做同样的事:
typescript复制import { createApp } from 'vue';
import App from './App.vue';
async function bootstrap() {
if (import.meta.env.DEV) {
const { worker } = await import('./mocks/browser');
await worker.start();
}
createApp(App).mount('#app');
}
bootstrap();
Vite 项目的 import.meta.env.DEV 可以直接判断开发环境。如果你用的是 Vite,这一步很顺手。
4.5 测试场景中 MWS 与 Jest/Vitest 的兼容性
在 Node.js 测试环境里,MSW 兼容 Jest 和 Vitest。但有一个问题是 Node.js 的版本差异。MSW 2.x 要求 Node.js 版本不低于 18。如果你还在用 Node 16,建议升级,不然会遇到各种 ES Modules 加载问题。
在 Vitest 里使用 MSW 时,有一个常见的配置问题:Vitest 默认只在 Node 环境运行,不包含 DOM。MSW 的 Node 端 server 不需要 DOM,所以没问题。但要确保你的测试文件里没有引入 msw/browser,否则会报错。我建议把 mocks/browser.ts 和 mocks/server.ts 分开,测试环境只引入 server。
如果遇到“Cannot use import statement outside a module”这类报错,多半是 esModuleInterop 或者 type: module 配置问题。这个要靠项目自身的 tsconfig 解决,MSW 本身不背这个锅。
4.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| mock 不生效,请求打到真实服务器 | Service Worker 文件路径不对 | 确认 mockServiceWorker.js 在正确静态目录下 |
| mock 不生效,console 无报错 | worker.start() 没有 await,页面先发请求了 | 在应用渲染前 await worker.start() |
| 某个接口 mock 不到 | handler URL 匹配规则不准确 | 使用精确路径或通配符,注意 path-to-regexp 语法 |
| 改了 handler 但返回旧数据 | Service Worker 脚本缓存 | 硬刷新(Cmd+Shift+R / Ctrl+Shift+R)或注销 SW |
| 测试用例之间数据污染 | 没有调用 server.resetHandlers() | 在 afterEach 里调用 server.resetHandlers() |
| 接口超时提示,一直 pending | delay 时间太久或 handler 抛异常 | 检查 handler 是否抛错,设置合理 delay |
| 跨域接口 mock 不通 | handler 里 URL 没写完整 | 在 handler 里写完整的绝对 URL |
5. 工程化落地经验:从“能用”到“好用”
5.1 设计可维护的 mock 目录与命名规范
MSW 用法本身不复杂,真正让一个项目里 mock 体系变得臃肿难维护的,往往是目录结构混乱和命名随意。我建议按业务模块组织 handler,而不是把所有 handler 塞在一个文件里。
我目前推荐的目录结构:
code复制mocks/
├── browser.ts
├── server.ts
├── handlers.ts
├── data/
│ ├── users.ts
│ ├── orders.ts
│ └── products.ts
├── handlers/
│ ├── user.ts
│ ├── order.ts
│ └── product.ts
└── utils/
└── db.ts
data 目录放模拟数据源,handlers 目录放接口处理逻辑,utils 目录放公共工具(比如分页、延迟、响应格式化)。handlers.ts 只负责聚合:把各个模块的 handler 导出成一个数组。这样每个人负责自己的业务模块,互不干扰,合并代码时冲突也少。
typescript复制// mocks/handlers.ts
import { userHandlers } from './handlers/user';
import { orderHandlers } from './handlers/order';
import { productHandlers } from './handlers/product';
export const handlers = [
...userHandlers,
...orderHandlers,
...productHandlers,
];
另外我强烈建议给每一个业务 handler 模块写一个简短的注释,说明这个 mock 接口的用途、依赖的数据源、当前是否与后端联调中。因为 mock 代码的量会随着业务增长而膨胀,几个月后你再看之前写的 handler,没有注释的话基本要靠猜。
5.2 用 TS 类型约束 mock 数据的完整性
前端项目里接口数据是有 TypeScript 类型的,mock 数据应该也受类型约束。这样才能在接口字段变动时第一时间发现 mock 数据没有同步更新。
方法很简单,直接从项目里已有的类型定义或后端生成的类型定义导入:
typescript复制// types/api.d.ts
export interface User {
id: number;
name: string;
email: string;
avatar: string;
}
// mocks/handlers/user.ts
import { http, HttpResponse } from 'msw';
import type { User } from '@/types/api';
const mockUsers: User[] = [
{ id: 1, name: '张三', email: 'zhangsan@example.com', avatar: '/avatar.png' },
{ id: 2, name: '李四', email: 'lisi@example.com', avatar: '/avatar.png' },
];
http.get('/api/user', () => {
return HttpResponse.json<User>({
code: 0,
data: mockUsers[0],
});
});
需要注意的是,如果你用了不同层级的返回包装(比如统一响应体 { code, data, message }),建议把响应体也定义成泛型工具类型,不然每个 handler 都要手写返回结构,非常容易漏字段。我项目中有一个 ApiResponse<T> 类型,mock 时统一用 ApiResponse<User> 规范返回。
5.3 环境切换:开发、测试、预发布、生产的配置策略
MSW 最容易被误用的地方是让它跑在生产环境。尽管很多开发者知道不应该这样,但如果不区分环境,Mock 很容易在 build 时被无意带上。我的建议是:生产环境不要启动 MSW,不要注册 Service Worker 文件,甚至不要在构建产物中包含 mock 代码。
在 Vite 项目里,可以用环境变量控制动态导入:
typescript复制if (import.meta.env.DEV || import.meta.env.MODE === 'staging') {
const { worker } = await import('./mocks/browser');
await worker.start();
}
在测试环境里,走 Node 端的 server.listen()。这个环境分得很清楚。至于预发布、灰度环境,我一般不接入 MSW,因为那已经是接近真实环境的验证场所,mock 在这里反而会造成误判。
5.4 与后端联调时的优雅切换
最后想聊聊最有意思的一个细节:当你依赖 MSW 完成了大量的前端开发,后端接口终于就绪了,这时候你是如何切换的?最简单粗暴的办法是把环境变量关掉,让 MSW 不启动。但问题来了:页面里有一部分请求是真实接口,一部分可能还是老字段,一下子全切过去必然报错。
我建议的做法是在 handler 里加一个“开关”逻辑,让部分接口走 mock、部分接口走真实服务。这种混合模式在后端联调期特别实用。
typescript复制// 用一个配置文件区分哪些接口走 mock
export const MOCK_ENABLED = {
user: true,
order: false,
product: true,
};
在 handler 注册时,只有 MOCK_ENABLED.user === true 才注册 user 模块的 handler。这样你可以先切少量接口调试,确认无误后再逐步放开。整个切换过程是渐进的、可控的,不会出现“一把梭全切过去然后页面崩了”的窘境。
6. 性能、安全与团队协作的额外建议
6.1 性能开销:MSW 会影响页面性能吗
很多人担心 Service Worker 常驻会不会拖慢页面速度。从我的实测来看,MSW 的开发环境性能开销几乎可以忽略不计。Service Worker 本身是一个独立线程,拦截请求然后查一遍 handler 列表,这个工作在本地完成,不会有网络延迟。唯一可能让请求变慢的情况是你在 handler 里加了很大的人工延迟(delay()),或者 handler 里做了特别耗时的逻辑(比如循环生成大数组)。
如果你在意性能,可以把 handler 匹配做得精确一些,不要用太多 * 通配符。通配符太多会导致每次请求都要遍历所有 handler。当然,这个开销在开发环境还是可以忽略的,但为了代码可读性和排查方便,我仍然建议能写具体路径就写具体路径。
6.2 安全边界:为什么生产环境绝不能开 MSW
这是一个很严肃的话题。Service Worker 有能力拦截并篡改页面发出的所有请求,这意味着它能访问到用户在页面上输入的所有信息、请求的所有接口、拿到的所有响应。如果你把 mock 注册到了生产环境,会带来两个致命问题:
第一,用户数据可能被截获或篡改。如果你的 handler 里有类似 /api/user 的通用匹配,它可能拦截掉真实用户请求从而返回假数据,造成页面逻辑混乱,甚至导致用户看到别人的数据(如果 mock 数据里包含测试账号信息)。
第二,安全问题。这个能力一旦被恶意代码利用,后果很严重。虽然 MSW 本身是正规开源库,但你不应该把这种“请求改写能力”暴露到生产环境。任何情况下生产环境必须完全关闭 MSW、保证 mockServiceWorker.js 不会被发布服务器访问到。
我在团队里定过一条规矩:发布脚本里必须有一条检查,如果构建产物中出现 mockServiceWorker.js 或者 msw 相关的 chunk,CI 直接报错。这比口头约定靠谱多了。
6.3 团队协作:让 mock 数据成为团队的公共资产
MSW 的 handler 文件本质上是接口文档的一种可执行形态。我在项目中把它当作团队的公共资产来维护。后端同学可以看着 handler 文件里的 mock 数据结构,快速了解前端期望的接口响应格式。前端新同学入职后,通过阅读 handler 文件就能迅速理解项目的接口约定和业务字段。
更进阶的玩法是配合 TypeScript 类型和接口文档自动生成工具,让 mock 数据、类型、文档三者同步。不过这属于“锦上添花”的部分,初期能把 handler 文件维护好就足够了。
最后分享一个我在实际项目里的真实体会:MSW 最大的价值不只是“开发时不用等后端”,而是它把 Mock 从“临时的、脆弱的、只属于某些人的事情”变成了“常态化的、可复用的、全团队共享的基础设施”。当你写完一套 handler,它既能在本地开发用、又能在 Storybook 用、还能在自动化测试用、甚至能在联调期无缝过渡,这种“一次编写,处处运行”的体验,是传统方案完全给不了的。如果你还没尝试过 MSW,找个周末把项目的 mock 体系重写一遍,你会回来感谢我的。
