Mock Server 中间件

Mock Server 中间件

概述

mock.js 是一套供本地Server Mock 的中间件,基于目录结构自动发现 mock 接口文件,集成在 vue.config.jsonBeforeSetupMiddleware 钩子中,在请求到达 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,所有中间件立即生效。
  • proxyPathsdevServer.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 无法直接解析压缩数据

相关推荐
MacroZheng12 小时前
平替Cursor!Claude Code + VSCode = 王炸!
前端·vue.js·人工智能
猩球中的木子12 小时前
什么是DNS解析
前端·vue.js·面试
米丘12 小时前
Vue3 渲染模式全解析:CSR、预渲染、SSG、SSR 如何选择?
vue.js
小救星小杜、13 小时前
new Router base的作用
前端·javascript·vue.js
ct97813 小时前
Object.defineProperty/Proxy与 vue2 + vue3 响应式原理
前端·javascript·vue.js
存在的五月雨13 小时前
Vue中的nextTick
javascript·vue.js·ecmascript
肉肉不吃 肉13 小时前
watch中为什么不能直接侦听响应式对象的属性
前端·javascript·vue.js
喵了几个咪13 小时前
吃透后台权限系统:从架构设计到 Vue3/React 双框架完整落地
前端·vue.js·react.js·权限系统
喵了几个咪13 小时前
统一范式:中后台Admin项目标准化API分层开发方案(Vue/React通用)
前端·vue.js·react.js·protobuf