从零搭建React组件库:工程化、踩坑与按需加载实践 两年前我经手了一个内部前端项目仓库名只有三个字母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 的配置mainCommonJS 入口dist/rea.cjs.jsmoduleESM 入口dist/rea.es.jstypes类型声明入口dist/types/index.d.tsexports精确控制子路径导出.与./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 到 Modal3.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 ButtonHTMLAttributesHTMLButtonElement { 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 forwardRefHTMLButtonElement, 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 classNamerea-modal-overlay onClick{props.onOverlayClick} div classNamerea-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 和不可维护的复杂度。保持克制比堆砌功能需要更大的勇气。