两年前,我经手了一个内部前端项目,仓库名只有三个字母:rea。最初没人说得清它到底代表什么,翻遍文档也只有一句"React 组件库代号"。那时候团队里正好有七八个业务项目在并行推进,每个项目里都躺着一套自己写的弹窗、按钮和表单样式,改个主题色要动十几个文件。后来我们决定把这个代号扶正,认认真真把它做成一个能长期用的 React 组件库,代号就叫rea。
这篇文章记录的,就是从零开始搭建这个组件库的完整过程:立项时的判断、工程化底座的设计、核心组件的踩坑、样式方案和按需加载的取舍,以及最后发布维护阶段的经验。如果你正在纠结要不要自建组件库,或者已经决定动手但不确定从哪开始,这篇文章应该能帮你省不少弯路。
1. 从“rea”这个代号出发:组件库立项前的真实考量
1.1 团队为什么要自建组件库:成本账与决策边界
在决定做rea之前,我们做过一次摸底。当时市面上已经有很多成熟的组件库,功能强大、文档完善、社区活跃,看起来没有任何理由再造一个轮子。但等到真正用起来,问题就暴露了:
- 业务定制化程度高。我们的后台系统里有大量重复但又不完全相同的区块,比如"审批流卡片""数据异常提示条""联动筛选面板",用现成组件库要么改样式改得面目全非,要么干脆不支持。
- 设计规范不一致。不同项目用的是不同版本的主题变量,整体视觉割裂,设计师每次提需求都要附带一份专门的样式约定。
- 维护成本被摊薄。每个项目各写各的公共组件,出了 bug 要修复好几遍,还经常出现修了 A 项目忘了 B 项目的情况。
真正让我下定决心的是第二和第三条。假如把公共组件收敛到一个仓库、一份文档、一套版本号里,长期来看节省的不只是代码量,更是沟通成本和心智负担。
但自建组件库不是无脑选。我见过不少团队立项的时候热血沸腾,结果半年过去组件库里的组件全是半成品。所以立项前我们做了个简单的判断:只有当你的业务组件重复度足够高、现有库的扩展成本大到无法忽略、而且团队里有专人愿意持续维护时,自建才是理性的选择。我们当时三条都占,rea才开始从代号变成一个项目。
1.2 组件库的边界:哪些该做,哪些不该碰
组件库最容易犯的错误,就是什么都往里塞。rea从一开始就划了一条线:
- 只做通用性组件:Button、Modal、Input、Select、Toast、Tooltip 这种任何业务都可能用到的。
- 不收业务逻辑:比如"根据用户权限显示不同按钮"这种逻辑,坚决不放组件库里。
- 不碰页面级:页面布局是业务层的事,组件库只负责组合出最小的可用单元。
这么划分之后,组件库的维护面一下子小了很多。新需求进来的时候,团队先问三个问题:它真的通用吗?它能被复用几次?如果只在一个业务里出现,那就留在业务项目里。这个纪律保住了rea的体量,直到现在也就三十来个组件,但个个都能打。
1.3 造轮子之前的三个判断指标
如果你正在犹豫要不要自建组件库,不妨拿这三个指标过一遍:
- 重复次数:同一个交互模式在多少个页面出现过?少于 3 次不建。
- 改动频率:这个模块一个月要被改几次?改得少说明不活跃,不一定值得抽象。
- 替换成本:如果未来出现了比自建库更好用的开源方案,迁移代价大不大?
rea立项时,我们对这三个问题都有明确答案:重复次数超过 8 次、改动频率稳定且高、替换成本可以接受(因为我们把样式和逻辑都包在了组件内部)。有了这些判断打底,后面所有开发决策都有了依据。
2. 冷启动阶段:一次到位的前端工程化底座
2.1 构建工具选型:Vite 的 library mode 接替了 Webpack
组件库本质上是"做工具给工具用",它和普通业务应用的构建方式很不一样。业务应用关注的是浏览器直接加载产物,组件库关注的是怎么输出多种格式、保留类型声明、让下游项目按需引用。
rea早期用的还是 Webpack,配置文件写了一大堆,每次改依赖都要等重启。后来切到 Vite,它的 library mode 让整个构建配置从两百多行瘦身到三十行左右。核心配置大概是这样的:
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { resolve } from 'path' export default defineConfig({ plugins: [react()], build: { lib: { entry: resolve(__dirname, 'src/index.ts'), name: 'Rea', fileName: (format) => `rea.${format}.js`, formats: ['es', 'cjs', 'umd'], }, rollupOptions: { external: ['react', 'react-dom'], output: { globals: { react: 'React', 'react-dom': 'ReactDOM', }, }, }, sourcemap: true, cssCodeSplit: true, }, })这里面最关键的一步是把react和react-dom标记为external。组件库不能把 React 打包进产物,否则下游项目里会出现两个 React 实例,导致 hooks 各种诡异报错。这个坑我们一开始就踩过,后来才明白 peerDependencies 的意义在哪里。
2.2 目录设计:让每个组件的边界一眼可见
rea的目录结构是按照"组件就是独立小项目"的思路设计的:
rea/ ├── src/ │ ├── components/ │ │ ├── Button/ │ │ │ ├── Button.tsx │ │ │ ├── Button.style.ts │ │ │ ├── Button.type.ts │ │ │ └── index.ts │ │ ├── Modal/ │ │ │ └── ... │ │ └── index.ts │ ├── hooks/ │ ├── styles/ │ └── utils/ ├── docs/ ├── examples/ ├── scripts/ ├── package.json └── vite.config.ts每个组件文件夹里,类型定义、样式、组件本体和对外出口都是独立文件。这样做的好处是后期做 codemod(自动化重构)或按需加载时,每个组件的边界都非常清楚。永远不要把所有组件放在一个巨大的components.tsx文件里,哪怕它在一开始看起来很方便。
2.3 package.json 里容易被忽略的四个字段
组件库的package.json比普通应用的讲究得多,有四个字段直接影响下游项目能不能正常使用:
| 字段 | 作用 | rea 的配置 |
|---|---|---|
main | CommonJS 入口 | dist/rea.cjs.js |
module | ESM 入口 | dist/rea.es.js |
types | 类型声明入口 | dist/types/index.d.ts |
exports | 精确控制子路径导出 | "."与"./styles"分别映射 |
特别提醒exports字段:用了它之后,下游项目就只能访问你明确导出的路径,这既是限制也是保护——用户不会误用内部模块,打包工具也更容易做 tree-shaking。rea的 exports 是这样写的:
{ "exports": { ".": { "types": "./dist/types/index.d.ts", "import": "./dist/rea.es.js", "require": "./dist/rea.cjs.js" }, "./styles": "./dist/rea.style.css" } }另一个容易被忽略的是sideEffects字段,这个后面讲按需加载时会细说。总之,底座的工程化配置决定了下游用户的体验,这部分值得花时间一次做对。
3. 核心组件设计与踩坑实录:从 Button 到 Modal
3.1 Button 组件:看似简单,细节里全是坑
rea的第一个组件是 Button。当时我觉得这玩意儿太简单了,两天就能写完,结果真正做下来,光是 API 设计就讨论了好几轮。
Button 的核心是"让调用者几乎没有学习成本"。用户应该能用一个variant属性切换主要/次要/危险按钮,用一个size属性控制尺寸,同时也得允许用户像使用原生button元素一样传onClick、disabled等属性。这意味着组件需要完整支持原生按钮的属性透传。
// Button.type.ts import type { ButtonHTMLAttributes, ReactNode } from 'react' export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' export type ButtonSize = 'sm' | 'md' | 'lg' export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: ButtonVariant size?: ButtonSize loading?: boolean children?: ReactNode }实现的时候,最关键的是loading状态。一个常见错误是只改变文字、加个 spinner,但在 loading 时没有禁用点击,导致用户连点了好多次提交。rea的做法是 loading 时自动设置disabled并阻止事件冒泡,同时保留按钮的实际宽度,防止文本切换导致布局跳动:
// Button.tsx import { forwardRef } from 'react' import type { ButtonProps } from './Button.type' export const Button = forwardRef<HTMLButtonElement, ButtonProps>( ({ variant = 'primary', size = 'md', loading = false, disabled, children, onClick, ...rest }, ref) => { return ( <button ref={ref} className={[ 'rea-btn', `rea-btn--${variant}`, `rea-btn--${size}`, loading ? 'rea-btn--loading' : '', ].join(' ')} disabled={disabled || loading} onClick={(e) => { if (loading) { e.preventDefault() return } onClick?.(e) }} {...rest} > {loading ? '加载中...' : children} </button> ) }, )后来又遇到了 SSR(服务端渲染)场景下的disabled属性闪烁问题、鼠标中键点击滚动行为被拦截的问题。这些都说明一个最简单的组件,只要被放到真实项目里使用,就会暴露设计时想不到的边界情况。
3.2 Modal 组件:createPortal 与事件冒泡的教训
Modal 是rea里第二个遇到大坑的组件。第一个版本直接挂在组件树里渲染,加了个position: fixed,看起来没什么问题,直到某次业务方反馈:弹窗里点半天,背景页面居然还能滚动。
原因其实不复杂:如果没有用createPortal把弹窗渲染到body下面,而是嵌在某个transform或filter属性的父级里,position: fixed会失效。transform会创建新的包含块,fixed 就变成相对于那个父元素定位了。
把 Modal 用createPortal挂到 body 之后,又撞上了事件冒泡的问题。通过createPortal渲染的内容从 DOM 结构上看在 body 下,但 React 的事件系统还是按照虚拟 DOM 结构冒泡,所以点弹窗空白区域居然会触发下层页面的点击事件。解决方式也很直接:在弹窗外层拦截鼠标事件,阻止它冒泡到 React 树的父级:
// Modal.tsx 关键片段 import { createPortal } from 'react-dom' function ModalContent(props) { return createPortal( <div className="rea-modal-overlay" onClick={props.onOverlayClick}> <div className="rea-modal" onClick={(e) => e.stopPropagation()} > {props.children} </div> </div>, document.body, ) }这里面的经验是:所有弹层类组件(Modal、Tooltip、Dropdown、Popover)都应该默认使用 createPortal,并且要明确设计事件冒泡的边界。否则你永远不知道用户的下一次点击会触发什么神奇行为。
3.3 Input 组件:受控状态与 ref 转发的配合
rea里最烦琐的组件是 Input,因为它要同时保证受控、非受控两种模式都可用,还要把ref转交给真实的 DOM 节点。早期我们偷懒,直接绑定value和onChange,一旦业务方想用ref操作输入框(比如设置光标位置、触发焦点),就会发现ref拿不到原生的 input 节点。
后来用forwardRef解决了 ref 转发,但还有一个隐藏问题:value为undefined时,React 会警告"受控组件与非受控组件切换"。这是因为我们内部对 value 做了一层默认值处理,导致 DOM 上的 value 一会儿是空的,一会儿是 undefined。
正确做法是保持value的传递逻辑简单直接:外部传什么就传什么,不要基于 truthy 判断做隐式转换。组件库不是业务代码,对外 API 的稳定性比内部实现的优雅更重要。这些经验都是在其他业务项目里用坏了几个版本之后才悟出来的。
4. 样式方案、主题定制与按需加载的配合
4.1 样式隔离:CSS 变量 + BEM 的组合拳
组件库的样式问题可以概括成一句话:既要能统一管理,又要能被定制,还要不污染业务项目。rea的样式方案经历过三次变化:
- 第一次:直接写在组件里,每个组件 import 一份 CSS。结果页面一渲染,全局样式互相覆盖。
- 第二次:用 CSS Modules 做局部作用域。冲突解决了,但主题定制变得很痛苦,业务方想统一改个主色,要把每个组件的 class 都翻一遍。
- 第三次:CSS 变量 + BEM 命名。组件内部用 BEM 命名避免类名冲突,颜色、圆角、间距等统一用 CSS 变量,业务方只需要改主题层的变量值就能全局换肤。
三种样式方案的对比:
| 方案 | 局部隔离 | 定制成本 | 构建复杂度 | 适合场景 |
|---|---|---|---|---|
| 普通 CSS | 差 | 很低 | 低 | 一次性页面 |
| CSS Modules | 好 | 高 | 中 | 中大型应用 |
| CSS 变量 + BEM | 好 | 很低 | 低 | 组件库/多主题 |
rea最终选择的方向,底层 Tokens 是 CSS 变量,组件类名分层。比如 Button 的背景色在样式文件里不是写死的十六进制值,而是var(--rea-color-primary)。这样下游项目想改主题时,覆盖 CSS 变量即可,不需要去动组件内部样式。
提示:CSS 变量命名最好带前缀(比如
--rea-),这样可以有效避免和其他库的命名冲突。曾经有客户项目同时引用了两个组件库,两边都用--primary-color,结果主题色鬼打墙,排查了半天。
4.2 按需加载的完整闭环:sideEffects 与 tree-shaking
组件库最怕的就是"用户只用了 Button,结果把整个库都打包进去了"。为了实现按需加载,rea做了三件事:
- 构建产物保持 ESM 格式,天然支持 tree-shaking。
- 在
package.json中把sideEffects设为false,告诉打包器这个库的模块都是纯净的,可以安全地删除没被用到的部分。 - 样式文件单独输出,按需加载时用
rea/style.css一次引入。
但sideEffects: false有个坑:如果你的组件模块在顶层执行了某些副作用代码(比如运行时给 document 挂监听器、动态修改某个全局变量),tree-shaking 会把它们安全地丢弃吗?不会,反而会因为声明了无副作用而导致这些代码被误删。所以rea的所有组件只负责渲染和交互,全局副作用代码统一收敛到src/effects.ts里单独处理。
性能实测下来,一个只用到 Button 和 Modal 的业务页面,在打包产物里rea部分从全量引入的 180KB 降到了 35KB 左右,收益非常明显。
4.3 主题定制的隐藏坑:别把颜色写死在组件里
主题定制是个看起来简单、做起来容易翻车的需求。rea曾经有个版本把默认主题色直接写在了组件样式里:
.rea-btn--primary { background-color: #1a73e8; }结果用户想换主题色时,只能靠!important强行覆盖,样式权重混乱不堪。后来我们把所有设计变量抽到了:root层:
:root { --rea-color-primary: #1a73e8; --rea-color-danger: #e81123; --rea-radius-md: 6px; --rea-font-md: 14px; } .rea-btn--primary { background-color: var(--rea-color-primary); }再遇到换肤需求,业务方只需要在项目里重定义这些变量:
:root { --rea-color-primary: #7c3aed; }组件库的样式代码里不应该出现字面量颜色值,所有颜色、字号、间距都应该走设计变量。这是一条对任何有换肤需求的项目都适用的原则,不是rea独有的经验,但我们在实践中体会得格外深。
5. 文档、发布与长期维护:项目能活多久取决于这里
5.1 用文档站点倒逼组件设计
组件库的文档不只是给使用者看的,它还是组件设计质量的试金石。如果组件 API 很难用一两段话解释清楚,那这个组件的设计八成是有问题的。
rea的文档站建在项目的docs目录下,没有用重型框架,就用 Vite 搭了个简单的静态站点,每个组件一页,包含:
- 基础用法示例(可复制、可修改)
- API 表格(属性名、类型、默认值、说明)
- 已知问题与迁移注意事项
后来我们养成了一个习惯:新组件提 PR 的前提是先写好文档。写文档的过程能把 API 的不合理之处暴露出来,比纯写代码时自己脑补要靠谱得多。
5.2 版本管理与变更记录的自动化
组件库是个多人协作的项目,版本管理不能靠自觉。rea用的是标准的 semver 语义化版本:
| 版本变化 | 对应动作 |
|---|---|
| 主版本号 | 存在破坏性变更(如修改 API 签名) |
| 次版本号 | 新增组件或兼容性新功能 |
| 补丁号 | 修复 bug、优化样式 |
变更记录我们一开始是靠手写 CHANGELOG,但经常有人忘记更新,发版时绞尽脑汁回忆改了什么。后来在 CI 里接入了 commit message 的规范化,每次代码合并时按 fix/feat/breaking 分类,发版时自动生成对应的 changelog。这套流程的收益在维护三个月后逐渐显现:追溯问题不再需要翻 commit 和文档两套系统。
5.3 开源前的自查清单与维护心得
rea从内部项目走向开源前,我们做过一轮认真自查,这份清单后来也被团队其他项目复用:
- 类型声明是否完整?下游用 TypeScript 的用户第一眼就看它。
- 依赖有没有收敛?依赖树里的第三方包越少,维护成本越低。
- 示例是否都可以直接运行?提供可复现的沙箱 demo 比任何说明书都管用。
- 有没有设计退化测试?比如安全补丁升级后,组件样式是否被意外改变。
- 文档与产物是否一致?文档里的 API 表每次发版都要同步核对。
说实话,组件库维护的难点从来不是写第一个组件,而是持续面对各种使用场景里的真实反馈。有些用户会用你完全没想到的方式调用你的组件,这些反馈往往是最珍贵的改进线索。
我个人在维护rea这段时间里最深的体会是:组件库是在"通用性"和"复杂度"之间走钢丝,每一次加功能前都要反问自己——这个 API 是让 99% 的用户更方便,还是只为了让 1% 的边界场景更优雅?答案如果是后者,宁可先不做。组件库一旦膨胀起来,最终拖垮它的一定是过量的 API 和不可维护的复杂度。保持克制,比堆砌功能需要更大的勇气。