接口文档还在评审,页面已经画完了,这种局面做前端的人都遇到过。等后端联调?大概率是等不到的。于是前端 mock 数据就成了每个项目开工前的第一件事——先自己造一份能跑通的数据,把页面交互、状态流转、异常分支全部验证一遍,等真接口来了直接换地址就行。我做过的大多数中后台项目,前端 mock 数据的方案都换过至少两轮,从最早的手写 JSON 到后来的 Mock.js,再到现在的 MSW,每一轮换的原因都不是"新工具更酷",而是上一轮在某个具体场景里翻车了。这篇就把我用过的几种方式摊开讲,包括每种方式适合什么场景、底层靠什么拦截、实际写起来长什么样,以及那些只有踩过才知道的坑。不管你是刚接触前端开发的新人,还是已经带过几个项目的老手,都能从里面挑到一条适合当前阶段的路径。
1. 先把问题拆开:前端 mock 数据到底在解决什么
很多人一上来就问"用哪个工具",其实这个问题问早了。工具是结果,不是起点。真正该先搞清楚的是:你现在缺的是数据,还是缺的是一个稳定的数据源?这两个问题的答案指向完全不同的方案。
1.1 三种真实场景,决定了你要选哪种方案
第一种场景是后端接口完全没动。接口路径定了个大概,字段名还在吵,返回结构随时可能改。这时候你要的不是"像真的数据",而是"能快速改的数据"。方案要轻,改一个字段不该重启服务,更不该重新打包。
第二种场景是接口有了但不稳定。后端开发环境三天两头挂,或者数据被人改得乱七八糟,你刷新十次能拿到八种不同的结果。这时候核心诉求是可复现——同一个请求,今天和明天拿到的数据必须一样,否则你没法定位前端的问题。
第三种场景是真实数据很难构造。比如你要测一个列表页在几千条数据下的滚动性能,或者要测一个极端的错误码分支,真实环境里造这些数据的成本高得离谱。这时候你需要的是可控的生成能力,能按规则批量造,也能按需触发特定异常。
我见过太多团队用一种方案硬扛三个场景,结果就是每个人都难受。轻量方案扛不住复杂生成,重型方案在改字段的时候又慢得要命。所以第一步永远是先认领场景,再选工具。
提示:如果一个项目同时存在这三种场景,不要试图用一个工具全包,按环境做分层比强行统一要省事得多。
1.2 拦截层次:方案的天花板由它在哪一层说话决定
所有前端 mock 数据的方式,本质上都是在请求发出的路径上找一个位置,把请求拦下来,然后自己返回一份数据。区别只在于拦在哪一层,而这一层直接决定了它的能力上限和坑点分布。
大致可以分成四层:请求调用层(你在自己的 request 封装里动手脚)、浏览器 API 层(重写 XMLHttpRequest 或 fetch)、Service Worker 层(在浏览器网络代理的位置拦截)、开发服务器与网络代理层(请求根本没到浏览器,在 Node 或代理工具那一端就被处理了)。
层次越靠前,代码侵入性越强,但可控性也越高;层次越靠后,业务代码越干净,但调试起来越隐蔽。举个具体例子:你在 request 封装里手写分支,业务代码里到处都是if (isMock),侵入性拉满,但你一眼就能看出数据从哪来。而在代理层改,业务代码一行不动,可你排查"为什么这个字段没更新"的时候,得同时看代码、看代理配置、看缓存,成本高得多。
还有一个容易被忽略的点:跨层混用会出问题。比如你同时用了 Mock.js 和 MSW,Mock.js 重写了 XHR,MSW 又想接管 fetch,两者的拦截顺序在不同浏览器里表现不一致,最后就是"有时候生效有时候不生效"。这种玄学问题的根源基本都在这里。
2. 六种主流方案逐个拆解与选型
下面这六种是我在实际项目里真正用过的,按我的推荐顺序排,但不代表后面的就没价值——有些场景下它们反而是唯一解。
2.1 本地 JSON 文件 + 请求层封装:最土但最稳
最原始的做法:在项目里建一个mock目录,每个接口对应一个 JSON 文件,然后在 request 封装里加一个开关,开关打开就走本地文件,关闭就走真实地址。
// src/utils/request.ts const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' const mockMap: Record<string, () => Promise<any>> = { '/api/user/list': () => import('@/mock/userList.json'), '/api/order/detail': () => import('@/mock/orderDetail.json'), } export async function request<T>(url: string, options?: RequestInit): Promise<T> { if (USE_MOCK && mockMap[url]) { const mod = await mockMap[url]() return mod.default as T } const res = await fetch(url, options) return res.json() }这个方案的优点非常明确:零依赖,不涉及任何拦截机制,调试的时候在 Network 面板里看不到请求(因为是本地 import),不会和任何工具打架。缺点也很明显:动态参数处理不了,分页、搜索、排序全都得自己实现,而且每加一个接口就要改一次 map。
它适合的场景是:接口数量少、结构固定、只做静态展示验证。比如一个纯展示的配置页面,或者一个只需要确认字段名对不对的对接。很多人在这种场景下非要去上 MSW,其实是拿高射炮打蚊子,配置成本比写业务还高。
注意:用
import()动态导入 JSON 的时候,构建工具可能会把整个 mock 目录打进生产产物。上线前一定要确认这块被 tree-shaking 掉,或者用环境变量做死条件让它不可能被引入。
2.2 Mock.js 重写 XHR:上手最快,坑也最集中
Mock.js 应该是国内前端圈用得最广的 mock 工具了,原因就是它的数据模板语法太顺手:
import Mock from 'mockjs' Mock.mock('/api/user/list', 'get', { code: 0, message: 'ok', 'data|10': [{ 'id|+1': 1, 'name': '@cname', 'email': '@EMAIL', 'age|18-60': 1, 'city': '@city', 'avatar': '@IMAGE("100x100")', }], 'total|100-500': 1, })@cname生成中文姓名,@EMAIL生成邮箱,'data|10'表示数组长度固定为 10,'total|100-500'表示在区间内随机。这套模板语言写起来确实爽,几行代码就能造出一大坨像模像样的数据,特别适合做列表页演练。
但它的坑也是真的多,我按遇到的频率列一下:
第一个坑,它只对 XMLHttpRequest 生效。现在的项目大量用 fetch 或 axios(axios 在浏览器端默认也是 XHR,但如果配了 fetch adapter 就不一样了)。你写好了 Mock.js 的规则,结果业务代码用的是 fetch,请求直接穿透,mock 一点反应都没有。这是新手最常遇到的问题,没有之一。
第二个坑,拦截是全局的,没有开关粒度。一旦引入mockjs并执行了Mock.mock(),整个页面所有匹配的请求都会被改写。你想让某一个接口走真实地址?只能靠 URL 精确匹配绕开,很别扭。
第三个坑,它会污染 XHR 原型。在一些和监控、埋点相关的 SDK 同时存在时,重写顺序会打架,出现请求被拦截两次或者回调丢失的情况。这类问题排查起来非常痛苦,因为报错栈里根本看不到 Mock.js 的痕迹。
第四个坑,随机数据不可复现。'id|+1'这类规则每次刷新都在变,你想复现一个特定的渲染 bug,得靠运气。虽然可以通过设置随机种子缓解,但用起来并不顺手。
我的结论是:Mock.js 适合快速原型、个人演练、教学演示,不适合长期维护的多人项目。如果你的项目超过三个人协作,或者预计生命周期超过三个月,建议直接跳到 2.4。
2.3 Vite / Webpack 的 devServer 中间件:和构建流程绑在一起
这个思路是把 mock 逻辑挂到开发服务器的中间件上,请求会真的发出去,但被 devServer 截住并返回你定义的数据。
以 Vite 生态里用得最多的vite-plugin-mock为例,配置大概是这样:
// vite.config.ts import { defineConfig } from 'vite' import { viteMockServe } from 'vite-plugin-mock' export default defineConfig(({ command }) => ({ plugins: [ viteMockServe({ mockPath: 'mock', enable: command === 'serve', logger: true, }), ], server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, }))然后mock目录下每个文件导出一个数组:
// mock/user.ts import type { MockMethod } from 'vite-plugin-mock' export default [ { url: '/api/user/list', method: 'get', timeout: 300, response: ({ query }) => { const page = Number(query.page ?? 1) const size = Number(query.size ?? 10) const list = Array.from({ length: size }, (_, i) => { const id = (page - 1) * size + i + 1 return { id, name: `用户${id}`, role: id % 3 === 0 ? 'admin' : 'user' } }) return { code: 0, message: 'ok', data: { list, total: 86 } } }, }, ] as MockMethod[]这个方案我最喜欢的一点是timeout参数。真实网络是有延迟的,很多加载状态、骨架屏的问题只有在有延迟的时候才暴露出来。你在本地用静态数据测的时候,请求 0 毫秒返回,loading 效果一闪而过根本看不清,上线之后才发现骨架屏闪得很难看。故意加个 300 毫秒延迟,这类问题在开发阶段就能被发现。
另外response是个函数,能拿到 query、body、headers,可以很方便地模拟分页、搜索过滤、甚至根据 token 返回不同的权限数据。这比 Mock.js 那种纯静态模板强太多了。
它的问题在于:和构建工具强绑定。你在 Vite 里配的这一套,换到 Webpack 项目里得重新写一遍。而且它本质上还是跑在开发服务器里,跨端调试(比如手机连局域网访问)的时候,请求地址和 mock 匹配规则容易对不上,需要额外注意 path 前缀。
2.4 MSW:Service Worker 层拦截,目前工程化的优先解
MSW(Mock Service Worker)的定位很特殊,它不在你的代码里拦截,而是在浏览器里注册一个 Service Worker,请求真的发出去了,但被 Service Worker 接住并直接返回响应。从 Network 面板看,请求是真实存在的,状态码、响应头、耗时都是真的。
这个特性带来的好处非常实在:业务代码零侵入。你的 axios 封装、fetch 调用、甚至第三方 SDK 发的请求,全都不用改。切到真接口的时候,只需要关掉 worker,其他一行代码不动。
安装和基础结构:
npm i msw -D npx msw init public/ --savemsw init会在public目录下生成一个mockServiceWorker.js,这个文件必须在能被浏览器直接访问到的路径下,否则 worker 注册会失败——这是第一次用最容易卡住的地方,很多人把它放到了src里,结果一直报注册失败。
handler 定义:
// src/mocks/handlers.ts import { http, HttpResponse, delay } from 'msw' export const handlers = [ http.get('/api/user/list', async ({ request }) => { await delay(300) const url = new URL(request.url) const page = Number(url.searchParams.get('page') ?? 1) const size = Number(url.searchParams.get('size') ?? 10) const keyword = url.searchParams.get('keyword') ?? '' const list = Array.from({ length: size }, (_, i) => { const id = (page - 1) * size + i + 1 return { id, name: `${keyword}用户${id}`, role: id % 3 === 0 ? 'admin' : 'user', createdAt: new Date(Date.now() - id * 86400000).toISOString(), } }) return HttpResponse.json({ code: 0, message: 'ok', data: { list, total: 86, page, size }, }) }), http.post('/api/user/create', async ({ request }) => { const body = await request.json() as Record<string, unknown> if (!body.name) { return HttpResponse.json( { code: 40001, message: '名称不能为空', data: null }, { status: 200 }, ) } return HttpResponse.json({ code: 0, message: 'ok', data: { id: 999 } }) }), ]注意上面那个错误分支的写法:HTTP 状态码返回 200,业务码返回 40001。这是国内中后台项目的常见约定,前端要根据业务码判断成功与否。如果你在 mock 里直接返回 400,前端的请求拦截器会走网络错误分支,和真实行为不一致,测出来的东西没有参考价值。这个细节很多教程都不提,但它是"mock 数据和真实行为对齐"的关键。
2.5 json-server / 独立 mock 服务:跨团队联调时的共享底座
前面几种方案都是"每个人在自己机器上跑一份",这在多人协作时会出问题:A 改了一条测试数据,B 那边刷新就变了,两个人对着屏幕吵半天,最后发现是数据源不统一。
json-server解决的就是这个问题。它把一份 JSON 文件直接变成一个 REST 接口服务:
npx json-server --watch db.json --port 3001{ "users": [ { "id": 1, "name": "张三", "role": "admin" }, { "id": 2, "name": "李四", "role": "user" } ], "orders": [] }启动之后,GET /users返回列表,GET /users/1返回单条,POST /users新增,PATCH /users/1修改,DELETE /users/1删除。而且还自带分页和排序:
| 需求 | 请求写法 | 说明 |
|---|---|---|
| 分页 | /users?_page=2&_limit=10 | 响应头里会带总数 |
| 排序 | /users?_sort=id&_order=desc | 多字段用逗号分隔 |
| 模糊搜索 | /users?name_like=张 | 正则匹配 |
| 区间筛选 | /users?id_gte=10&id_lte=20 | 支持 gte/lte/ne |
| 关联查询 | /users?_embed=orders | 需要数据结构对应 |
把这个服务部署到一台内网机器上,团队所有人配同一个代理地址,数据就统一了。谁改的数据,其他人刷新就能看到,沟通成本一下就降下来了。
它的短板是:写操作是真写文件的,多人同时改容易冲突,而且没有事务概念。另外它只能处理符合 RESTful 风格的接口,遇到那种一个 URL 返回复杂嵌套结构的业务接口,就得写自定义中间件,反而更麻烦。
2.6 代理层转发:只改配置不改代码的兜底手段
最后一种,也是最"外科手术"式的:不动前端代码,用开发服务器的 proxy 把请求转发到一个本地 mock 服务或者一个响应改写工具上。
// vite.config.ts server: { proxy: { '/api': { target: 'http://127.0.0.1:3001', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, }, }这个方案的适用场景是:你接手了一个老项目,代码动不了,但需要改接口返回。比如临时要把某个列表接口从真实环境切到本地数据,改一行代理配置就完事,不用去翻请求封装层。
排查这类方案不生效的时候,有几个固定的检查点,我按命中率排序:
- 代理规则的前缀有没有匹配上。
/api和/api/在某些配置下行为不同,路径重写正则写错了会转发到错误地址。 - 是不是浏览器缓存了 304。开启 DevTools 的 Disable cache,或者给请求加个随机参数再看。
- 是不是 Keep-Alive 连接复用了旧连接。改了代理配置一定要完全重启开发服务器,热更新不会重新加载 proxy 配置,这个坑我踩过不止一次。
- HTTPS 页面访问 HTTP 接口。浏览器会直接拦掉混合内容,请求根本发不出去,Network 面板里连记录都没有。
3. Vue3 + TS + MSW 完整落地记录
讲了这么多方案,挑一个我目前最常用的完整走一遍,把每一步的意图都说清楚。
3.1 依赖安装与目录约定
npm i msw -D npx msw init public/ --save目录结构我固定成这样:
├── public/ │ ── mockServiceWorker.js # msw init 生成的,不要手改 ├── src/ │ ├── mocks/ │ │ ├── browser.ts # 浏览器端 worker 实例 │ │ ├── handlers.ts # handler 注册入口 │ │ ├── handlers/ │ │ │ ├── user.ts │ │ │ └── order.ts │ │ └── db.ts # 内存数据源 │ └── main.ts把 handler 按业务域拆文件,是因为项目大了之后handlers.ts会变成几百行,改一个字段要在一堆代码里找。按域拆开,谁负责的模块谁改自己的文件,冲突概率低很多。
db.ts是我加的一层"内存数据库",目的是让增删改查在同一个会话里有连续性:
// src/mocks/db.ts export interface UserItem { id: number name: string role: 'admin' | 'user' createdAt: string } function seed(): UserItem[] { return Array.from({ length: 86 }, (_, i) => ({ id: i + 1, name: `用户${i + 1}`, role: i % 3 === 0 ? 'admin' : 'user', createdAt: new Date(Date.now() - (i + 1) * 86400000).toISOString(), })) } export const db = { users: seed(), nextUserId: 87, }为什么要这么做?因为如果你每次请求都重新生成数据,那么"新增一条用户"之后刷新列表,新增的那条就消失了。测新增流程的时候,前端开发者会以为是自己的提交逻辑有问题,查半天最后发现是 mock 没存。有了内存 db,整个会话内的数据是连续的,测试体验和真实后端一致。
3.2 写 handler:把分页、排序、错误码都模拟出来
// src/mocks/handlers/user.ts import { http, HttpResponse, delay } from 'msw' import { db, type UserItem } from '../db' export const userHandlers = [ http.get('/api/user/list', async ({ request }) => { await delay(300) const url = new URL(request.url) const page = Number(url.searchParams.get('page') ?? 1) const size = Number(url.searchParams.get('size') ?? 10) const keyword = url.searchParams.get('keyword')?.trim() ?? '' const sort = url.searchParams.get('sort') ?? '' let rows = [...db.users] if (keyword) { rows = rows.filter((u) => u.name.includes(keyword)) } if (sort === 'createdAt,desc') { rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) } const start = (page - 1) * size return HttpResponse.json({ code: 0, message: 'ok', data: { list: rows.slice(start, start + size), total: rows.length, page, size }, }) }), http.post('/api/user/create', async ({ request }) => { await delay(200) const body = (await request.json()) as Partial<UserItem> if (!body.name?.trim()) { return HttpResponse.json({ code: 40001, message: '名称不能为空', data: null }) } if (db.users.some((u) => u.name === body.name)) { return HttpResponse.json({ code: 40002, message: '名称已存在', data: null }) } const item: UserItem = { id: db.nextUserId++, name: body.name, role: body.role ?? 'user', createdAt: new Date().toISOString(), } db.users.unshift(item) return HttpResponse.json({ code: 0, message: 'ok', data: item }) }), http.delete('/api/user/:id', async ({ params }) => { await delay(150) const id = Number(params.id) const idx = db.users.findIndex((u) => u.id === id) if (idx === -1) { return HttpResponse.json({ code: 40401, message: '记录不存在', data: null }) } db.users.splice(idx, 1) return HttpResponse.json({ code: 0, message: 'ok', data: null }) }), ]这里有几个刻意的设计。delay是分接口给的:列表接口 300 毫秒,创建 200 毫秒,删除 150 毫秒。真实后端不同接口的耗时本来就不一样,统一给一个延迟反而会让你的 loading 状态设计变得失真。排序在 mock 里也实现了:因为列表页的排序交互必须是"服务端排序",如果你在 mock 里不处理,前端就得自己排,等切到真接口的时候再改一遍,等于白写。
注意:
delay不要给太大。超过 1 秒的延迟会让你在开发阶段频繁等待,效率极低。300 毫秒左右已经足够暴露出 loading 和骨架屏的问题了。
3.3 main.ts 接入与环境开关
// src/main.ts import { createApp } from 'vue' import App from './App.vue' import router from './router' async function bootstrap() { if (import.meta.env.DEV && import.meta.env.VITE_USE_MOCK === 'true') { const { worker } = await import('./mocks/browser') await worker.start({ onUnhandledRequest: 'bypass', serviceWorker: { url: '/mockServiceWorker.js' }, }) } createApp(App).use(router).mount('#app') } bootstrap()两个关键点。第一,worker.start()必须 await,而且要等它完成之后再挂载应用。如果不等,首屏发出的请求会赶在 worker 注册完成之前跑出去,直接穿透到真实接口,表现就是"首页数据是对的,但一刷新就变成真实数据了"。这个现象非常迷惑人,我见过好几个同事在这里卡了一整天。
第二,onUnhandledRequest: 'bypass'。默认值是warn,也就是未匹配的请求会被打印一条警告但正常放行。项目里接口多的时候,控制台会刷满警告,把真正的报错淹掉。改成bypass就安静了。调试阶段如果怀疑某个请求没被匹配上,临时改回warn反而有用。
.env.development:
VITE_USE_MOCK=true.env.production:
VITE_USE_MOCK=false3.4 生产构建隔离:这条线不能破
MSW 相关的代码绝对不能进生产包,原因不只是体积问题。如果 worker 在生产环境被注册,用户的请求会先被 Service Worker 拦一遍,未匹配的才放行——多这一层不仅带来性能损耗,还可能在某些网络环境下导致请求失败。更严重的是,如果 handler 里写死了返回成功,你会在生产环境看到一份"看起来有数据但全是假的"页面,这种事故的排查成本极高。
隔离手段有三层,建议全上:
第一层是环境变量,构建时通过import.meta.env.DEV做静态条件,Vite 在生产构建时会把整个if分支标记为死代码并剔除。
第二层是条件动态导入。注意我上面写的是await import('./mocks/browser'),动态导入配合静态条件,能让打包器明确知道这段代码在条件为假时永远不会执行。
第三层是上线前的产物检查。构建完之后跑一句:
grep -rl "mockServiceWorker" dist/ || echo "clean"如果输出不是 clean,就说明有东西漏出去了,得回去查引入路径。这个检查我建议直接加到 CI 流程里,人工检查一定会忘。
4. 排查实录:mock 不生效、数据对不上、上线翻车
这一节是我这些年攒下来的问题清单,绝大部分都能在五分钟内定位。
4.1 配了 mock 却还是打到真实接口
按这个顺序查,命中率从高到低:
先看 Network 面板里请求的响应头有没有x-powered-by之类的服务端标识。有,说明请求真的到了服务器,worker 没拦住。
然后看 Console 里 Service Worker 的注册状态。打开 DevTools 的 Application 面板,看 Service Workers 那一栏是不是处于 activated 状态。如果是 waiting 或者 redundant,说明注册失败或者旧版本还在。刷新页面没用,要点一下 skipWaiting,或者干脆关掉整个标签页重开。
再看 URL 匹配模式。http.get('/api/user/list')这种相对路径是相对于当前页面的 origin 的。如果你的页面在http://localhost:5173,请求发到http://localhost:3001/api/user/list,那这个规则根本匹配不上。要么把请求改成相对路径,要么在 handler 里写完整地址。
最后看请求是不是被别的层拦走了。同时装了 Mock.js,或者配了 devServer proxy 把/api转发走了,请求压根没走 Service Worker。这时候关掉其他拦截层再试。
4.2 同一接口两个人拿到不一样的数据
这个问题的根因通常有三个。
随机数据没固定种子。用了 Mock.js 的随机模板,或者在自己的 handler 里用了Math.random(),每次请求结果都不同。如果这个接口是静态展示,建议直接把数据写死;如果必须随机,把种子固定住,保证同一台机器每次启动结果一致。
数据源各自独立。A 用的是本机 json-server 的db.json,B 用的是自己手写的内存数组,两人根本不在一份数据上。这时候需要统一数据源,要么共用一台内网 mock 服务,要么把 mock 数据文件纳入版本管理,谁改谁提交。
日期和时区处理不一致。一个人用 UTC 生成的createdAt,另一个人用本地时区格式化输出,展示出来的时间差好几个小时。mock 里生成时间字段时,建议统一用 ISO 字符串,格式化交给前端组件处理。
提示:把"数据不一致"当作一个需要设计的问题,而不是需要排查的问题。在设计 mock 方案的时候就把数据源唯一化,能省掉后面 90% 的扯皮。
4.3 抓包工具改了响应没反应
用抓包工具做响应替换(不是前端方案,但在联调阶段经常被用来临时验证),改了规则之后发现页面没变化,一般跑不出这几个原因:
规则匹配的 URL 和实际请求的 URL 不一致。带 query 参数的请求,规则里如果写了完整 URL 包括参数,参数值变一下就匹配不上。建议规则里只匹配路径部分。
响应被浏览器缓存了。请求返回 304,走的是本地缓存,根本没用到你改的响应。强制刷新或者禁用缓存再试。
HTTPS 流量没有解密。开启解密之后,需要在本机安装并信任对应的根证书,没有这一步,抓包工具只能看到加密流量,改不了内容。这一步在 macOS 上还需要在钥匙串里手动设置信任,只安装不设置是没用的。
抓包工具没被系统或浏览器代理走到。比如浏览器装了代理插件,把流量导到了别的地方。检查一下系统代理设置和浏览器插件的状态。
连接被复用了。长连接场景下,改动规则后旧连接还在用,新规则要等连接断开才生效。重启抓包工具或者等一会儿再试。
4.4 打包后的产物里混进了 mock 代码
这个问题我在两个项目里遇到过,一次是因为用了import静态引入handlers.ts并且在顶层执行了注册逻辑;另一次是因为msw被放到了dependencies而不是devDependencies,构建工具认为它是运行时依赖,不敢剔除。
排查和修复的顺序是:先检查所有 mock 相关的 import 是不是都在import.meta.env.DEV或等价条件里面,再把msw、mockjs这类包统一挪到devDependencies,最后跑一遍产物检查。
下面这张表是我整理的问题速查表,贴在团队文档里,新人上手能少走很多弯路:
| 现象 | 最可能的原因 | 快速验证方法 |
|---|---|---|
| 首屏数据正确,刷新后变成真实数据 | worker 未 await 完成就挂载应用 | 在 start 后打日志,看顺序 |
| 所有请求都穿透 | worker 未注册成功 | Application 面板看 SW 状态 |
| 部分接口穿透 | URL 匹配规则不匹配 | Console 开 warn 模式看告警 |
| fetch 请求不生效 | 用了只重写 XHR 的方案 | 检查请求用的是 fetch 还是 XHR |
| 改了 handler 没变化 | Service Worker 缓存了旧脚本 | 硬刷新或手动 unregister |
| 新增数据刷新后消失 | mock 数据源每次重新生成 | 检查是否有模块级持久化数组 |
| 生产环境出现假数据 | mock 代码未做条件隔离 | grep 产物中的关键字 |
5. 团队协作层面的几条硬规矩
单人项目怎么写都行,多人协作就必须有约定,否则 mock 会变成技术债的主要来源。
5.1 目录、命名与开关约定
目录固定为src/mocks,只允许这个入口。不允许在业务组件里直接写 mock 逻辑,也不允许在utils里塞 mock 分支。入口唯一,才可能做出可靠的构建隔离。
命名上,mock 文件与接口模块一一对应。src/api/user.ts对应src/mocks/handlers/user.ts。这样改接口的时候,需要同步改哪个 mock 文件是一目了然的。
开关只有一个,就是环境变量。不要在代码里加if (process.env.NODE_ENV === 'development' && window.location.search.includes('mock'))这种复合条件判断,条件越多越难维护,也越容易在构建时漏掉分支。
mock 数据里不写真实的业务敏感信息。即使是从真实环境复制过来的结构,字段值也要替换成明显的假数据,避免截图、录屏、分享代码片段时泄露。
5.2 mock 数据的字段结构必须和真实接口对齐
这条是最容易被忽视、代价也最大的。很多人的 mock 数据是自己"猜"出来的结构,等真实接口来了,发现字段名对不上、嵌套层级不一样、null 和空数组的语义不同,然后要改一堆组件。
我的做法是:接口文档一出来,先把 TypeScript 类型定义写出来,放在src/types里,mock 数据和业务代码都从这个类型来。
// src/types/user.ts export interface UserItem { id: number name: string role: 'admin' | 'user' createdAt: string } export interface PageResult<T> { list: T[] total: number page: number size: number } export interface ApiResponse<T> { code: number message: string data: T }handler 里返回的时候显式标注类型:
return HttpResponse.json<ApiResponse<PageResult<UserItem>>>({ ... })这样如果 mock 返回的结构和类型定义不符,编译期就会报错。等真实接口来了,先拿真实响应去对一遍类型定义,类型对了,业务代码基本不用改。这一步花的时间,能省掉后面几倍的对齐成本。
还有一个细节:空值语义要对齐。列表为空的时候,后端返回的是[]还是null?分页总数超过范围时返回空数组还是报错?这些在 mock 里都要按真实约定来,否则测试的时候一切正常,上线第一天空列表就白屏。我习惯在 handler 里专门留一个"空数据"分支,通过查询参数触发,比如?__empty=1,方便专门验证空态。
5.3 联调切换与上线前检查清单
从 mock 切到真实接口,不要直接改环境变量然后祈祷。按这个清单走一遍:
- 逐个接口核对真实响应的字段结构,和 TS 类型定义做一次 diff,有不一致的地方立刻记录。
- 检查所有依赖 mock 特殊行为的代码。比如你在 mock 里实现了前端排序,而真实接口也支持排序,那就要确认前端有没有重复排序。重复排序会导致结果看起来"随机正确随机错误",非常难查。
- 检查错误码分支。mock 里你只模拟了成功和两三种失败,真实接口可能有十几种业务码。挑几个高风险的补上处理逻辑,至少要有兜底提示。
- 检查时间字段。mock 里是 ISO 字符串,真实接口可能是时间戳,格式化函数要能兼容。
- 跑一遍产物检查,确认 mock 代码没有进生产包。
- 上线之后用真机走一遍主要流程,重点看首屏加载和列表分页这两个最容易出问题的地方。
6. 补充一个容易忽略的环节:调试期间的网络环境差异
还有件事值得单独说。开发机的网络环境和用户的实际环境差别很大,本地 mock 数据永远无法暴露这类问题。我遇到过几次典型情况:本地 mock 里接口响应 20 毫秒返回,列表页滚动流畅;上线之后接口要 800 毫秒,滚动加载的节流逻辑没写对,直接触发了十几次重复请求。
应对办法是在 mock 里主动制造恶劣条件。除了给接口加延迟,还可以加抖动:
const jitter = 200 + Math.floor(Math.random() * 600) await delay(jitter)还可以模拟偶发失败:
http.get('/api/order/list', async () => { if (Math.random() < 0.15) { return HttpResponse.json({ code: 50000, message: '服务繁忙,请稍后重试', data: null }) } return HttpResponse.json({ code: 0, message: 'ok', data: { list: [], total: 0 } }) })有人会担心"随机失败会让开发变得很烦"。确实会,所以这个开关必须能一键关掉。我的做法是把它挂在环境变量上,只在专门做健壮性验证的时候打开,平时保持稳定输出。
同理,还要模拟同一接口的重复请求。用户手快连点两次提交按钮,前端有没有做防抖或者按钮禁用?这类问题在本地 0 延迟的环境下几乎不可能被发现,但在真实环境里非常常见。在 mock 里加 500 毫秒延迟,然后自己快速点两下,问题立刻就暴露了。
7. 我选方案时的实际判断逻辑
说了这么多方案,最后落回到一个实际问题上:新项目开工,我到底怎么选。
判断逻辑其实就三句话。个人项目或者短期原型,用 devServer 中间件那一套,改起来快,配置量小,不需要额外的注册流程。多人协作的中长期项目,直接上 MSW,零侵入带来的收益会随着项目规模放大,而且它的 handler 写法和真实的接口测试工具非常接近,后面要做自动化测试的时候能直接复用。需要跨团队共享数据的场景,加一台 json-server,把它作为公共数据源,成本极低。
至于 Mock.js,我现在基本不用了。它的数据模板语法确实顺手,但那套基于 XHR 重写的机制和现代前端技术栈的兼容性越来越差。如果你只是想快速生成一批假数据填表格,用它没问题;但如果是搭一套长期的 mock 基础设施,它带来的不确定性远大于便利。
如果项目的接口数量特别多,还有一个折中做法:用工具从接口文档自动生成 mock。比如从 OpenAPI 或 Swagger 定义里批量生成 handler 骨架,字段结构和文档天然对齐,剩下的工作只是补充数据值。这样既保证了结构一致,又省掉了逐个手写的功夫。我最近两个项目都是这么做的,接口对齐的成本几乎降到零。
做前端这么多年,我对 mock 方案的评价标准一直在变。早期看的是"配置有多简单",后来看的是"能不能和真实接口对齐",现在看的是"能不能在不改业务代码的前提下切换"。这个变化背后其实是同一件事:mock 不是一次性的临时脚手架,它是开发流程里长期存在的一环。把它当作正式代码来设计、来维护、来检查,它才能真正帮你省事,而不是在某次上线前的深夜里变成那个让你抓狂的源头。我在实际使用中发现,凡是把 mock 目录随便建、规则随便写、上线前不做检查的项目,最后都在这上面花掉了不止一次的时间;而那几个把 mock 当作一份正式契约来对待的项目,切换真接口那天基本上一个下午就全跑通了。