
接口文档还在评审页面已经画完了这种局面做前端的人都遇到过。等后端联调大概率是等不到的。于是前端 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 和 MSWMock.js 重写了 XHRMSW 又想接管 fetch两者的拦截顺序在不同浏览器里表现不一致最后就是有时候生效有时候不生效。这种玄学问题的根源基本都在这里。2. 六种主流方案逐个拆解与选型下面这六种是我在实际项目里真正用过的按我的推荐顺序排但不代表后面的就没价值——有些场景下它们反而是唯一解。2.1 本地 JSON 文件 请求层封装最土但最稳最原始的做法在项目里建一个mock目录每个接口对应一个 JSON 文件然后在 request 封装里加一个开关开关打开就走本地文件关闭就走真实地址。// src/utils/request.ts const USE_MOCK import.meta.env.VITE_USE_MOCK true const mockMap: Recordstring, () Promiseany { /api/user/list: () import(/mock/userList.json), /api/order/detail: () import(/mock/orderDetail.json), } export async function requestT(url: string, options?: RequestInit): PromiseT { 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表示数组长度固定为 10total|100-500表示在区间内随机。这套模板语言写起来确实爽几行代码就能造出一大坨像模像样的数据特别适合做列表页演练。但它的坑也是真的多我按遇到的频率列一下第一个坑它只对 XMLHttpRequest 生效。现在的项目大量用 fetch 或 axiosaxios 在浏览器端默认也是 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 MSWService Worker 层拦截目前工程化的优先解MSWMock 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 Recordstring, 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?_page2_limit10响应头里会带总数排序/users?_sortid_orderdesc多字段用逗号分隔模糊搜索/users?name_like张正则匹配区间筛选/users?id_gte10id_lte20支持 gte/lte/ne关联查询/users?_embedorders需要数据结构对应把这个服务部署到一台内网机器上团队所有人配同一个代理地址数据就统一了。谁改的数据其他人刷新就能看到沟通成本一下就降下来了。它的短板是写操作是真写文件的多人同时改容易冲突而且没有事务概念。另外它只能处理符合 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 PartialUserItem 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.developmentVITE_USE_MOCKtrue.env.productionVITE_USE_MOCKfalse3.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.jsonB 用的是自己手写的内存数组两人根本不在一份数据上。这时候需要统一数据源要么共用一台内网 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 PageResultT { list: T[] total: number page: number size: number } export interface ApiResponseT { code: number message: string data: T }handler 里返回的时候显式标注类型return HttpResponse.jsonApiResponsePageResultUserItem({ ... })这样如果 mock 返回的结构和类型定义不符编译期就会报错。等真实接口来了先拿真实响应去对一遍类型定义类型对了业务代码基本不用改。这一步花的时间能省掉后面几倍的对齐成本。还有一个细节空值语义要对齐。列表为空的时候后端返回的是[]还是null分页总数超过范围时返回空数组还是报错这些在 mock 里都要按真实约定来否则测试的时候一切正常上线第一天空列表就白屏。我习惯在 handler 里专门留一个空数据分支通过查询参数触发比如?__empty1方便专门验证空态。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 当作一份正式契约来对待的项目切换真接口那天基本上一个下午就全跑通了。