说起来很巧,我最近刚在内部技术分享上聊过这个代号为“Rea”的项目。有人可能觉得“Rea”听起来像某个框架的缩写,或者是某种新语言的代号,实际上它是我从零搭建的一个基于 React 的轻量级组件库项目。为什么起这个名字?纯粹是因为“React”被缩写得太频繁,而且现在市面上组件库一个比一个庞大,我想做一个“小而美”、只包含业务里最高频组件的库,所以就用了这个简短的项目代号。这篇博文不打算讲那种“Hello World”式的教程,而是把整个项目从立项、选型到落地、发布、踩坑的完整逻辑拆开给你看,适合那些准备自建组件库,或者正被超大组件库折磨、想换一条路的朋友参考。
我自己最早接触组件库的时候,和大多数人一样,第一反应是“直接用现成的开源库不就行了”。但真正在多个业务线待过之后,你会发现现成库有几个绕不开的痛点:第一,为了覆盖所有场景,包体积和样式复杂度会随着版本迭代不断膨胀;第二,业务的定制化需求越来越多,能直接用现成组件不改内部逻辑的比例其实不高;第三,主题定制、国际化这些能力一旦深入,你要面对的其实是人家框架的设计哲学,而不是你自己的业务模型。所以 Rea 这个项目从一开始就想清楚了一个问题:它不是一个“通用组件库”,而是一个“面向特定业务场景的定制化组件解决方案”。
1. 项目整体设计与思路拆解
1.1 先搞清楚组件库到底解决什么问题
盲猜你会觉得组件库解决的问题就是“复用”,这话对,但不全对。复用只是表象,真正要解决的是两个层面的事:一是“设计一致性”,二是“开发效率的边际成本”。
我举个例子。你团队里十个前端,每个人写弹窗组件都会有自己的习惯。有人喜欢用绝对定位,有人用 fixed,有人习惯把遮罩层写在组件内部,还有人把遮罩层提升到 body 下管理。这些差异在代码 review 的时候很难统一,但最后 UI 走查时暴露的问题却是实打实的:遮罩层级不对、动画时序不一致、焦点管理缺失。这些问题的根因不是工程师水平差,而是没有一个“被统一约束过的组件抽象层”。Rea 要做的第一件事,就是把这个抽象层立起来。
再一个,组件库能不能降低成本,其实要看它的“组合能力”。单一组件单独看都很好用,但两个组件放在一起,比如 Table 和 Modal 组合,能否在不改组件源码的情况下完成联动?这决定了整个库的扩展成本。所以我在设计 Rea 时特别强调了“内部状态可控”和“外部状态联动”之间的解耦。简单说,就是每个组件内部不该管的全局状态坚决不管,但可以通过受控或半受控模式挂到外部去。
1.2 技术选型背后的取舍
Rea 的核心选型其实非常朴素:TypeScript + React + CSS Variables。没有引入任何运行时 CSS 框架,也没有使用像 styled-components 这种 CSS-in-JS 方案,这是在反复权衡后才定下来的。
先聊 TypeScript。组件库本身面向的是开发者,类型系统不是可选项,而是产品的一部分。组件 props 的类型推导是否友好、泛型是否能穿透到业务代码里,直接影响使用体验。比如 Table 的 columns 配置里,dataIndex 和 render 的参数类型要能自动关联到你所定义的数据结构;Select 的 options 若传了 label/value 的自定义类型,onChange 里拿到的 value 必须精确推导而不是 any。这些都需要在一开始就把类型设计作为一等公民对待。
再聊样式方案。我早期也用过 CSS Modules,但在主题定制上非常痛苦。你需要在 :root 或某个容器节点上挂一大串变量,还要考虑变量在运行时被修改后的响应式更新问题。后来切到 CSS Variables 后,整体清爽很多:组件内部完全通过 var(--rea-xxx) 引用变量,主题切换只需要动态改一批 HTML 属性或 class 即可。最重要的是,它让所有组件的样式都保留在离散的样式表里,团队里不熟悉 JS 的视觉设计师也能快速上手。
1.3 目录结构与架构约束
一个组件库项目,如果目录结构从一开始就乱七八糟,后面一定会变成一场灾难。Rea 的工程目录我花了不少心思设计,核心原则是“组件自治、共享收拢、文档独立”。
简单解释一下这个原则。每个组件都有独立的目录,包含组件源码、样式、类型定义、测试用例和 README。组件之间不允许直接互相引用对方的源码,只能通过网络请求级别的 API 或事件进行交互。这样做可以保证组件物理隔离,从机制上避免循环依赖和“改一处挂一片”的隐患。共享的工具函数、hooks、类型定义则收拢到 components 下面的 _shared 目录中。至于文档,不放在组件目录里,而是独立成一个 site 目录,这样构建文档站点时不会把组件测试或源码里的内部函数一起打包进去,也避免了文档和源码工程之间的构建配置互相污染。
另外,我还给组件分了三层:基础组件、业务组件、布局组件。基础组件就是 Button、Input、Tooltip 这类不掺杂业务语义的原子组件;业务组件是带数据获取逻辑、权限控制、分页交互的 ProTable、ProForm 这类;布局组件则是处理栅格、容器、响应式伸缩的封装。分层的意义在于控制组件之间的依赖方向:基础组件不准依赖业务组件,布局组件不准反向依赖业务组件,这样可以保证依赖图是单向的,维护的时候思路非常清晰。
2. 核心细节解析与实操要点
2.1 组件 API 设计:从需求到 Props 的推导
很多人在设计组件的时候喜欢一上来就写代码,这个习惯在组件库里极其致命。一个 Button 组件,看起来简单到不行,但仔细想一下你会发现它的 API 设计牵涉到非常多决策:按钮的类型是字符串枚举还是布尔组合?loading 状态和 disabled 状态叠加时的交互优先级是什么?图标是作为 ReactNode 传入,还是单独拆一个大小尺寸的映射?这些不做完约束就动手,写出来的组件大概率会给人一种别扭的感觉。
我的经验是:每个组件编写前,先产出 API 草案文档。先列出这个组件要覆盖的 UI 变体、交互状态、以及和外部组件的联动关系。然后逐个推演 props 的最小集合。推到不能再精简,就停下。宁愿在语义上多花时间,也不要为了“预留可能性”加入一堆可能用不上的配置项。因为每多一个 props,就多一份类型维护成本和测试成本,而且会把组件的默认行为变得含糊。
以 Button 为例,最终我设计的核心 props 只有这些:variant、size、loading、disabled、block、icon、href。没有 color 这种通用样式配置,因为颜色在 Rea 里是通过语义化变量控制的。也没有 onClick 的额外封装,直接用 React 原生事件。这样组件保持最小,但用途已经能覆盖几乎所有按钮场景了。
2.2 可控性与受控模式的设计
React 组件库最核心的概念之一就是“受控”和“非受控”。很多初级工程师都理解这个概念的表面意思:受控组件受 state 控制,非受控组件自己管 state。但组件库设计里真正的难点是“半受控”模式——组件既允许外部传入 value,也允许在外部没传时让组件内部维护状态。
我举一个 Select 的例子。在很多业务场景里,选中的值是由页面的 URL query 驱动的。如果 Select 是纯内部状态,页面刷新后状态丢失;如果完全受控,那所有引用它的页面每次都要写 onChange 去同步状态,很烦。Rea 里所有带状态的组件都统一支持通过 value 和 defaultValue 的组合来切换受控/非受控,并且通过内部 useControllableState 这个 hook 统一实现。这个 hook 的核心逻辑是:若外部提供了 value 且 onChange 已绑定,则完全把状态交给外部;否则内部维护一份 state,用 defaultValue 作为初始值。
这个设计在 Table 的分页器、Tabs 的激活项、Modal 的打开关闭中都非常好用。你要养成一个习惯:在写任何带交互状态的组件时,先问自己两个问题——这个状态是否可能被外部控制?外部控制时组件能否忠实地反映外部状态?如果答案都是“是”,那这个组件就要用受控模式设计。
2.3 样式主题与设计令牌的设计方法论
样式主题化做得好不好,直接影响组件库的后期接受度。Rea 放弃了在代码里写死颜色值的做法,全部改用设计令牌(Design Tokens)来承载。
什么是设计令牌?简单说,就是把颜色、间距、字体、圆角、阴影这些设计层面的决策,抽成有语义的名字,再映射到一个个实际的值。比如我不在组件里直接写color: #409eff,而是写color: var(--rea-color-primary)。然后在全局样式里,通过:root声明默认的主题值。
这套体系的优势在换肤时非常明显。假设某天业务方需要把主色从蓝色换成橙色,如果组件源码里全是硬编码的颜色,这一轮的修改量足以让人崩溃。但有了设计令牌之后,只需要在应用入口动态覆盖几个变量值即可。而且因为 CSS Variables 支持运行时继承,所以可以实现真正意义上的动态主题,不需要刷新页面,也不需要重新打包。
给新手的建议是:不要一上来就设计上百个令牌,而是从高频的颜色、圆角、字体大小、间距开始,先用十几个令牌覆盖 80% 的场景。令牌多了以后,维护成本也非常大,你会在这个迭代里体会到“命名”远比想象中难。
2.4 可访问性与键盘交互细节
这个话题经常被忽略,但在组件库里其实是“藏分项”。很多使用者不会因为你的组件支持无障碍而选择你,但当业务方明确要求符合无障碍标准时,没有键盘操作支持的组件库几乎无法通过审计。
Rea 里的每个交互组件,在开发时都会同步做三件事:焦点管理、ARIA 属性注入和键盘事件绑定。以 Modal 为例,打开弹窗后焦点必须转移到弹窗内,并且要锁定 Tab 键的循环范围,避免焦点跑到页面背后的元素去;关闭后焦点要恢复到原来的触发元素上;弹出层要用 role="dialog" 和 aria-modal="true" 标记。这一套逻辑说起来容易,做起来牵扯的细节非常多,一不小心就会出现焦点漂移的问题。
再比如 Tabs 组件,键盘上支持左右方向键切换标签页,Home/End 跳到首页和末页。这些交互看起来是“小细节”,但如果你不是从头设计而是后期补丁式添加,成本会增加非常多倍。所以我的建议是,任何交互类组件的开发 checklist 里,必须包含键盘交互支持这一项,并且要在组件测试中体现出来。
3. 实操过程与核心环节实现
3.1 工程初始化与基础配置
Rea 最初的工程搭建,我用了 Vite 作为开发服务器和构建工具。选择 Vite 而不是 Webpack,核心原因是组件库的开发场景里,热更新速度和依赖预构建的体验非常重要。开发组件库时,你经常只改一个组件的内部样式或一个 TS 类型,如果构建链路太重,每改一次代码等几秒到十几秒,那种割裂感会非常消磨开发积极性。
初始化之后,第一件事就是配置路径别名、ESLint 和 Prettier。这一层必须在一开始就做好,因为代码风格不统一在多人协作组件库时会产生特别多的无效 diff。我采用的是统一使用单引号、尾随逗号、分号省略的规则,同时给 TS 设置了 "strict": true。组件库不比业务项目,类型严谨性会直接传导到使用方的开发体验,所以从一开始就不要放宽约束。
再有一点,组件库的构建产物要考虑两种场景:ESM 和 CommonJS。在 package.json 里需要同时维护 main 和 module 字段,并且在构建配置里分别产出两份产物。同时还需要把副产物贡献为 .d.ts 类型文件,否则 TypeScript 的使用者一引用你的库就会跳出一堆 implicit any 报错,体验很差。产物的目录结构,我采用的是 dist 下分 es 和 lib 两个文件夹,再配合一个 types 文件夹收拢所有声明文件。
3.2 组件开发全流程:从零实现一个 Button
Button 是组件库里的“入门组件”,看起来人人会写,但把 Button 写到完备程度,其实也蛮考验细节。
第一步,定 API。我前面提到过,Rea 的 Button 核心 props 很少,但实现时要注意几个点:
- variant:控制视觉层级,主要承担 primary、secondary、outline、ghost、danger 这几个语义。每个 variant 对应不同组合的设计令牌,而不是写死颜色。
- size:small、medium、large 三档。不同档位影响按钮的高度、横向内边距和字号。
- loading:为 true 时,按钮要展示一个 loading 图标,并且锁定点击事件。要注意 loading 和 disabled 的视觉效果是不同,前者保留语义但提示忙碌,后者表示不可互交。
- icon:需要在文字左侧渲染的图标节点。如果只传 icon,不传 children,那按钮就是纯图标形态,宽度要收缩为正方形比例。
第二步,写样式。我用的是 CSS 变量 + 普通 class 选择器,样式文件按组件维度独立存在。Button 的样式要处理 hover、active、focus-visible、disabled、loading 这几个态。这里有个常用的细节:在移动端上,hover 态不应该生效,所以我通过媒体查询把 hover 效果限定在 (hover: hover) and (pointer: fine) 的设备上,这样可以避免触屏设备的“粘滞 hover”问题。
第三步,写类型。Button 的 props 类型需要继承 React.ButtonHTMLAttributes,这样才能透传原生 button 标签的全部属性,比如 name、form、type 等。但注意,CSS 类名和样式相关属性要做 Omit 或按需重写,避免使用方拿到不必要的灵活性。
第四步,写测试。Button 的测试主要覆盖:不同 variant 是否渲染对应 class、点击事件是否触发、loading 时点击是否拦截、href 传入时是否渲染成 a 标签。这个组件虽然简单,但它建立了整个库的组件测试基调和测试工具函数的基础。
3.3 复杂组件 Table 的实现思路
Table 是组件库里最复杂的那一档,Rea 实现时也花了很多时间。核心难点不在于渲染一个表格,而在于把“数据”、“列配置”、“交互”三者解耦。
我用一个比较适合中小型业务的中庸方案:表头配置驱动渲染。columns 数组里每一项包含 title、dataIndex、width、align、render 等字段。Table 的泛型贯穿整个组件定义,比如TableProps<T>里 dataSource 的类型是 T[],columns 里 render 函数的第一个参数类型是 T,这样就实现了传入数据结构和列配置之间的强关联。
Table 的分页功能也没有单独依赖第三方库,而是自己实现了一个纯函数的分页计算模块。为什么要自己干?因为第三方分页器往往带有自己的 UI 和事件体系,想要完整融合到 Table 的布局里,总要写一大堆适配层。自己实现的成本反而更低,而且可以精确控制“数据总量超过一页时显示分页器,否则隐藏”这类小交互。
排序和筛选也是通过列配置声明式完成的。sorter 字段传入一个比较函数,过滤字段传入一个 filter 配置数组,组件内部维护当前排序状态和筛选状态,然后对 dataSource 做纯函数处理。注意,这里不直接修改原数组,而是基于原数组创建一个新的引用,因为 React 的重新渲染机制依赖不可变数据。
3.4 测试策略与流程集成
在 Rea 里,测试不是在组件完成后才补的,而是和组件开发同步推进的。我使用的是 Vitest 作为测试框架,配合 @testing-library/react 来渲染组件和执行交互测试。
我最常写的测试类型有两类。一类是渲染测试,验证组件在不同 props 下的输出是否符合预期。比如 Button 在 loading 状态下是否渲染了 loading 图标、是否设置了 aria-busy。另一类是交互测试,模拟用户的点击、输入、键盘操作,验证组件的响应是否符合设计。比如测试 Select 组件的键盘操作、打开和关闭行为、选项筛选逻辑。
测试里有一个容易被忽视的点:异步更新。涉及 setTimeout、requestAnimationFrame 或者 React 并发特性的组件,断言时机不对会出现“测试偶发挂掉”的问题。这通常需要用 waitFor 或 findBy 这类异步查询方法来处理。我在 Table 组件测试里就踩过这个坑,当时排序后列表的更新是异步的,测试一直在用同步的 getBy 查询,结果时好时坏。后来改成 await screen.findByText 才稳定。
3.5 构建与发布流程
组件库的发布流程可以做到很自动化,但这里面有很多非技术层面的坑。我当时的做法是:用 semantic-release 做版本管理,遵循 Conventional Commits 规范。每次提交代码时,commit message 里标注 fix、feat、breaking change 等类型,release 阶段就能自动计算出下一个版本号是 patch、minor 还是 major。
发布前的构建脚本,我拆成了几步:清理 dist、类型检查、单元测试、构建 ESM 和 CJS 产物、生成类型声明。有一个很重要但容易被忽略的步骤是为 package.json 提前设置好 files 字段,只把你真正要发布的目录和文件打包释放出去,不要把源码、测试文件这些通通晒到 npm 上。这样既减少包体积,又避免把内部实现细节暴露成公共 API。
4. 常见问题与排查技巧实录
4.1 样式主题切换时,我应该用 CSS 变量还是重新加载所有样式?
这是一个我在设计 Rea 时反复犹豫的问题。纯 CSS 变量方案可以做到切换零延迟,但如果你需要重度定制文本排版、复杂嵌套布局之类的场景,纯变量还是会显露出灵活性上的限制。更建议的方式是“变量为主,特殊样式覆盖为辅”,把主题相关的核心属性通过变量抽离,把结构化样式留在组件内部。
如果你想做到用户根据当前系统主题自动切换明暗模式,可以通过媒体查询prefers-color-scheme: dark动态改变默认主题变量,再给用户一个手动切换的按钮覆盖即可。但要注意一点,如果你需要在运行时挂载多个主题变量集合,务必使用类名作用域来隔离,避免全局污染。否则你会发现,某个页面里切换了主题,所有页面都被带跑了。
4.2 组件库是否需要兼容 IE 或者旧版浏览器?
如果项目没有历史包袱,我的建议是直接放弃对 IE 的兼容。真的,很多年前你可能会为了一个劳什子浏览器环境去调整 CSS 变量的回退方案,但那是在旧技术栈统领江湖的时期,React 都开始逐步收敛对旧浏览器的支持了,组件库再去迁就老环境只会拖垮自己的设计体系。
当然,如果实在绕不开必须兼容,那你还有一条路:利用 CSS 变量和@supports检测能力,在不支持或者支持不完全的环境下提供静态主题样式。这类方式的问题在于需要维护两套样式输出,而且无法实现运行时动态换肤。比起收益,这个成本大多数时候是不划算的。
4.3 TypeScript 类型提示不友好,枚举值全部显示 any
这个问题多数出现在“泛型穿透”没做好的时候。例如 Table 的 columns render 函数里,如果参数类型硬编码为any,那使用方拿到的是没有任何提示的函数参数,这对开发体验是毁灭级的。
解决方案是在 TableProps 和 ColumnType 中都使用泛型占位,让 render 的参数类型来自 dataSource 数据的结构。类似的,Select 的 options 类型和 onChange 的参数类型也应当通过泛型关联。所有组件库的公开类型,我建议都显式导出在组件入口文件里,而不是藏在一大堆内部类型定义深处,否则使用方导入类型都要挖半天。
在 Rea 的实际开发中,我养成的习惯是,任何一个组件在完成功能之后,至少要在项目里写一组使用范例,专门测试类型推导是否顺畅。这一步从 IDE 的自动补全提示里能非常直观地感受到类型质量。
4.4 组件库的体积优化与控制
组件库最终会被应用打包进业务项目,所以体积控制是炉火纯青的基本功。很多人容易忽略的是,如果组件库的入口文件整体导出所有组件,那么业务项目在不使用 Tree-Shaking 的情况下,会把整库引入,打包体积直接膨胀。所以 Rea 的构建产物除了提供整体入口之外,还额外为每个组件生成了独立的子路径导入入口。你用的不是整库,而是@scope/rea/es/button这样的路径,可以让打包器只解析对应模块。
还有一个容易被忽略的体积陷阱:把所有组件的样式打包成一个巨大的 CSS 文件。即使代码没执行,样式也被一起加载了。Rea 的做法是每个组件的样式独立输出,在应用里配合 CSS 的按需加载或者自动注入机制实现精准加载。这样做虽然多了一些配置成本,但带来的收益比全局 CSS 大得多。
4.5 组件库维护阶段最容易翻车的点
维护组件库最怕的不是没有新需求,而是需求变更时影响到旧行为。这里我踩过一个大坑:某个业务团队需要修改 Table 排序的默认行为,把“点击表头直接排序”改成“先弹出下拉菜单选择排序字段”。当时我图省事,直接改了 Table 的默认排序触发方式,结果其他所有用 Table 但没有自定义配置的业务页面全部遭殃,测试全红。
这背后其实是一个维护模式的问题:组件库的默认行为一旦发布,就成为了“隐性契约”。除非你愿意为此发布一个破坏性版本,并且给出迁移方案,否则尽量不要改变默认值。如果你一定要新增一种交互模式,正确做法是新增一个 props 开关,让使用者显式选择新模式,而不是悄悄替换旧模式。
另外,组件库的文档必须与版本同步更新。像 API 变更、废弃提醒、迁移说明这类信息,写进 README 不过瘾,最好是搭一个文档站,把每个版本的 changelog 都附上。这也省得使用方总是跑到 GitHub Issues 里问你“这个功能为什么没了”。
我个人在这套项目上最深的体会是:组件库的难点从来都不是某个组件的实现难度,而是所有组件叠加在一起后的约束一致性与可持续发展性。约定规范比灵感重要,克制比炫技重要,类型体验比实现速度重要。如果你也在自建组件库,上面提到的这些问题大概率会逐一出现。别怕踩坑,关键是踩完之后把教训固化到代码、测试和文档里,让后来者不必再踩一遍。从内部工具变成团队公共设施,这本身就是一件值得认真对待的事。