rsbuild mock 插件开发指南

rsbuild没有提供mock方案,要实现类似umi中的mock特性需要自行开发。不管是umi还是rsbuild或其他各种脚手架,mock特性都基于devServer的中间件,因此只要实现mock文件的watch、解析、devServer的拦截就可以实现该特性。

本文就以rsbuild为例,介绍如何开发mock插件。

mock文件格式

假设我们要支持的mock文件格式如下,key定义请求method和url,value为mock返回结果

js 复制代码
export default {
  "GET /api/user/:id": {
    code: "0",
    message: "success",
    data: { id: 1, name: "小明xxx" },
  },
  "GET /api/users": {
    code: "0",
    message: "success",
    data: [{ id: 1, name: "小明xxx" }],
  },
}

起步

这里首先要将mock文件加入到devServer的watch files,当mock文件更新后重启服务。

js 复制代码
import type { RsbuildPlugin } from "@rsbuild/core";
import { defineConfig } from "@rsbuild/core";
import { createMockMiddleware } from "./mock";
export const rsbuildMockPlugin = (): RsbuildPlugin => ({
  name: "plugin:mock",
  setup(api) {
    api.modifyRsbuildConfig((defaultConfig, utils) => {
      const config = defineConfig({
        dev: {
          watchFiles: [
            {
              paths: ["mock/**/*.[jt]s"],
              type: "reload-server",
            },
          ],
          setupMiddlewares: (middlewares) => {
            middlewares.unshift(createMockMiddleware());
          },
        },
      });
      return utils.mergeRsbuildConfig(defaultConfig, config);
    });
  },
});

核心:createMockMiddleware

getMockData------获取mock配置

js 复制代码
export async function getMockData(opts: {
  cwd: string;
  mockConfig: { exclude?: string[]; include?: string[] };
}): Promise<Record<string, IMock>> {
  function normalizeMockFile(file: string) {
    const cwd = opts.cwd.endsWith("/") ? opts.cwd : `${opts.cwd}/`;
    return chalk.yellow(file.replace(cwd, ""));
  }
  const MOCK_FILE_GLOB = "mock/**/*.[jt]s";
  const ret = Promise.all(
    [MOCK_FILE_GLOB, ...(opts.mockConfig.include || [])]
      .reduce<string[]>((memo, pattern) => {
        memo.push(
          ...globSync(pattern, {
            cwd: opts.cwd,
            ignore: ["**/*.d.ts", ...(opts.mockConfig.exclude || [])],
          }),
        );
        return memo;
      }, [])
      .map(async (file) => {
        const mockFile = path.join(opts.cwd, file);
        const m = await loadLatestEsmModule(mockFile).catch((e) => {
          throw new Error(
            `Mock file ${mockFile} parse failed.\n${(e as Error).message}`,
            { cause: e },
          );
        });
        return [m, mockFile];
      }),
  ).then((res) => {
    return res.reduce<Record<string, any>>((memo, [m, mockFile]) => {

      const obj = m?.default || m || {};
      for (const key of Object.keys(obj)) {
        const mock = getMock({ key, obj });
        mock.file = mockFile;
        // check conflict
        const id = `${mock.method} ${mock.path}`;
        assert(
          lodash.isArray(mock.handler) ||
            lodash.isPlainObject(mock.handler) ||
            typeof mock.handler === "function",
          `Mock handler must be function or array or object, but got ${typeof mock.handler} for ${
            mock.method
          } in ${mock.file}`,
        );
        if (memo[id]) {
          console.warn(
            `${id} is duplicated in ${normalizeMockFile(
              mockFile,
            )} and ${normalizeMockFile(memo[id].file)}`,
          );
        }
        memo[id] = mock;
      }
      return memo;
    }, {});
  });

  return ret;
}

function getMock(opts: { key: string; obj: any }): IMock {
  const { method, path } = parseKey(opts.key);
  const handler = opts.obj[opts.key];
  return { method, path, handler };
}

function parseKey(key: string) {
  const spliced = key.split(/\s+/);
  const len = spliced.length;
  if (len === 1) {
    return { method: DEFAULT_METHOD, path: key };
  } else {
    const [method, path] = spliced;
    const upperCaseMethod = method.toUpperCase();
    assert(
      VALID_METHODS.includes(upperCaseMethod),
      `method ${method} is not supported`,
    );
    assert(path, `${key}, path is undefined`);

    return { method: upperCaseMethod, path };
  }
}

匹配mock path并返回mock结果

这里有一点需要注意,rsbuild的devServer是connect,返回json结果无法直接用express的res.json,得先用JSON.stringify包装。res.end(JSON.stringify(mock.handler));

js 复制代码
const DEFAULT_METHOD = "GET";
const VALID_METHODS = [
  "GET",
  "POST",
  "PUT",
  "DELETE",
  "PATCH",
  "HEAD",
  "OPTIONS",
];
interface IMock {
  method: string;
  path: string;
  handler: Function;
  file?: string;
}
//以下代码参考了umi的源码,对connect服务器做了适配
export function createMockMiddleware(): RequestHandler {
  return async (req: any, res, next) => {
    const mockData = await getMockData({ cwd: process.cwd(), mockConfig: {} });
    const method = req.method.toUpperCase();
    for (const key of Object.keys(mockData)) {
      const mock = mockData[key];

      if (mock.method !== method) continue;
      const { keys, re } = getPathReAndKeys(mock.path);
      const m = re.exec(req.url);
      if (m) {
        if (typeof mock.handler === "function") {
          // 可扩展支持mock返回function
        } else {
          res.statusCode = 200;
          res.end(JSON.stringify(mock.handler));
        }
        return;
      }
    }
    next();
  };
}

function getPathReAndKeys(path: string) {
  const { regexp, keys } = pathToRegexp(path);
  return {
    re: regexp,
    keys,
  };
}

loadLatestEsmModule------esm模块缓存问题

设定的mock文件格式为esm,而esm的模块加载后会缓存。不同于cjs的require,可以通过delete require.cache[xxfile]的方式清除缓存重新加载,esm无法清除模块缓存,只能通过对模块加时间戳的方式解决。

js 复制代码
export async function loadLatestEsmModule(filePath: string) {
  try {
    // 1. 将文件路径转为 file URL(ESM import 要求 URL 格式)
    const fileUrl = pathToFileURL(filePath);

    // 2. 拼接时间戳参数,绕过 ESM 缓存(核心逻辑)
    const timestamp = Date.now();
    const cacheBustUrl = new URL(fileUrl);
    cacheBustUrl.searchParams.set("t", timestamp.toString());

    // 3. 动态 import 最新模块(无缓存)
    const module = await import(cacheBustUrl.href);

    // 4. 返回模块默认导出/全部导出(适配用户的 mock 文件)
    return {
      default: module.default,
      ...module,
    };
  } catch (error) {
    console.error(`加载模块 ${filePath} 失败:`, error);
    throw error;
  }
}

插件使用

在rsbuild.config.ts中添加插件

js 复制代码
import { rsbuildMockPlugin } from "./mockplugin";
// Docs: https://rsbuild.rs/config/
export default defineConfig({
  plugins: [pluginReact(), rsbuildMockPlugin()],
});

组件中进行mock请求

scss 复制代码
  useEffect(() => {
    fetch("/api/user/1")
      .then((res) => res.json())
      .then(console.log);
    fetch("/api/users")
      .then((res) => res.json())
      .then(console.log);
  }, []);

mock返回

express 与 connect的区别

rsbuild的devServer基于connect,而其他多数脚手架的devServer都基于express,express也是在connect上进行的二次封装,它的req,res api更简单。比如express中可以使用req.path获取到不带search的请求路径,也可以直接用res.json返回json对象。

在上述代码中使用了req.url和mock path进行匹配,无法匹配到带search的请求,如/api/userList?pageSize=10&pageNo=2。因此如果继续使用rsbuild的devServer,需要额外处理req.url。更简单的办法是将express也加入到中间件中。

js 复制代码
import express from "express";
const app = express();
export const rsbuildMockPlugin = (): RsbuildPlugin => ({
  name: "poisson:mock",
  setup(api) {
    api.modifyRsbuildConfig((defaultConfig, utils) => {
      const config = defineConfig({
        dev: {
          watchFiles: [
            {
              paths: ["mock/**/*.[jt]s"],
              type: "reload-server",
            },
          ],
          setupMiddlewares: (middlewares) => {
            middlewares.unshift(...[app, createMockMiddleware()]);
          },
        },
      });
      return utils.mergeRsbuildConfig(defaultConfig, config);
    });
  },
});
相关推荐
用泥种荷花2 小时前
【LangChain.js学习】 文档加载(Loader)与文本分割全解析
前端
cxxcode2 小时前
Vite 热更新(HMR)原理详解
前端
HelloReader3 小时前
Tauri 架构从“WebView + Rust”到完整工具链与生态
前端
Bigger3 小时前
告别版本焦虑:如何为 Hugo 项目定制专属构建环境
前端·架构·go
代码匠心5 小时前
AI 自动编程:一句话设计高颜值博客
前端·ai·ai编程·claude
_AaronWong6 小时前
Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记
前端·javascript·vue.js
cxxcode6 小时前
I/O 多路复用:从浏览器到 Linux 内核
前端
用户5433081441946 小时前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo6 小时前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端