ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

前端 Mock 数据方案全解:Mock.js、MSW、axios 与独立服务

2026/10/1 1:06:23 拓冰建站 浏览量
前端 Mock 数据方案全解:Mock.js、MSW、axios 与独立服务 周三下午三点产品在群里发了条消息明天上午十点评审把新版用户列表页跑起来。你打开项目页面骨架写完了接口文档上标着待开发。这时候摆在面前的无非三条路等后端、自己造假数据、或者干脆先把页面写死。第一条不现实第三条会留下技术债所以真正可选的只有前端 mock 数据这一条路。说到底前端 mock 数据不是一个技术选型问题而是节奏问题——你得让页面先在自己的节奏里转起来而不是被后端进度拖着走。这件事我从 jQuery 时代手写 XML 假数据开始一路做到现在用 MSW 在 Service Worker 里拦请求中间踩过的坑足够写一本小册子。有人觉得 mock 就是随便写点假数据其实它承担的东西比想象中多它要顶住联调前的空窗期要让异常分支能被触发要在没有后端的情况下把分页、延迟、错误码这些真实场景模拟出来。这篇文章我会把目前主流的几种前端 mock 数据方式从原理到落地全部拆开讲一遍包括各自适合什么场景、代码怎么写、以及那些文档里不会写的坑。适合正在做项目、准备面试或者想把团队 mock 规范理顺的前端开发者。1. 接口没就绪时前端到底在等什么1.1 三种等待方式各自的代价大部分人对 mock 的理解停留在后端没给接口我先编一个。这个理解没错但太窄了。真实项目里前端被迫停下来的场景至少有三种每种的处理方式其实不一样。第一种是接口完全没开始做。这种情况最常见也最好处理因为你可以随便定数据结构自由度最高。但代价是你定的结构很可能和后端最终给的不一样等真接口来了要返工一遍字段映射。第二种是接口做了但字段没定。后端可能已经写好了 Controller但返回的字段名还是userName还是username还在拉扯。这时候 mock 的重点不是编数据而是把契约固化下来让前后端围绕一份 mock 数据去对齐字段而不是靠口头约定。第三种是接口有了但环境不通。测试环境挂了、内网访问不了、数据被清了这类问题在联调后期特别常见。这时候 mock 起的是降级兜底作用让你不至于因为环境问题一整天啥也干不了。三种场景对应的方案诉求完全不同第一种要快、要自由第二种要准、要能当契约第三种要稳、要能一键切换。很多团队 mock 做得难受就是因为用一套方案硬套三种场景。1.2 Mock 真正要解决的四件事把 mock 拆开看它其实同时在解决四个问题缺一个都会别扭。第一是数据供给页面要有数据能渲染这是最基础的。第二是契约对齐mock 的数据结构要能当接口文档用前后端看到的是同一份东西。第三是异常覆盖真实接口会返回 500、会超时、会返回空数组、会返回超长文本你在开发阶段不模拟这些等上线了才发现空状态页面根本没写。第四是可控性你要能控制响应延迟多久、什么时候返回错误、返回第几页的数据而不是每次都得求后端帮你改。我自己最深的体会是第三条。有一次做订单详情页所有 mock 数据都返回正常订单结果上线第二天碰到一个已取消的订单页面上状态标签直接白了——样式类名没有对应的分支。如果当时 mock 里加几条边界数据这个问题根本不会流到线上。所以后面我给团队定的规矩是任何一个列表接口的 mock至少要有正常数据、空数组、超长字段、错误码四种情况。2. 静态数据文件和 Mock.js五分钟能跑起来的两种土办法2.1 直接把 JSON 或 TS 常量写进项目最简单的 mock 方式就是在项目里建一个mock目录扔几个 JSON 文件或者 TS 常量进去组件里直接 import 使用。// src/mock/userList.js export const userList [ { id: 1, name: 张三, email: zhangsanexample.com, role: admin }, { id: 2, name: 李四, email: lisiexample.com, role: member } ]// 组件里 import { userList } from /mock/userList async function loadUsers() { // 模拟异步保持和真实请求一样的调用形态 await new Promise((resolve) setTimeout(resolve, 300)) return userList }这种方式的好处非常直接零依赖、零配置、TypeScript 类型能跟着走、IDE 跳转方便。写个 demo 页面、做个原型、临时给产品演示它是最快的。但它的问题也很致命。第一调用形态和真实请求不一样。真接口是request.get(/api/user/list)你这里变成了import userList等接口来了要改的不只是数据源而是整个数据获取层。第二分页、搜索、排序这些逻辑全部要自己手写写到最后你会发现你在实现一个假的后端。第三打包体积。如果 mock 数据文件被静态 import构建工具很难自动摇掉它尤其是当它被用在某个不常走的条件分支里时。我的建议是这种方式只在两种情况下用——一是纯静态页面比如官网、帮助中心二是组件的单元测试。其他场景哪怕多花十分钟也把数据挂到一个请求上别直接 import。2.2 Mock.js 与造数库的什么时候值得引入当你要造的不是两三条数据而是两百条带随机姓名、随机手机号、随机日期的数据时手写就变成折磨了。这时候 Mock.js 或者 faker 这类造数库就有价值。// src/mock/modules/user.js import Mock from mockjs Mock.mock(/api/user/list, get, { code: 0, message: ok, data|10-20: [ { id|1: 1, name: cname, email: email, phone: /^1[3-9]\d{9}$/, age|18-45: 1, createTime: datetime(yyyy-MM-dd HH:mm:ss), status|1: [active, disabled, pending] } ] })data|10-20表示生成 10 到 20 条id|1: 1表示 id 自增cname是中文姓名占位符。这套语法上手很快写复杂列表数据确实省事。这里必须说清楚一个历史包袱Mock.js 的核心能力是造数据但它自带的拦截功能是改写 XMLHttpRequest 和 fetch来实现的属于运行时猴补丁monkey patch。这带来几个问题在模块加载顺序不对的时候拦截会失效在某些打包配置下会被 tree-shaking 干掉在新版本浏览器或某些请求库上兼容性不稳定。所以我现在用 Mock.js基本只用它的造数能力拦截交给更靠得住的层去做。另外提一句 faker它更偏国际化假数据生成姓名、地址、公司名都有但它的中文数据质量参差做中文产品的话Mock.js 的cname反而更可用。选哪个取决于你的数据要给人看还是要给程序跑。3. 在请求层动手axios 适配器与拦截器的取舍3.1 拦截器方案的先天缺陷大多数教程教你的第一种正经mock 方案是改 axios 拦截器axios.interceptors.request.use((config) { if (config.url /api/user/list) { config.adapter () Promise.resolve({ data: { code: 0, data: [] }, status: 200, statusText: OK, headers: {}, config }) } return config })这段代码能跑但它有三个隐藏问题。第一它在请求链路的中间插了一刀。请求拦截器之后还有一堆逻辑token 注入、参数签名、loading 计数。你的 mock 数据返回后这些逻辑仍然会跑一遍如果某段逻辑依赖真实的响应头就会出错。第二错误处理路径不对。真实接口返回 500 时axios 会走 reject 分支触发响应拦截器里的错误处理。如果你在请求拦截器里直接 resolve 一个假响应那错误分支永远走不到你对异常状态码的处理逻辑就完全没被验证过。第三拦截器是全局的开关粒度粗。你想只 mock 某个模块的接口就得在拦截器里写一堆 if 判断越写越脏。3.2 自定义 adapter 的写法与一个可切换开关更干净的做法是替换 axios 的 adapter。adapter 是 axios 真正发请求的那一层把它换掉意味着请求确实发出去了只是没走网络。// src/mock/adapter.js import axios from axios import { matchRoute } from ./routes const defaultAdapter axios.defaults.adapter export function createMockAdapter(options {}) { const { delay 300 } options return async function mockAdapter(config) { const route matchRoute(config.url, config.method) if (!route) { // 没命中的请求走真实网络 return typeof defaultAdapter function ? defaultAdapter(config) : defaultAdapter[0](config) } await new Promise((resolve) setTimeout(resolve, route.delay ?? delay)) const body route.handler({ params: config.params, data: typeof config.data string ? JSON.parse(config.data || {}) : config.data, headers: config.headers }) if (body.__status body.__status 400) { const error new Error(body.message || mock error) error.response { data: body, status: body.__status, statusText: Mock Error, headers: {}, config } throw error } return { data: body, status: 200, statusText: OK, headers: { content-type: application/json }, config } } }路由表单独抽出来每条路由声明方法、路径、延迟、handler// src/mock/routes.js export const routes [ { method: get, path: /api/user/list, delay: 500, handler: ({ params }) { const page Number(params?.page || 1) const size Number(params?.size || 10) return { code: 0, data: { list: buildUsers(size, (page - 1) * size), total: 128, page, size } } } }, { method: get, path: /api/user/detail, handler: () ({ __status: 500, message: 服务开小差了 }) } ] export function matchRoute(url, method get) { const clean url.split(?)[0] return routes.find( (r) r.path clean r.method method.toLowerCase() ) }然后在入口处按环境变量决定是否挂上// src/request/index.js import axios from axios import { createMockAdapter } from /mock/adapter const service axios.create({ baseURL: /api, timeout: 10000 }) if (import.meta.env.DEV import.meta.env.VITE_USE_MOCK true) { service.defaults.adapter createMockAdapter({ delay: 300 }) } export default service这个方案比拦截器好在哪三点。一是它复用了 axios 完整的错误处理链路__status: 500会真的走 catch你的错误 toast、401 跳登录这些逻辑都能被验证。二是它有天然的开关VITE_USE_MOCK一关就走真实网络不用改任何业务代码。三是 mock 逻辑和业务逻辑彻底分离src/mock目录整个删掉都不影响任何组件。注意不同 axios 版本的defaults.adapter形态不一样。1.x 早期是函数后来变成数组按顺序尝试。挂载前先打印一下确认类型别直接照着老教程抄否则会出现mock 生效了但真实请求发不出去的诡异现象。4. 在构建层拦请求devServer 中间件与 Vite 插件4.1 webpack devServer 的两种写法和历史包袱如果你的项目基于 webpack可以直接在 devServer 上加中间件让请求在到达浏览器之前就被本地服务截住。这种方式的本质是浏览器认为自己在访问/api/user/list实际上是本地 dev server 返回了一份 JSON。老版本用before钩子// webpack.config.js const fs require(fs) const path require(path) module.exports { devServer: { before(app) { app.get(/api/user/list, (req, res) { const file path.resolve(__dirname, mock/user-list.json) res.setHeader(Content-Type, application/json) res.end(fs.readFileSync(file, utf-8)) }) } } }webpack-dev-server 4 之后before/after被合并成了setupMiddlewaresmodule.exports { devServer: { setupMiddlewares(middlewares, devServer) { devServer.app.get(/api/user/list, (req, res) { res.json({ code: 0, data: [] }) }) return middlewares } } }这两种写法的区别坑过不少人升级 dev-server 大版本后before静默失效——不报错就是 mock 不生效你会怀疑人生。所以升级构建工具时第一件事就是把 mock 相关的钩子跑一遍。这种方案的优点是不依赖任何运行时代码mock 数据完全没有进包的风险。缺点是它只存在于 dev server一旦你把请求代理到真实后端域名本地中间件就不起作用了——因为它的作用域是本地的/api不是被代理走的那个域。4.2 Vite 插件里用 configureServer 挂中间件Vite 的做法更现代一些写个内联插件就行// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue import { createMockMiddleware } from ./mock/server export default defineConfig({ plugins: [ vue(), { name: vite-plugin-local-mock, configureServer(server) { server.middlewares.use(createMockMiddleware()) } } ] })中间件的实现可以按文件路径自动匹配省得一条条声明// mock/server.js import fs from node:fs import path from node:path import { fileURLToPath } from node:url const __dirname path.dirname(fileURLToPath(import.meta.url)) export function createMockMiddleware() { return function mockMiddleware(req, res, next) { if (!req.url || !req.url.startsWith(/api)) return next() const key req.url.split(?)[0].replace(/^\/api\//, ) const file path.resolve(__dirname, ${key}${req.method GET ? .json : .post.json}) if (!fs.existsSync(file)) return next() res.setHeader(Content-Type, application/json; charsetutf-8) setTimeout(() { res.end(fs.readFileSync(file, utf-8)) }, 300) } }文件目录结构就是约定的接口地图/api/user/list对应mock/user/list.jsonmock/ user/ list.json detail.json order/ list.json这套做法在团队里推广特别顺因为后端同学也能看懂——他们照着这个目录结构去写 mock 文件比让他们学一套 JS 语法容易多了。过期字段、新增字段直接改 JSON。提示中间件里一定要写未命中就next()。有一次我漏了这行所有/api开头的请求都被 mock 中间件吞掉了登录接口拿到的是空响应排查了半小时才反应过来。5. MSW用 Service Worker 把 Mock 伪装成真接口5.1 Service Worker 拦截的原理和它为什么更接近真实MSWMock Service Worker是目前我认为最优雅的方案原因在于它的拦截位置——不在框架层不在构建层而在浏览器的 Service Worker 层。Service Worker 是浏览器提供的独立线程能拦截页面发出的所有 fetch/XHR 请求。MSW 注册一个 Service Worker在里面根据你定义的路由规则决定命中就返回 mock 响应没命中就放行到真实网络。这个位置意味着几件事。第一你的业务代码一行都不用改fetch(/api/user/list)该怎么写还怎么写它甚至感知不到 mock 的存在。第二它能覆盖所有请求方式包括 XMLHttpRequest、fetch、以及基于它们的各种库axios、umi-request 都行。第三它能在浏览器真实环境里跑包括调试工具 Network 面板里能看到请求记录你能像观察真接口一样观察 mock 请求的耗时和响应。还有一点很多人忽略MSW 的 handler 是纯函数可以同时用在浏览器Service Worker和 Node 环境setupServer里。这意味着你可以用同一套 mock 规则既支撑本地开发又支撑 Jest/Vitest 的单元测试不用维护两份数据。5.2 从零配好 MSW 的关键几步第一步安装并生成 Service Worker 文件npm install msw --save-dev npx msw init public/ --savenpx msw init会在你指定的目录生成mockServiceWorker.js这个文件必须能被浏览器通过 HTTP 访问到通常放在public/或者 Vite 的public目录。这一步忘了做后面会一直报 404页面请求全部失败。第二步定义 handler// src/mocks/handlers.js import { http, HttpResponse } from msw const allUsers Array.from({ length: 128 }, (_, i) ({ id: i 1, name: 用户${i 1}, email: user${i 1}example.com, status: i % 7 0 ? disabled : active })) export const handlers [ http.get(/api/user/list, ({ request }) { 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 filtered keyword ? allUsers.filter((u) u.name.includes(keyword)) : allUsers return HttpResponse.json({ code: 0, data: { list: filtered.slice((page - 1) * size, page * size), total: filtered.length } }) }), http.get(/api/user/detail, ({ request }) { const url new URL(request.url) if (url.searchParams.get(id) 404) { return HttpResponse.json({ code: 40401, message: 用户不存在 }, { status: 404 }) } return HttpResponse.json({ code: 0, data: allUsers[0] }) }), http.post(/api/user/create, async ({ request }) { const body await request.json() if (!body.name) { return HttpResponse.json({ code: 40001, message: 姓名必填 }, { status: 400 }) } return HttpResponse.json({ code: 0, data: { id: 999, ...body } }) }) ]第三步启动 worker// src/mocks/browser.js import { setupWorker } from msw/browser import { handlers } from ./handlers export const worker setupWorker(...handlers)第四步在应用挂载前异步启动// src/main.js import { createApp } from vue import App from ./App.vue 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).mount(#app) } bootstrap()onUnhandledRequest: bypass这句很关键。默认配置下MSW 会对未命中的请求打印警告如果配上error甚至直接报错。开发阶段你肯定会有大量请求不归 mock 管比如静态资源、第三方统计全被警告刷屏就没法看了设成bypass安静放行。5.3 mock 不生效最常见的四个原因MSW 配置起来不难但新手十有八九会在同一个坑里卡住。我把遇到过的几种情况整理一下。现象根因处理方式所有请求都 404 或直接失败mockServiceWorker.js路径不对Service Worker 注册失败确认文件在静态目录下能被直接访问检查serviceWorker.url配置部分请求被 mock部分走空handler 里的路径和实际请求路径不完全一致打印实际request.url对比注意 baseURL 前缀是否被包含首次刷新页面 mock 失效二次刷新正常Service Worker 需要一次注册后才能接管页面启动逻辑放在await worker.start()之后再 mount不要并行生产环境误开 mock环境判断只用了import.meta.env.DEV但构建配置里 DEV 被改写用显式的VITE_USE_MOCK变量控制并在 CI 里加检查最后一条我要多说一句。有三类现象会让人误以为mock 不生效一是浏览器缓存Service Worker 有自己的更新机制改了 handler 记得硬刷新二是多标签页Service Worker 是页面级注册的新开的标签页需要重新注册三是代理冲突如果你同时配了 dev server 的 proxy 和 MSW请求可能先被 proxy 转发走了MSW 根本没机会拦。排查的时候第一件事永远是打开 Network 面板看请求的发起方和响应来源而不是猜。6. 独立 Mock 服务与本地映射跨语言、跨团队的兜底方案6.1 json-server 与 Express 自建假接口前面几种方案都依赖前端项目本身的构建环境。但有些场景不适用比如你要给移动端、桌面端、甚至外部合作方提供一份统一的假接口或者项目本身就是多端共用一套 API。这时候起一个独立的 mock 服务更合适。json-server 是最省事的npm install -g json-server json-server --watch db.json --port 3001 --delay 500db.json里写数据它自动给你生成增删改查全套接口{ users: [ { id: 1, name: 张三, role: admin }, { id: 2, name: 李四, role: member } ], orders: [ { id: 1, userId: 1, amount: 128.5, status: paid } ] }启动后/users?roleadmin_page1_limit10直接可用分页、过滤、排序都是现成的。--delay 500是模拟网络延迟这个参数很多人不知道但它特别有用——没延迟的接口会让你写出同步思维的前端代码等接了真接口才发现 loading 状态写漏了。需要更复杂的逻辑比如按 token 返回不同数据、动态计算字段时换成 Express// mock-server/index.js import express from express import cors from cors const app express() app.use(cors()) app.use(express.json()) app.get(/api/user/list, (req, res) { const { page 1, size 10 } req.query console.log([MOCK] 请求列表, { page, size }) res.json({ code: 0, data: { list: [], total: 0 } }) }) app.listen(3001, () { console.log(mock server 已启动http://localhost:3001) })加一个请求日志中间件特别值得。[MOCK] 请求列表这行日志能帮你在联调时快速判断到底是页面没发请求还是请求发了但参数不对。我见过太多次页面没数据最后查出来是参数名写成了pageSize而接口要size。6.2 抓包工具的本地映射与接口管理平台还有一种几乎不需要写代码的方式是用抓包代理工具的本地映射功能。原理是在工具里配置一条规则当请求匹配到某个 URL 时不去真实服务器而是返回本地指定文件的响应。这种方式的好处是完全不侵入项目适合临时救急——比如你只是想验证某个接口返回不同数据时页面的表现改一下本地 JSON 文件刷新页面就行。但它有几个典型问题匹配规则太严。很多工具的匹配是完整 URL 匹配一旦带查询参数、或者协议从 http 换成 https规则就失效了。这时候要么改成通配符匹配要么把参数部分也写进规则。响应头不对。本地文件默认返回的 Content-Type 可能是text/plain或者带 BOM前端拿到后 JSON 解析就崩了报的是Unexpected token其实问题在响应头。只在本机生效。同事拿不到你的规则团队协作时每个人都要配一遍。如果团队规模大一些接口管理平台会更合适。这类工具YApi、Apifox、EasyMock 这类的核心逻辑是接口文档和 mock 数据是同一份东西。后端写文档的时候顺手填个返回值示例前端立刻就能通过一个 mock 域名拿到数据还能配置延迟、生成随机数据、按规则返回不同状态码。它解决的是契约对齐这个痛点——前后端看的是同一份定义不存在我以为是这个字段的情况。代价是需要维护。接口文档一旦没人更新mock 数据就变成了假数据的源头比没有 mock 更危险。所以用平台的前提是团队有纪律或者有人负责review。7. 几种方案横向对比与选型思路把上面的方案拉平了看方案拦截位置侵入性切换成本能走错误链路适合场景静态 JSON 常量无高改组件高否原型、单测、静态页Mock.js 造数无只造数低低否需要大量随机数据的列表页axios adapter请求库层中低是单项目、请求库统一的项目devServer 中间件本地服务层无低是webpack/Vite 项目本地开发MSWService Worker无低是需要真实网络行为、要复用到单测独立 mock 服务独立进程无中是多端共用、跨团队协作抓包工具本地映射代理层无高是临时验证、不改代码接口管理平台远程服务无低是中大型团队、契约驱动开发选型的思路其实很简单按三个问题问自己就行。第一个问题这套 mock 要不要跟着项目走。如果只是临时看一眼效果抓包工具最快。如果是持续几个月的项目就得选能进代码仓库的方案否则换个电脑就没了。第二个问题错误分支要不要能被验证。如果答案是要那静态常量和纯造数方案就不够了必须选能走完整请求链路的方案——axios adapter、devServer 中间件、MSW 都可以。这一点我在前面强调过但真的要亲自踩过一次空数据白屏的坑才会记住。第三个问题是谁在维护这份 mock。如果只有前端自己用MSW 或者 devServer 中间件都行。如果需要后端也参与那就选目录约定式的方案JSON 文件按路径映射或者直接上接口管理平台。我自己的组合是这样的日常开发用 MSW因为它零侵入、能复用、错误分支真实需要给外部提供接口时起一个 json-server 或者简单的 Express 服务做组件单测时直接用 MSW 的 Node 版本setupServer和浏览器共用一套 handler。这套组合用了两年没出过什么大问题。8. 让 Mock 不返工的工程细节8.1 把类型定义当成 mock 的一部分返工最大的来源不是技术方案是字段对不上。你 mock 里写了userName后端返回username联调那天你要改所有用到这个字段的组件。解决方法很简单mock 数据和类型声明放在一起并且让类型从 mock 推导或者反过来。我的做法是先定类型再按类型造数据// src/types/user.ts export interface UserItem { id: number name: string email: string status: active | disabled | pending createTime: string } export interface ListResultT { list: T[] total: number page: number size: number }// src/mocks/factory/user.ts import type { UserItem, ListResult } from /types/user export function buildUserList(total: number, page 1, size 10): ListResultUserItem { const start (page - 1) * size const list: UserItem[] Array.from({ length: Math.min(size, total - start) }, (_, i) ({ id: start i 1, name: 用户${start i 1}, email: user${start i 1}example.com, status: ([active, disabled, pending] as const)[i % 3], createTime: new Date(Date.now() - i * 86400000).toISOString() })) return { list, total, page, size } }这样做的好处是类型改了mock 工厂会立刻编译报错你就被迫同步更新。而不是等到联调那天才发现字段名对不上。提示status这种枚举字段mock 数据一定要覆盖所有枚举值。我见过太多次因为 mock 里只有active一种状态导致其他状态标签的颜色分支根本没被写出来。8.2 延迟、错误码和分页这些细节别省我说过mock 的价值有一半在模拟真实。具体要模拟哪些东西我列一份清单。延迟。给每个接口配一个 200 到 800 毫秒的随机延迟。固定延迟会让你习惯某种节奏随机延迟才能暴露出竞态问题——比如快速切换 tab 时慢的那个请求返回后把快的那个结果覆盖了。错误码。至少覆盖400参数错误、401未登录、403无权限、500服务异常。可以在 mock 里加一个错误率配置比如 10% 的请求随机返回 500这样你在开发时就能顺便验证错误兜底页。分页边界。第一页、最后一页、超出范围的页码、total为 0、size特别大这几种都要试。尤其是最后一页很多分页组件在数据条数不是 size 整数倍时会出问题。空状态与超长内容。空数组要能触发空状态图超长文本要能触发省略号或者换行。这两个是最容易被忽略、也最容易在线上出洋相的。8.3 上线前怎么确认 mock 没被打进包里这是个必须检查的事。想象一下线上用户看到的是你造的用户1、用户2那场面不好收场。检查方式有三层。第一层是打包产物检查在dist目录里搜一下 mock 相关的关键字比如你在 mock 里写的某个特殊字符串npm run build grep -r 用户1 dist/ echo 警告mock 数据进了产物 || echo 产物干净第二层是代码层面隔离把 mock 入口写成动态 import 环境变量双保险if (import.meta.env.DEV import.meta.env.VITE_USE_MOCK true) { const { worker } await import(./mocks/browser) await worker.start() }import.meta.env.DEV在生产构建时会被替换成false构建工具就能把整个分支摇掉。动态 import 又保证了mocks目录不会被静态打进主包。第三层是CI 检查。在流水线里加一条命令产物里出现 mock 特征字符串就直接失败。这条最靠得住因为它不依赖人记性。8.4 排查 mock 不生效的完整链路最后把排查思路整理成一条可以照着走的链路。遇到请求没被 mock的时候不要瞎猜按顺序来。第一步看 Network 面板。请求发出去了吗状态码是多少响应体是空还是有内容这一步能区分三种情况请求根本没发前端代码问题、请求发了但走的是真实服务拦截没生效、请求被拦了但返回不对handler 逻辑问题。第二步确认拦截层是否真的启动。用 MSW 的话控制台会打印注册成功的信息Service Worker 面板里能看到它是否 active。用 devServer 中间件的话在中间件里加一行console.log([MOCK] hit, req.url)看终端有没有输出。第三步确认匹配规则。打印实际请求的完整 URL 和你的规则做对比。90% 的不生效都是路径对不上baseURL 是多了一层/api或者查询参数在匹配时没被剥掉或者大小写不一致。第四步确认加载顺序。如果 mock 的初始化是异步的MSW 就是这样而你的应用在它完成之前就发了请求第一次请求肯定拦不住。解决办法是把 mount 放到await worker.start()之后。第五步清缓存重试。Service Worker 有独立的缓存和更新机制浏览器缓存也可能命中旧响应。硬刷新清空缓存并重新加载走一遍很多时候问题就消失了。这套顺序的核心逻辑是从外到内、从现象到原因而不是一上来就改代码。我在团队里带新人时反复强调这一点先确认现象再定位层级最后才动代码。反过来做你会改一堆本来没问题的东西最后还找不到原因。最后分享一个我自己一直在用的小技巧。给每个 mock handler 加上请求日志记录 URL、参数、耗时并且在页面上做一个mock 面板只在开发环境显示列出当前生效的 mock 路由。这个东西做起来不到一百行代码但它能省掉大量这个接口到底 mock 了没有的沟通成本——产品来问的时候你直接把面板截图发过去就行。