Mock Server 中间件
概述
mock.js 是一套供本地Server Mock 的中间件,基于目录结构自动发现 mock 接口文件,集成在 vue.config.js 的 onBeforeSetupMiddleware 钩子中,在请求到达 proxy 之前拦截并返回本地 mock 数据。
核心特性:
| 特性 | 说明 |
|---|---|
| 目录即路由 | mock 文件的目录层级 = 接口路径层级,无需手动注册路由 |
| 空文件自动抓取 | 创建 0 字节的 .json 文件,首次请求自动转发真实代理并写入响应 |
| 文件监听热更新 | mock/ 目录增删改自动重新收集,无需重启 dev server |
| 按需启用 | 环境变量 VUE_APP_MOCK=true 控制,未设置时所有请求走代理 |
启用方式
在 package.json 的 scripts 中添加:
bash
# 直接命令行
VUE_APP_MOCK=true vue-cli-service serve
# 或在 package.json scripts 中
"serve:mock": "VUE_APP_MOCK=true vue-cli-service serve"
可选:IS_DEBUG_LOG=true 开启每条请求的路径日志。
vue.config.js 接入示例:
javascript
const { setupMock } = require("../mock");
module.exports = {
devServer: {
proxy: {
"/api": {
target: "http://your-test-server.example.com",
changeOrigin: true,
},
},
onBeforeSetupMiddleware(devServer) {
setupMock(devServer, __dirname, ["/api"]);
},
},
};
Mock 文件约定
在 vue.config.js同级 的 mock/ 目录下,按接口路径创建 .json 文件:
bash
mock/
└── api/
└── xxx/
└── yyy/
└── zzz.json ← 对应 POST /api/xxx/yyy/zzz
规则:
- 文件路径(去掉
mock/前缀和.json后缀)= 接口完整路径 - 请求方法不限(GET/POST 均匹配同一文件)
- 返回数据自动注入
"mock": true标记字段 - 找不到对应
.json文件的请求,自动 fallback 到devServer.proxy代理
快速使用指南
新增一个 mock 接口
bash
# 1. 创建对应路径的 .json 文件(mock 接口:POST /api/xxx/yyy/zzz)
mkdir -p mock/api/xxx/yyy
echo '{"code":0,"data":{"name":"test"}}' > mock/api/xxx/yyy/zzz.json
# 2. 启动 mock 模式
VUE_APP_MOCK=true npm run serve
自动抓取真实数据
bash
# 创建一个空的 .json 文件
touch mock/api/xxx/yyy/zzz.json
# 启动后首次请求该接口,会自动抓取真实响应并写入文件
# 重新抓取:清空文件内容(变为 0 字节)即可
临时关闭某个接口的 mock
删除或者改名对应的 .json 文件,该接口自动 fallback 到真实代理(热更新生效,无需重启)。
整体流程图
graph TD
A["请求 /api/xxx/yyy/zzz"] --> B["setupMock 注册的中间件"]
B --> C{"mockFilesRef 中有对应文件?"}
C -- 否 --> D["next() → devServer.proxy 正常代理"]
C -- 是 --> E{"文件大小"}
E -- "有内容" --> F["读取 .json + 注入 mock:true"]
F --> G["res.json() 返回 mock 数据"]
E -- "0字节(空文件)" --> H["forwardToProxy()"]
H --> I["转发到真实后端"]
I --> J["写入 .json 文件"]
J --> K["res.json() 返回真实数据"]
核心函数拆解
1. setupMock --- 入口函数
职责: 判断是否启用、初始化 mock 文件映射、注册文件监听、按路径挂载中间件。
javascript
function setupMock(devServer, appRoot, proxyPaths) {
if (process.env.VUE_APP_MOCK !== "true") {
console.log("[Mock] Disabled");
return;
}
console.log(`[Mock] Enabled ,IS_DEBUG=${IS_DEBUG} `);
const mockDir = path.resolve(appRoot, "./mock");
// 收集 mock 文件(所有中间件共享,用对象引用实现共享状态)
const mockFilesRef = {current: collectMockFiles(mockDir, mockDir)};
console.log("[Mock] collect mock apis: ", Object.keys(mockFilesRef.current));
...
}
关键设计点:
mockFilesRef用对象引用而非直接变量 :让所有中间件实例和文件监听回调共享同一份映射,热更新时只需修改mockFilesRef.current,所有中间件立即生效。proxyPaths与devServer.proxy顺序一致 :按索引从devServer.options.proxy中提取对应的target,确保 mock 请求转发到正确的后端服务。
javascript
// 监听 mock 目录变化,自动重新收集文件
fs.watch(mockDir, { recursive: true }, (eventType, filename) => {
if (filename && filename.endsWith(".json")) {
console.log(`[Mock] File changed: ${filename}, re-collecting...`);
mockFilesRef.current = collectMockFiles(mockDir, mockDir);
}
});
// 从 devServer.options.proxy 中按顺序提取 target,逐个注册中间件
const proxyConfigs = Array.isArray(proxy) ? proxy : Object.values(proxy);
for (let i = 0; i < proxyPaths.length; i++) {
const proxyTarget = typeof config === "string" ? config : config?.target;
devServer.app.use(proxyPath, createMockMiddleware(mockDir, proxyTarget, mockFilesRef));
}
2. collectMockFiles --- 目录扫描
职责: 递归遍历 mock/ 目录,建立"接口路径 → 文件绝对路径"的映射表。
javascript
function collectMockFiles(dir, rootDir) {
const files = {};
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
// 跳过 node_modules 和隐藏目录
if (item.name === "node_modules" || item.name.startsWith(".")) continue;
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
Object.assign(files, collectMockFiles(fullPath, rootDir));
} else if (item.isFile() && item.name.endsWith(".json")) {
const relativePath = "/" + path
.relative(rootDir, fullPath)
.replace(/\.json$/, "")
.replace(/\\/g, "/"); // 兼容 Windows 路径
files[relativePath] = fullPath;
}
}
return files;
}
输出示例:
javascript
// mock/api/xxx/yyy/zzz.json
{
"/api/xxx/yyy/zzz": "/{absolute_path}/mock/api/xxx/yyy/zzz.json"
}
设计细节:
- 用
withFileTypes: true避免额外stat调用,提升扫描性能 - 过滤
node_modules和.开头的隐藏目录(如.DS_Store所在目录) replace(/\\/g, "/")保证 Windows 路径也能正确映射
3. createMockMiddleware --- 请求拦截中间件
职责: 核心请求分发逻辑,判断 mock 文件是否存在、是否为空,决定返回 mock 数据还是转发代理。
javascript
function createMockMiddleware(mockDir, proxyTarget, mockFilesRef) {
if (!fs.existsSync(mockDir)) {
return (req, res, next) => next(); // mock 目录不存在,直接跳过
}
const recording = new Set(); // 防止并发请求同一空文件时重复抓取
return async (req, res, next) => {
const urlPath = (req.originalUrl || req.url).split("?")[0]; // 去掉 query string
const mockFile = mockFilesRef.current[urlPath];
if (!mockFile) { next(); return; } // 无对应 mock 文件,走 proxy
const stats = fs.statSync(mockFile);
if (stats.size === 0) {
// 空文件:自动抓取逻辑(见下方详解)
...
}
// 正常返回 mock 数据
const data = JSON.parse(fs.readFileSync(mockFile, "utf-8"));
data.mock = true; // 注入标记,前端可据此判断数据来源
res.json(data);
};
}
recording Set 的并发保护:
多个请求同时命中同一空文件时,只有第一个触发真实代理抓取,其余直接 next() 走代理,避免重复写入和请求风暴。
javascript
if (recording.has(urlPath)) { next(); return; } // 正在录制,跳过
recording.add(urlPath);
try {
const data = await forwardToProxy(req, proxyTarget);
fs.writeFileSync(mockFile, JSON.stringify(data, null, 2) + "\n");
res.json(data);
} catch (err) {
next(); // 抓取失败,走代理,下次重试
} finally {
recording.delete(urlPath); // 确保无论成功失败都清理
}
4. forwardToProxy --- 代理转发
职责: 将请求原样转发到真实后端,返回响应 JSON。是整个中间件中最复杂的部分。
javascript
function forwardToProxy(req, proxyTarget) {
return new Promise((resolve, reject) => {
const targetUrl = new URL(req.originalUrl || req.url, proxyTarget);
const isHttps = targetUrl.protocol === "https:";
const client = isHttps ? https : http;
const options = {
hostname: targetUrl.hostname,
port: targetUrl.port || (isHttps ? 443 : 80),
path: targetUrl.pathname + targetUrl.search,
method: req.method,
timeout: 30000,
headers: {...req.headers, host: targetUrl.hostname},
};
// 删除可能导致问题的 headers
delete options.headers["content-length"];
delete options.headers["transfer-encoding"];
delete options.headers["accept-encoding"]; // 避免后端返回 gzip 压缩数据
...
}
}
Body 处理的 3 种情况:
dev server 中 req.body 可能已被 body-parser 消费,也可能还没读,需要区分处理:
javascript
// 情况 1:body-parser 已解析,req.body 有数据
if (req.body && Object.keys(req.body).length > 0) {
const bodyData = JSON.stringify(req.body);
proxyReq.setHeader("Content-Type", "application/json");
proxyReq.setHeader("Content-Length", Buffer.byteLength(bodyData));
proxyReq.write(bodyData);
proxyReq.end();
// 情况 2:req 流已经结束,无法再读
} else if (req.readableEnded) {
proxyReq.end();
// 情况 3:从 req 流中读取原始 body
} else {
const bodyChunks = [];
req.on("data", (chunk) => bodyChunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(bodyChunks);
if (body.length > 0) {
proxyReq.setHeader("Content-Length", body.length);
proxyReq.write(body);
}
proxyReq.end();
});
}
});
}
Headers 清理的原因:
| Header | 删除原因 |
|---|---|
content-length |
body 序列化后长度会变,需重新计算 |
transfer-encoding |
代理请求使用普通传输,不用 chunked |
accept-encoding |
避免后端返回 gzip,JSON.parse 无法直接解析压缩数据 |