1. 为什么前端总要自己造一份“假数据”刚入行那会儿我最怕听到的一句话就是“接口还没好你先用假数据顶一下”。那时候我的做法特别原始在组件里写一个const list [{...}, {...}]然后直接渲染。页面确实能跑起来但只要产品经理说“这个字段改个名字”“列表再加个分页”我就得满项目文件里搜那个数组改完还得担心漏了哪一处。更麻烦的是这种硬编码的数据和真实接口的返回结构往往对不上等后端接口真上线了联调阶段又是一轮大改。后来我开始用Mock.js配合Express搭一个本地模拟服务才算真正把这件事理顺了。简单说这套组合解决的核心问题是让前端在接口就绪之前就能拿到结构真实、可随机生成、能模拟分页和延迟的响应数据。Mock.js 负责按规则生成数据Express 负责把这些数据通过 HTTP 接口暴露出去前端代码里写的请求地址和真实环境几乎一致切换时只需要改一个 baseURL。这套方案适合谁我觉得三类人最需要一是独立开发或小团队里没有专职后端的前端二是做原型演示、需要快速出效果的人三是想系统学习接口联调、理解前后端数据契约的初学者。它不需要你懂数据库也不需要部署服务器一台装了 Node.js 的电脑就能跑起来。下面我把自己踩过的坑、调过的参数、以及最终稳定下来的做法完整地拆开讲一遍。2. Mock.js 和 Express 各自扮演什么角色很多人一开始会混淆这两个东西的职责觉得“既然 Mock.js 能拦截请求那还要 Express 干嘛”。我最初也这么想过结果在真实项目里碰了壁。这里必须先把分工讲清楚不然后面的配置很容易做错。2.1 Mock.js 的核心能力是“按规则造数据”不是“当服务器”Mock.js 最值钱的地方在于它的数据模板语法。比如你想生成一个用户列表每个用户有姓名、年龄、邮箱、注册时间用 Mock.js 可以这样写const Mock require(mockjs) const user Mock.mock({ list|10: [{ id|1: 1, name: cname, age|18-60: 1, email: email, registerTime: datetime }] })这段代码里list|10表示生成 10 条数据id|1表示 id 从 1 开始递增cname是中文姓名占位符email是邮箱占位符。这些占位符是 Mock.js 内置的覆盖了常见的姓名、地址、日期、图片、文本等类型。它的价值在于你不需要手写几十条假数据只需要描述数据的“形状”和“规则”它就能批量生成。但 Mock.js 本身不是一个 HTTP 服务器。它早期确实有拦截 XHR 的能力可以在浏览器里直接劫持请求但那种方式有几个硬伤一是只能在浏览器环境用二是对 fetch 和 axios 的拦截行为不一致三是没法模拟真实的网络延迟和状态码。所以我现在基本不用它的拦截功能只把它当成一个“数据生成器”。2.2 Express 负责把生成的数据变成真正的 HTTP 接口Express 是一个轻量的 Node.js Web 框架几行代码就能起一个服务。它的作用是把 Mock.js 生成的数据挂到具体的路由上比如/api/user/list、/api/order/detail并且可以控制返回的格式、状态码、响应头。const express require(express) const Mock require(mockjs) const app express() app.get(/api/user/list, (req, res) { const data Mock.mock({ list|10: [{ id|1: 1, name: cname, age|18-60: 1 }] }) res.json({ code: 200, message: success, data: data.list }) }) app.listen(3000, () { console.log(mock server running at http://localhost:3000) })这样前端用 axios 请求http://localhost:3000/api/user/list拿到的就是一份结构完整的 JSON。关键区别在于Mock.js 决定“数据长什么样”Express 决定“数据从哪个地址、以什么方式返回”。两者配合才能模拟出接近真实的接口行为。2.3 为什么不用纯前端拦截方案我早期用过纯前端拦截就是在入口文件里引入 Mock.js让它劫持所有/api开头的请求。这种方式在简单页面里确实方便但一旦项目变大就出问题一是拦截规则和业务代码混在一起很难维护二是当你想临时切到真实接口时需要改代码而不是改配置三是它无法模拟跨域、Cookie、状态码这些真实场景。用 Express 单独起一个服务前端代码里完全感知不到“这是假数据”切换时只改环境变量里的 baseURL干净利落。3. 从零搭一个能用的模拟服务这一节我把完整的搭建过程拆成几步每一步都说明为什么这么做。你跟着走一遍大概二十分钟能跑起来。3.1 初始化项目与依赖安装先建一个空目录执行npm init -y生成 package.json然后安装两个核心依赖npm install express mockjs如果你想让服务在文件改动后自动重启可以再加一个开发依赖npm install nodemon --save-dev然后在 package.json 的 scripts 里加一行mock: nodemon server.js。这样每次改完模拟数据服务会自动重启省去手动 CtrlC 再启动的麻烦。这个细节看起来小但实际开发中能省不少时间。3.2 目录结构怎么划分才不乱我见过很多人把所有路由和 Mock 规则写在一个 server.js 里刚开始几十行还行写到几百行就完全没法看了。我建议按下面的结构组织mock-server/ ├── server.js // 入口注册路由和中间件 ├── routes/ │ ├── user.js // 用户相关接口 │ ├── order.js // 订单相关接口 │ └── index.js // 路由汇总 ├── mock/ │ ├── userMock.js // 用户数据的 Mock 规则 │ └── orderMock.js // 订单数据的 Mock 规则 └── utils/ └── response.js // 统一响应格式封装这样划分的好处是路由负责“地址和参数处理”mock 目录负责“数据规则”utils 负责“统一格式”。三者职责分离改数据不用动路由加接口不用翻旧代码。我实际用下来即使接口数量涨到五六十个维护起来也不吃力。3.3 统一响应格式的封装真实项目里后端返回的 JSON 通常有固定结构比如{ code, message, data }。如果每个路由都手写一遍既啰嗦又容易写错。我在 utils/response.js 里封装一个函数function success(data, message success) { return { code: 200, message, data } } function fail(code 500, message error) { return { code, message, data: null } } module.exports { success, fail }路由里就可以这样用const { success } require(../utils/response) router.get(/list, (req, res) { const data Mock.mock({ list|10: [{ id|1: 1, name: cname }] }) res.json(success(data.list)) })提示统一格式这件事一定要在项目初期就定下来。我踩过的坑是前期随便返回数组后期想加 code 和 message 时前端所有取值逻辑都要改非常痛苦。3.4 处理跨域和请求日志前端项目通常跑在另一个端口比如 8080而模拟服务在 3000这就涉及跨域。Express 里最简单的处理方式是手动加一个中间件app.use((req, res, next) { res.header(Access-Control-Allow-Origin, *) res.header(Access-Control-Allow-Headers, Content-Type, Authorization) res.header(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS) if (req.method OPTIONS) { return res.sendStatus(200) } next() })同时我习惯加一个请求日志中间件把每次请求的方法、路径、参数打印出来app.use((req, res, next) { console.log([${new Date().toLocaleTimeString()}] ${req.method} ${req.url}) next() })这个日志在排查“前端说请求没发出去”这类问题时特别有用。很多时候前端以为接口没通其实是参数传错了或者路径写错了日志一看就清楚。4. 让假数据“像真的”Mock 规则进阶写法数据能返回只是第一步真正让模拟服务有价值的是数据要“像真的”。如果返回的列表永远是那十条一模一样的记录前端做分页、搜索、排序时根本测不出问题。这一节讲几个我常用的进阶技巧。4.1 用占位符生成贴近真实的字段Mock.js 内置了大量占位符我整理了几个高频使用的占位符生成内容典型用途cname中文姓名用户列表email邮箱地址账号信息datetime日期时间创建时间integer(1, 100)指定范围整数数量、年龄float(0, 1000, 2, 2)两位小数金额image(200x100)图片地址商品图paragraph一段文本描述、备注boolean布尔值状态开关比如做一个订单列表我会这样写const orderMock Mock.mock({ list|20: [{ orderId|1: 10000, userName: cname, amount: float(10, 5000, 2, 2), status|1: [待付款, 已付款, 已发货, 已完成], createTime: datetime, remark: paragraph(1, 3) }] })这里status|1表示从数组里随机取一个值paragraph(1, 3)表示生成 1 到 3 句话。这样每次请求返回的数据都不一样前端做筛选和状态展示时能覆盖到各种情况。4.2 分页、搜索、排序的参数处理真实接口通常支持page、pageSize、keyword这些查询参数。模拟服务也应该支持否则前端没法测分页逻辑。我的做法是在路由里读取req.query然后对 Mock 生成的全量数据做切片router.get(/list, (req, res) { const { page 1, pageSize 10, keyword } req.query const allData Mock.mock({ list|55: [{ id|1: 1, name: cname, title: ctitle(5, 15) }] }).list let filtered allData if (keyword) { filtered allData.filter(item item.title.includes(keyword)) } const start (page - 1) * pageSize const end start Number(pageSize) const pageData filtered.slice(start, end) res.json(success({ list: pageData, total: filtered.length, page: Number(page), pageSize: Number(pageSize) })) })这里有个细节要注意req.query里的值默认是字符串做数学运算前必须用Number()转换否则page - 1会变成字符串拼接。我第一次写的时候就因为这个page2算出来的 start 是2 - 1 1还好但page10时10 - 1 9也对直到pageSize参与乘法才暴露问题。所以养成习惯查询参数一律先转数字。4.3 模拟网络延迟和异常状态真实接口不会瞬间返回也不会永远成功。模拟服务如果总是 0 毫秒返回 200前端写的 loading 状态和错误处理根本测不到。我通常加一个延迟中间件app.use((req, res, next) { const delay Math.random() * 800 200 setTimeout(next, delay) })这样每次请求会有 200 到 1000 毫秒的随机延迟前端的 loading 动画就能正常展示。对于异常状态我习惯在特定路由里根据参数返回不同状态码router.get(/detail, (req, res) { const { id } req.query if (id 999) { return res.status(404).json(fail(404, 资源不存在)) } if (id 500) { return res.status(500).json(fail(500, 服务器内部错误)) } const data Mock.mock({ id: id, name: cname }) res.json(success(data)) })这样前端只要传特定的 id就能触发 404 和 500用来验证错误提示和兜底逻辑。这个技巧在联调前自测时特别省事不用等后端真的出错。5. 踩过的坑和排查思路这套方案我用了好几年中间踩的坑不算少。挑几个最有代表性的讲一下都是那种“不知道就卡半天知道了就一句话”的问题。5.1 路由顺序导致的 404Express 的路由是按注册顺序匹配的。我有一次写了一个通配路由app.use(*, ...)放在前面做日志结果所有请求都被它截住了后面的业务路由一个都没生效。排查的时候看日志发现请求进来了但返回的是通配路由的内容才意识到顺序问题。正确做法是把通配、错误处理这类中间件放在所有业务路由之后。5.2 POST 请求体拿不到Express 默认不解析请求体POST 请求里req.body是 undefined。需要在路由之前加上app.use(express.json()) app.use(express.urlencoded({ extended: true }))这两个中间件分别处理 JSON 格式和表单格式的请求体。我一开始只加了 json结果前端用表单提交时又拿不到数据查了半天才发现少了一个。现在我的习惯是两行都写上省心。5.3 Mock 规则写错导致数据为空Mock.js 的模板语法很灵活但写错了不会报错只会返回空或者不符合预期的数据。比如list|10和list|10-20含义不同前者固定 10 条后者随机 10 到 20 条。我有一次把id|1: 1写成了id: 1结果所有记录的 id 都是 1前端做 key 的时候全部重复列表渲染出问题。排查这类问题的办法是先在 Node 环境里单独跑一遍 Mock 规则把结果打印出来看确认数据形状对了再挂到路由上。5.4 前端切换真实接口时的路径问题模拟服务的接口路径最好和真实接口保持一致比如都用/api/user/list。这样切换时只需要改 baseURL不用改每个请求的路径。我见过有的项目模拟服务用/mock/user/list真实接口用/api/user/list结果切换时所有请求路径都要改非常容易漏。从第一天起就让模拟路径和真实路径对齐这是最省事的做法。6. 怎么把它接进真实的前端工程模拟服务搭好了接下来要让它和前端项目配合起来。这一节讲几种常见的接入方式以及我推荐的做法。6.1 通过环境变量控制 baseURL前端项目通常有开发、测试、生产几套环境。我习惯在环境配置里加一个VUE_APP_BASE_API或VITE_API_BASE开发环境指向模拟服务地址其他环境指向真实网关// .env.development VITE_API_BASEhttp://localhost:3000/api // .env.production VITE_API_BASEhttps://real-api.example.com/apiaxios 实例里读取这个变量作为 baseURL。这样切换环境只需要改配置文件代码一行不动。这个做法看起来简单但它是整套方案能“无缝切换”的关键。6.2 用 npm scripts 一键启动我习惯在 package.json 里配置一个组合命令同时启动前端和模拟服务{ scripts: { dev: vite, mock: nodemon mock-server/server.js, start: concurrently \npm run mock\ \npm run dev\ } }concurrently是一个可以并行跑多个命令的工具装一下就行。这样开发者只需要执行npm start前端和模拟服务一起起来不用开两个终端。团队协作时这个细节能减少很多“忘了启动模拟服务”的沟通成本。6.3 接口文档和 Mock 规则同步维护模拟服务最大的风险是“和后端真实接口脱节”。我踩过的坑是模拟服务返回的字段叫userName后端真实接口叫username联调时前端一片报错。后来我养成了一个习惯每次后端更新接口文档我第一时间同步修改 Mock 规则并且在 mock 文件顶部用注释标注对应的接口文档版本和更新日期。这样即使隔了几周再回头看也知道这份规则是基于哪个版本写的。7. 一些让效率翻倍的小技巧最后分享几个我在长期使用中攒下来的小技巧都是那种“用了就回不去”的。第一个是把常用的 Mock 片段抽成函数。比如分页列表、树形结构、详情对象这些在多个接口里反复出现。我建了一个mock/templates.js把通用规则封装成函数路由里直接调用改一处全局生效。第二个是给模拟服务加一个“数据重置”接口。有时候前端测试需要一份干净的数据我加一个POST /api/reset调用后重新生成全量数据。配合前端的一个调试按钮测试起来很方便。第三个是用Mock.Random自定义占位符。Mock.js 允许你扩展自己的占位符比如生成符合公司编码规则的订单号Mock.Random.extend({ orderNo() { return ORD Mock.Random.date(yyyyMMdd) Mock.Random.string(number, 6) } })之后就可以在模板里用orderNo了。这个能力在需要生成特定格式数据时特别有用。第四个是把模拟服务部署到内网。如果团队里前端不止一个人可以把模拟服务跑在一台内网机器上大家共用一份接口。这样避免了每个人本地数据不一致的问题产品经理演示时也能直接访问。部署方式和普通 Node 服务一样用 pm2 守护进程就行。这套 Mock.js 加 Express 的方案我从最初的硬编码数组一路演进过来中间换过几种思路最终稳定在这个组合上。它的好处是足够轻、足够灵活、学习成本低同时又能模拟出接近真实的接口行为。对于前端来说能在接口就绪前就把页面逻辑、异常处理、加载状态全部调通等真实接口上线时基本只需要改一个地址这种顺畅感是硬编码假数据给不了的。