侧边栏

基于monorepo的antd二次开发调试方案

发布于 | 分类于 前端/前端工程

最近在做 Ant Design 的二次开发,需要深度定制一些组件,甚至要改底层的 rc-* 组件。这个过程并不是很顺利,遇到了很多流程上的问题。

本文将介绍一种基于monorepo 二开antd代码的方案,解决antd 二开遇到的一些问题。

Ant Design 的架构设计

先简单介绍一下 Ant Design 的整体架构。

Ant Design 采用了分层设计,将组件拆分为两层:

  • 上层组件antd 包,提供完整的 UI 组件,包含样式、主题、国际化等
  • 底层组件rc-* 系列包(如 rc-menurc-tooltiprc-dropdown 等),提供核心的交互逻辑和状态管理

这种设计的好处是职责分离:rc-* 组件专注于交互逻辑,antd 组件负责样式和用户体验。但问题在于,这些包都是独立的 Git 仓库和 npm 包:

ant-design/ant-design          # antd 主仓库
react-component/menu           # rc-menu 仓库
react-component/tooltip        # rc-tooltip 仓库
react-component/dropdown       # rc-dropdown 仓库
react-component/...            # 还有几十个 rc-* 仓库

每个包都有自己的:

  • 独立的 Git 仓库和版本管理
  • 独立的 npm 发布流程
  • 独立的构建配置和依赖关系

举个例子,一个简单的 Dropdown 组件,实际上涉及多个包的协作:

antd/dropdown
  └── rc-dropdown
      ├── rc-trigger
      └── rc-menu
          └── rc-motion

二次开发的痛点

当我们需要对 Ant Design 进行二次开发时,这种架构就带来了不少麻烦。

场景 1: 修改上层组件

如果只是修改 antd 的样式或配置,看起来还算简单:

  1. Fork ant-design/ant-design 仓库
  2. 修改代码
  3. 构建发布

但实际上,Ant Design 内置了一套基于 dumi 的文档系统,使用 MDX 格式编写组件文档和示例。这套系统在编写文档时确实很方便,但在开发调试时就比较麻烦了。

举个例子,Button 组件的文档页面大概是这样的:

mdx
# Button 按钮

## 代码演示

<code src="./demos/basic.tsx">基础按钮</code>
<code src="./demos/type.tsx">按钮类型</code>
<code src="./demos/size.tsx">按钮尺寸</code>
<code src="./demos/loading.tsx">加载状态</code>
<code src="./demos/disabled.tsx">禁用状态</code>
<!-- ... 还有十几个 demo -->

一个组件页面会同时渲染十几个 demo,这就带来了几个问题:

  1. 日志混乱:所有 demo 的 console.log 都输出到同一个控制台,调试时很难分辨是哪个 demo 的日志

  2. 热更新慢:修改一个 demo 的代码,整个页面的所有 demo 都要重新渲染,等待时间长

  3. 状态干扰:多个 demo 可能会相互影响,特别是涉及全局状态或事件监听时

  4. 性能问题:页面加载时要同时初始化十几个组件实例,首次加载很慢

  5. 难以聚焦:想专注测试某个特定功能时,其他 demo 会分散注意力

比如我要调试一个 Button 的 loading 状态问题,打开页面后:

  • 要滚动到对应的 demo 位置
  • 控制台里混杂着其他 demo 的日志
  • 改完代码后,整个页面的 20 个 Button demo 都要重新渲染
  • 等了 3-5 秒才能看到效果

这种体验在快速迭代和调试时真的很影响效率。

场景 2: 修改底层组件

但如果需要修改底层的 rc-* 组件,就麻烦了。比如我遇到的一个问题:Tooltip 在全屏模式下渲染位置不对,需要修改 rc-tooltip 的源码。

传统的开发流程是这样的:

  1. Fork react-component/tooltip 仓库
  2. 克隆到本地,修改代码
  3. 在 rc-tooltip 目录执行 npm link
  4. 在 antd 目录执行 npm link rc-tooltip
  5. 构建 rc-tooltip
  6. 构建 antd
  7. 在业务项目中 npm link antd
  8. 启动业务项目测试

每次修改代码后,都要重复步骤 5-8。如果涉及多个 rc-* 组件,link 关系会更复杂:

bash
# 在各个包的目录中执行
cd rc-tooltip && npm link
cd rc-dropdown && npm link
cd rc-menu && npm link

# 在 antd 目录中链接
cd antd
npm link rc-tooltip
npm link rc-dropdown
npm link rc-menu

# 在业务项目中链接
cd my-project
npm link antd

这种方式存在几个问题:

  • 每次改完代码都要 npm link 来链接本地包
  • 修改源码后必须重新构建才能看到效果
  • 调试多个相互依赖的包时,link 来 link 去容易出错
  • 热更新基本不可用,改一行代码要等半天
  • 版本管理混乱,不知道改了哪些包的哪些代码
  • 团队协作困难,每个人都要在本地配置一遍 link

场景 3: 追踪上游更新

更麻烦的是,当上游发布新版本时,我们需要:

  1. 在每个 fork 的仓库中手动同步上游更新
  2. 解决合并冲突
  3. 重新构建和测试
  4. 确保各个包的版本兼容性

如果 fork 了 5 个仓库,就要重复 5 次这个流程。

解决方案

为了解决这些痛点,探究了一种基于 pnpm workspace + Vite + git subtree 搭建antd开发调试环境。核心思路是:

  • git subtree 将所有上游代码引入到一个 monorepo 中,统一管理
  • pnpm workspace 自动处理包之间的依赖关系,无需手动 link
  • Vite 提供极速的开发服务器,支持 HMR

现在改代码就像写普通项目一样丝滑,热更新响应时间不到 100ms,再也不用等构建了。

这篇文章主要整理一下这套方案的实现思路和使用方式。

monorepo结构

先看看项目结构:

antd-monorepo/
├── packages/
│   ├── antd/              # 主组件库(基于 Ant Design 二次开发)
│   │   └── components/     # 通过 git subtree 从 ant-design/ant-design 引入
│   └── rc-xxx/             # rc 组件源码(通过 git subtree 管理)
│       └── src/            # 通过 git subtree 从 react-component/xxx 引入
├── demo/                   # 调试项目(本方案核心)
│   ├── src/
│   │   ├── routes/         # 按组件组织的演示页面
│   │   │   ├── button/
│   │   │   │   └── demos/  # Button 组件的各种用例
│   │   │   └── splitter/
│   │   │       └── demos/  # Splitter 组件的各种用例
│   │   └── main.tsx
│   ├── vite.config.ts      # Vite 配置(核心)
│   └── package.json
└── pnpm-workspace.yaml

整个方案的核心思路是:

  • pnpm workspace 管理 monorepo,自动处理包之间的依赖关系
  • git subtree 引入上游源码,既能追踪版本历史,又能方便地合并更新
  • Vite 提供极速的开发服务器,支持 HMR
  • demo 项目中按组件组织测试用例,每个 demo 都是一个独立的 React 组件

这套方案用下来,确实解决了不少痛点:

  • 无需手动 npm link,pnpm workspace 自动处理依赖关系,支持多层级依赖
  • Vite 原生 ESM 支持,启动速度快,HMR 响应时间 < 100ms
  • 每个 demo 独立运行,互不干扰,可以针对特定 bug 创建最小复现用例
  • TypeScript 类型提示完整,支持 CSS Modules / UnoCSS,可以直接使用 React DevTools 调试
  • 使用相同的构建工具链和模块解析规则,减少"本地能跑,发布后出问题"的情况
  • git subtree 完整的版本历史追踪,清晰记录每次二开修改,支持选择性合并上游更新
  • 所有代码在一个仓库,团队成员克隆一次即可获得完整代码

下面介绍一下核心的思路

git subtree

传统的组件库二次开发通常会 fork 仓库,但这样做有几个问题:

  • 上游更新很难合并,经常要手动解决一堆冲突
  • 无法在一个仓库中统一管理多个依赖包
  • 代码对比和冲突解决都很麻烦

我们采用 git subtree 方案,将上游仓库的代码作为子目录引入。这样做的好处是:

  • 所有代码在一个 monorepo 中统一管理
  • 保留完整的 git 历史,方便追溯变更
  • 支持双向同步:既能拉取上游更新,也能推送修改回上游(如果需要贡献代码)
  • 清晰的二开代码对比和版本管理

看起来学习成本会高一点,但用起来确实比 fork 方便多了。

仓库映射

对应的目录与仓库映射关系

本地路径上游仓库用途
packages/antd/componentsant-design/ant-designAnt Design 组件源码
packages/rc-menureact-component/menuMenu 底层实现
packages/rc-tooltipreact-component/tooltipTooltip 底层实现
packages/rc-dropdownreact-component/dropdownDropdown 底层实现
packages/rc-*react-component/*其他 rc 组件

这里只列举了需要二开的rc-*组件,如果没有定制需求,则使用npm官方源默认的版本即可。

vite 开发调试

前面提到,Ant Design 自带的 dumi 文档系统更适合编写文档,但在开发和调试源码时存在不少问题。那么为什么选择 Vite 来替代呢?

dumi 的局限性

dumi 作为一个文档工具,它的设计目标是展示组件的各种用法,而不是为了快速开发调试。具体来说:

  1. 文档导向的架构

    • dumi 需要解析 MDX 文件,处理文档元数据、目录结构等
    • 每个组件页面会同时加载十几个 demo,这对文档展示很友好,但对开发调试来说是负担
    • 启动时间长,通常需要 10-20 秒才能看到页面
  2. 构建流程复杂

    • dumi 基于 Umi 框架,有自己的一套构建流程和约定
    • 需要处理路由生成、主题配置、插件系统等
    • 热更新需要重新编译整个文档站点,速度慢
  3. 调试体验差

    • 一个页面渲染多个 demo,日志混乱
    • 修改代码后,所有 demo 都要重新渲染
    • 难以针对单个功能点进行快速验证

Vite 的优势

相比之下,Vite 作为一个纯粹的开发工具,更适合我们的场景:

  1. 极速冷启动

    • Vite 利用浏览器原生 ESM,无需打包即可启动
    • 启动时间通常在 1-2 秒内
    • 按需编译,只处理当前访问的文件
  2. 快速热更新

    • 基于 ESM 的 HMR,只更新修改的模块
    • 响应时间 < 100ms,几乎是即时的
    • 不会影响其他未修改的代码
  3. 简单直接

    • 没有复杂的文档系统,就是一个普通的 React 应用
    • 可以用熟悉的 React Router 组织路由
    • 配置简单,专注于开发体验
  4. 灵活的 demo 组织

    • 每个 demo 是独立的路由页面,互不干扰
    • 可以针对特定功能创建最小复现用例
    • 日志清晰,调试方便

为什么不直接改造 dumi

你可能会问,为什么不直接优化 dumi 的开发体验呢?主要有几个原因:

  1. 设计目标不同:dumi 是为了生成文档站点,而我们需要的是开发调试环境
  2. 改造成本高:要改造 dumi 的构建流程和渲染逻辑,工作量很大
  3. 维护负担:改造后的 dumi 需要持续维护,跟上游版本同步会很麻烦
  4. 灵活性差:dumi 有很多约定和限制,不如从零搭建一个简单的 Vite 项目灵活

所以最终选择了用 Vite 单独搭建一个开发调试环境,保持 dumi 用于文档生成,两者各司其职。

Git Subtree 使用指南

初次引入上游代码

第一次引入上游代码时,使用 git subtree add 命令:

bash
# 引入 Ant Design 源码(指定版本 5.x.x)
git subtree add --prefix=packages/antd \
  https://github.com/ant-design/ant-design.git v5.22.0 --squash

# 引入 rc-menu 源码
git subtree add --prefix=packages/rc-menu \
  https://github.com/react-component/menu.git v9.15.0 --squash

# 引入 rc-tooltip 源码
git subtree add --prefix=packages/rc-tooltip \
  https://github.com/react-component/tooltip.git v6.2.0 --squash

这里有几个参数需要注意:

  • --prefix: 指定代码存放的本地目录
  • --squash: 压缩上游的所有提交为一个,保持本地历史清晰
  • 版本号: 使用 tag 或 branch 名称,建议锁定稳定版本

更新上游代码

当上游发布新版本时,可以选择性合并(由于是内部的二开组件库,除了非常严重的bug,一般不会轻易升级):

bash
# 更新 Ant Design 到 5.23.0
git subtree pull --prefix=packages/antd \
  https://github.com/ant-design/ant-design.git v5.23.0 --squash

# 更新 rc-menu 到最新版本
git subtree pull --prefix=packages/rc-menu \
  https://github.com/react-component/menu.git v9.16.0 --squash

git 会自动对比本地修改和上游更新,如果有冲突会提示手动解决。解决冲突后提交,就完成合并了。

Vite 本地开发

由于antd自带的dumi,更适合用来编写文档,在开发和调试源码方面,并不合适。

接下来看看 Vite 这边的配置。

Workspace 依赖配置

demo/package.json 中通过 workspace:* 协议引用本地包:

json
{
  "dependencies": {
    "antd": "workspace:*"
  }
}

这样 pnpm 会自动将 packages/antd 软链接到 demo/node_modules/antd,无需手动 link。

路径转换插件

这里有个坑:rc-* 组件在发布时使用 /lib/ 目录,但vite开发时需要使用 /es/ 目录(ESM 格式)。

我们在 Vite 中添加了自定义插件来处理:

typescript
{
  name: 'replace-lib-to-es',
  transform(code, id) {
    if (/\.(j|t)sx?$/.test(id)) {
      const transformed = code.replace(
        /from\s+(['"])([^'"]*\/rc-[^'"]+)\/lib\//g,
        (_, quote, path) => `from ${quote}${path}/es/`
      )
      if (transformed !== code) {
        return { code: transformed, map: null }
      }
    }
    return null
  }
}

这个插件会自动将所有 rc-*/lib/ 的导入路径转换为 rc-*/es/,确保 Vite 能正确处理 ESM 模块,同时避免了修改源码的lib写法。

按组件组织演示用例

采用约定式路由结构,每个组件独立管理其演示用例:

routes/
├── button/
│   ├── demos/
│   │   ├── Basic.tsx      # 基础用法
│   │   ├── Type.tsx       # 按钮类型
│   │   └── Size.tsx       # 按钮尺寸
│   └── routes.tsx         # 路由配置
└── splitter/
    ├── demos/
    │   └── fullscreen.tsx # 全屏功能演示
    └── routes.tsx

每个 demo 文件都是一个独立的 React 组件,可以专注于测试特定功能点。

启动和调试

整个流程非常简单:

  1. 启动开发服务器

    bash
    cd demo
    pnpm dev
  2. 修改组件源码

    • 编辑 packages/antd/components/xxx/index.tsx
    • 或编辑 packages/rc-xxx/src/xxx.tsx
  3. 实时预览效果

    • Vite 自动检测文件变化
    • HMR 即时更新浏览器页面
    • 无需手动刷新或重新构建
  4. 添加新的测试用例

    • demo/src/routes/xxx/demos/ 下创建新文件
    • 在路由配置中注册
    • 立即可访问测试

一个实际案例

举个例子,我之前遇到 Splitter 组件在全屏模式下 Tooltip 位置不对的问题。下面是测试用例:

tsx
// demo/src/routes/splitter/demos/fullscreen.tsx
export function Component() {
  const [isFullscreen, setIsFullscreen] = useState(false);
  const leftPanelRef = useRef<HTMLDivElement>(null);

  const handleFullscreen = async () => {
    if (!isFullscreen) {
      await leftPanelRef.current?.requestFullscreen();
      setIsFullscreen(true);
    } else {
      await document.exitFullscreen();
      setIsFullscreen(false);
    }
  };

  return (
    <ConfigProvider getPopupContainer={() => document.fullscreenElement || document.body}>
      <Splitter layout="vertical">
        <Splitter.Panel>
          <div ref={leftPanelRef}>
            <Button onClick={handleFullscreen}>
              {isFullscreen ? '退出全屏' : '全屏'}
            </Button>
            <Tooltip title="测试内容" placement="top">
              <Button>编辑</Button>
            </Tooltip>
          </div>
        </Splitter.Panel>
      </Splitter>
    </ConfigProvider>
  );
}

这个用例专门测试全屏模式下 Tooltip 的渲染容器问题,可以快速验证修复方案。改完源码,浏览器立刻就能看到效果,非常方便。

小结

通过 git subtree + pnpm workspace + Vite 的组合,我们搭建了一个高效的 Ant Design 二次开发调试环境。

这套方案的核心价值在于:

  • Vite 的极速 HMR,修改即生效(< 100ms),无需 npm link,workspace 自动关联,一键启动即可调试所有组件
  • git subtree 保留完整历史,清晰追踪二开代码,支持选择性合并上游更新,可对比任意版本差异
  • Monorepo 统一管理,依赖关系清晰,标准化的 commit 规范和 PR 流程,所有代码在一个仓库
  • 按需添加测试用例,支持复杂场景模拟,易于集成测试框架、文档工具等

与传统的 fork 仓库或 npm link 方案相比,这套方案在上游更新、版本对比、多包管理、历史追踪等方面都有明显优势。虽然学习成本稍高,但用起来确实比较方便。

这套方案比较适合需要深度定制 Ant Design 或 rc-* 组件、需要长期维护自己的组件库版本、团队规模 > 3 人需要协作开发、需要追踪和对比二开代码的场景。

如果只是简单使用 Ant Design 不需要修改源码,或者临时性的小项目,就没必要搞这么复杂了。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。