前端组件库设计:复用不是把所有参数都暴露出去

同一个 Button,什么时候该多给一个 prop,什么时候该把口子焊死,这条边界画不清,组件库迟早失控。

判断标准其实不复杂:这个可配置项是设计系统认可的变化轴,还是业务临时想改的一处样式。前者该有,后者不该有。可现实里大家常常反着来——一个 Button 暴露几十个 props:颜色、圆角、字体、阴影、内边距、hover 样式、图标位置。看着灵活,实际是把设计系统的约束全甩给了业务页面。每个页面都能调一点,最后全站按钮长得都不一样。

好的组件库不是无限自由,而是把常见场景做到开箱即用,把不该发生的变化挡在外面。下面这几节,基本就是我判断"这个口子开不开"时会过的几道关。

第一道判断:基础组件还是业务组件

这两类组件的设计取向是相反的,先分清楚,后面的判断才有依据。

基础组件解决通用交互,Button、Input、Select、Modal、Tabs、Table 这些。它们该稳定、抽象、少依赖业务。判断一个组件属于哪类,有个简单的问法:把它搬到另一个完全不同的项目里,还能不能直接用。Button、Modal 搬走就能用,是基础组件;UserPicker 离了这套用户接口和权限模型就是空壳,是业务组件。这个"可搬迁性"测试比按名字归类靠谱——有些看着通用的组件其实早被业务逻辑绑死了,一搬就散架。业务组件解决业务语义,UserPicker、OrderStatusTag、PermissionTree、PriceInput、AuditTimeline 这些,它们可以理解业务规则、接口和权限。

两类别混在一起设计。我见过有人把"选择用户"的接口逻辑塞进通用 Select,结果 Select 越来越重,一个通用组件里掺进了业务的分页、搜索、权限判断,所有用到 Select 的页面都被拖累。更稳的做法是:通用 Select 只管选择交互,UserPicker 在外层组合它,把接口和权限收在业务组件这一层。

**这里最该守住的一条,就是别让业务语义渗进基础组件。**一旦渗进去,基础组件的稳定性就没了。

这两层之间其实还常有第三层,值得单独拎出来:领域无关但比基础组件更成型的"模式组件",比如带确认按钮的对话框、带搜索框的下拉、带分页的表格。它们不绑具体业务,但把常见组合固化了一层。放对位置能省很多重复,放错位置就两头不讨好——把它塞进基础层,基础层被撑肿;把它散在各业务里,又到处重复。我一般给它单独一层,明确标注"这是常见组合的封装,不是最小基础件",让人用的时候心里有数:要极致灵活就下到基础层自己拼,要开箱即用就用这层。分清这三层,比只分"基础/业务"两类更贴近真实工程里的复用结构。

props 表达意图,不是表达样式

组件 API 别只暴露样式细节。看这两种写法的区别:

1<Button color="#1677ff" radius={6} padding="0 16px" />

这等于让业务页面接管了视觉设计。更好的是暴露语义:

1<Button variant="primary" size="md" />

variantsize 是设计系统认可的有限选项,业务开发不需要关心具体颜色和间距。这里用 TypeScript 的字面量联合类型把选项焊死,越界的值编译期就报错:

1type ButtonProps = {
2  variant?: "primary" | "default" | "danger";
3  size?: "sm" | "md" | "lg";
4};

判断一个 prop 该不该有,就看它落在哪一侧:是设计系统的一个有限枚举(该有),还是一个连续的样式数值(大概率不该有)。当然,组件也要留少量扩展口,比如 classNamestyle,但这些是逃生通道,不是常规用法。

留逃生通道也要给它定规矩:className 挂在哪个节点、style 能覆盖哪些属性,最好在文档里说清楚,别让它变成一个"什么都能改"的后门。逃生通道的价值在于"极少数确实特殊的场景有出口",一旦大量业务都在走这条通道,说明组件的常规 API 没设计到位——该回头补一个正规的 variant 或 slot,而不是默许大家全靠 style 硬改。逃生口用得越多,越说明常规 API 这道口子开错了地方。

默认值该覆盖大多数场景

一个组件如果每次用都要传一堆 props,说明默认值没设计好。

1<Modal
2  width={520}
3  maskClosable={false}
4  destroyOnClose
5  centered
6  footer={null}
7/>

这套配置如果项目里 80% 都一样,就该变成默认值,或者干脆封一个业务层 Modal 把这套约定固化下来。组件库的目标是减少重复决策,常见场景开箱即用,少数特殊场景再配置。

这里也有个反向的判断:默认值不是越多越好。把过于业务化的默认值塞进基础组件,等于又把业务渗回去了。基础层给中性默认值,业务层再叠自己的约定,这样分工才清楚。

受控还是非受控,这条路线要一开始就定死

表单类组件绕不开一个设计选择:受控(值由外部 props 决定,变化靠回调通知)还是非受控(组件内部自己管状态)。这不是"支持哪个"的问题,是"默认走哪个、要不要两个都支持"的取舍问题。

一个 Input 如果既接受 value + onChange(受控),又接受 defaultValue(非受控),就得想清楚两者不能同时生效。常见的坑是使用方同时传了 valuedefaultValue,或者传了 value 却忘了给 onChange,组件就变成一个改不动的死输入框。

1type InputProps = {
2  value?: string;        // 受控
3  defaultValue?: string; // 非受控
4  onChange?: (v: string) => void;
5};

我的判断是:基础组件两种都支持,但文档里把"受控就必须给 onChange"这条写死,并且在开发环境下检测到 value 存在却没有 onChange 时打一条 warning。业务组件则倾向只暴露一种,别把这个选择继续往上抛。选择越少,误用越少。

组合优于巨型组件

组件最容易越写越大。比如 Table 想支持搜索、筛选、分页、批量操作、导出、权限、列设置,最后变成:

1<ProTable
2  search
3  exportable
4  batchActions
5  permission
6  columnSetting
7  request={...}
8/>

短期很爽,长期很难维护。一个 ProTable 用久了,你会不敢改它任何一个内部逻辑,因为不知道哪个页面靠着某个隐蔽的 prop 组合活着。每加一个功能,都要考虑它和其它功能怎么组合,props 之间的耦合会指数级涨。更麻烦的是这类巨型组件很难测——功能一多,组合出的状态数量爆炸,你没法给每种组合都写覆盖,最后测试要么形同虚设,要么改一个功能就得改一大片用例。更稳的是拆成可组合部件:

1<TableShell>
2  <TableToolbar />
3  <DataTable />
4  <TablePagination />
5</TableShell>

业务按需组合,复杂场景仍然能在业务层封装,但底层别只留一个巨型入口。判断标准在这里就是:一个 prop 是在描述"这个组件长什么样",还是在开关"一整块子功能"——后者往往是该拆成子组件的信号。

这一年 React 生态里越来越多人用 headless 的思路(比如 Radix、TanStack Table 这类只给行为、不给样式的库)来处理这个问题:逻辑和结构分离,交互由 hook 提供,样式完全交给使用方。这套思路正好对上"基础组件收着做行为、业务层贴场景做样式"的分工,我在评估要不要把内部表格往这个方向重构。

拆成部件之后有个新问题:这些部件之间要不要通信、怎么通信。TableToolbar 里的批量删除按钮,得知道 DataTable 里当前选中了哪几行。如果靠使用方一层层往下传 props,就把组合的清爽又拆没了。这类"一组协作部件共享状态"的场景,用 Context 做复合组件(compound component)更合适——外层 TableShell 建一个 Context 放共享状态,各部件自己取,使用方只管把部件摆好,不用手动接线:

1const TableCtx = createContext(null);
2
3function TableShell({ children }) {
4  const [selected, setSelected] = useState([]);
5  return (
6    <TableCtx.Provider value={{ selected, setSelected }}>
7      {children}
8    </TableCtx.Provider>
9  );
10}
11// TableToolbar / DataTable 内部各自 useContext(TableCtx)

但这条口子也有该守的规矩:Context 只放这组部件真正共享的交互状态,别把业务数据、请求逻辑也塞进去。一旦 Context 变成什么都往里丢的全局篮子,复合组件就退化成了另一个巨型组件,只是换了种写法。

样式扩展要有节制,插槽是正解

组件库要支持业务布局,但不能让业务随意改内部结构。我一般会开放这几类口子:className 挂根节点、style 做少量布局调整、slots 或 render props 替换局部内容、token 管主题和状态变量。

不鼓励业务用深层选择器改内部 DOM:

1.page .button span:nth-child(2) {
2  margin-left: 3px;
3}

内部结构一改,这段样式就坏。判断标准很直接:如果业务经常需要改某个内部区域,说明这个组件该提供一个 slot,而不是让业务靠选择器硬覆盖。硬覆盖是把组件的内部实现变成了业务依赖的契约,而内部实现本就该能自由变。这类深层选择器还有个连带麻烦:它是全局作用域的,一处 .page .button span 可能误伤到别处结构相似的组件,排查起来很费劲。给足了 slot 和 token,业务就没动机去写这种脆弱的覆盖。

token 这块今年也更好落地了。CSS 自定义属性配合层叠层 @layer(去年随 Chrome 99 落地,现在主流浏览器都跟上了)能把组件库的默认样式放进较低优先级的层,业务覆盖时不用再靠堆选择器权重打"优先级战争"。主题切换也能靠一组 CSS 变量做,比早年那套 Sass 变量编译时定死要灵活。

token 分层也有取舍值得设计。一套成熟的做法是分两级:底层是"原始 token"(--color-blue-500--space-4 这类中性值),上层是"语义 token"(--color-primary--space-card-padding,指向某个原始值)。组件只消费语义 token,不直接引原始值。这样换主题只改语义层的映射,组件一行不用动;某个原始色值调整,所有引用它的语义 token 一起跟着变。业务能覆盖的口子也就限定在语义 token 这一层,既留了定制空间,又不会让业务直接去改底层调色板把全站带偏。这条分层,本质还是那句话——把变化收敛到一个稳定、有限的轴上。

深色模式也靠这套自然落地:语义 token 在 :root[data-theme="dark"] 下各指向一组不同的原始值,组件消费的语义名不变,切主题只是换了底层映射。要是组件里到处硬编码颜色值,深色模式就得挨个组件改,那才是真正的维护灾难。所以主题能力强不强,很大程度上取决于当初有没有把颜色都收进语义 token,而不是等要做深色模式了才回头补。

顺带提一个今年可以试的新能力:CSS 容器查询(去年下半年随 Chrome 105/106 落地,Safari 16 也已支持)让组件能按自己容器的宽度而不是视口宽度来响应。对组件库很对味——一个卡片放在侧边栏还是主区域,该显示成窄版还是宽版,本来该由它所在的容器决定,而不是由整个页面的媒体查询决定。落地才半年多,一些老版本浏览器还没覆盖到,我暂时只在内部项目里拿它做几个卡片组件试水,还没往对外的组件库里铺。

无障碍和键盘交互,是基础组件必须兜住的底

无障碍不该是业务每处自己补,而该由基础组件默认兜住——这也是一条该守住的分工:可访问性属于组件的行为契约,不属于业务的自由发挥。

一个 Modal 打开时焦点该进到弹窗内、Tab 不该跳到弹窗背后的页面、按 Esc 该关闭、关闭后焦点该回到触发它的按钮。这套焦点管理(focus trap)如果每个业务页面自己写,一定写得七零八落。放进基础 Modal 里做一次,全站受益。同理,下拉菜单要支持方向键选择、Tabs 要支持左右键切换、危险按钮要有可访问名。

1// 图标按钮:视觉上只有图标,但必须给屏幕阅读器一个名字
2<IconButton icon={<TrashIcon />} aria-label="删除这一行" />

判断标准是:凡是"不做用户就用不了键盘或读屏"的能力,归基础组件默认提供;业务只在有特殊语义时补充 aria-label 的文案。把这条分工立住,无障碍才不会变成上线前临时补的欠债。

校验和错误展示,判断逻辑要留在组件外

表单组件很容易把校验逻辑也一起吞进去,这里最容易手痒,也最需要克制。一个 Input 该不该知道"手机号必须 11 位"?我的答案是不该。校验规则是业务的,Input 只该负责展示错误态——给它一个 statusmessage,具体怎么校验、什么时候校验,由外层表单或业务决定。

1<Input
2  value={phone}
3  onChange={setPhone}
4  status={error ? "error" : undefined}
5  message={error}
6/>

这么分的好处是 Input 保持通用,同一个组件既能配"手机号校验"也能配"邮箱校验",规则变了不用动组件。反过来,如果把某条业务规则写进基础 Input,它就再也不通用了。

校验编排交给表单容器组件(收集字段、触发校验、聚合错误),基础输入组件只管把错误状态显示出来——这条分工是基础组件保持稳定的关键。

校验时机也归表单容器管,别下放到输入组件。是失焦就校验、还是提交时才校验、还是边输边校验,这属于交互策略,不同表单可能不一样。把它留在容器层,同一个 Input 就能适配所有策略;一旦 Input 自己决定"我一失焦就飘红",就把策略焊死了,遇到"提交前不打扰用户"的表单又得开新口子。分工划在这里,输入组件才真的通用。

文档要写反例,不只写 API 表

组件文档别只列 props 表。更值钱的是:什么时候用、什么时候不用、常见组合、错误用法、和相似组件的区别。

比如 Button 文档里就该写明:普通跳转用 Link,别用 Button 假装链接;危险操作用 danger variant;纯图标按钮必须给 aria-label,不然屏幕阅读器读不出来。无障碍这条我一般会单列出来——基础组件是全站复用的,一个 Button 缺了可访问名,全站的图标按钮就一起缺。基础层把这些做对,业务层就不用每处补。

文档最好是活的,别是一份和代码分家的静态页。今年用 Storybook 这类工具把每个组件的各种状态做成可交互的 story,既是文档也是开发时的调试台,还能直接接上前面说的视觉回归和无障碍检查。文档一旦能跟着代码一起跑、一起测,就不容易过期——最怕的是 API 改了、文档还停在半年前,新人照着写全是坑。让文档和组件长在一起,共识才不会烂尾。

组件库维护的从来不只是代码,是团队共识。文档就是共识的载体,反例比正例更能防止误用。

测试该测行为,不该测实现细节

组件库要长期维护,测试是绕不开的一层,但测什么很有讲究。一个常见的错误是去断言内部 DOM 结构、className 或 state 的具体值——这跟前面说的"别让业务依赖内部结构"是同一个道理,测试也不该依赖内部结构,否则组件一重构、内部结构一变,一堆测试就红了,可对使用方而言行为根本没变。

更稳的是测行为:用 Testing Library 这类工具,从使用者视角出发,点这个按钮、弹窗该出现,输入这个值、错误提示该显示,Esc 按下、弹窗该关闭。断言的是"用户能观察到什么",不是"组件内部长什么样"。

1test("点击触发按钮后弹窗打开", async () => {
2  render(<Modal trigger={<button>打开</button>}>内容</Modal>);
3  await userEvent.click(screen.getByText("打开"));
4  expect(screen.getByRole("dialog")).toBeVisible();
5});

这类测试还顺带逼你把可访问性做对——getByRole("dialog") 能查到,说明你给对了 role。测行为的测试改起来省心,也正好是组件契约的一份可执行文档,比手写的 API 表更不容易过期。

视觉这块光靠单元测试盖不住,样式改坏了行为测试往往还是绿的。所以我会另配一层视觉回归:给关键组件的各个 variant、各个尺寸、各个状态截图存基线,改动后重新截图对比,像素级差异超阈值就标出来让人确认。这层对组件库尤其值——一个基础组件全站复用,它的 padding 被误改了 2px,靠肉眼在几十个页面里根本发现不了,视觉回归能在合并前就把它拦下。行为测试保交互不坏,视觉回归保外观不漂,两层各管一段,组件库才敢频繁改动而不心虚。

打包和按需引入,也是设计的一部分

组件库怎么打包、怎么分发,直接影响用它的项目首屏大不大,这条常被当成纯工程问题,其实也是设计取舍的一部分。一个组件库如果只导出一个大 bundle,业务只用了一个 Button 也可能把整包拉进去。

要让按需引入真正生效,得从两头下手。库这边:保证 ESM 产物、每个组件独立可导入、package.json 里标 "sideEffects": false(或精确列出有副作用的文件,比如全局 CSS),这样打包工具的 tree-shaking 才敢摇掉没用到的部分。使用方这边:优先具名导入 import { Button } from "ui",而不是 import * as UI

1{
2  "sideEffects": ["*.css"],
3  "exports": {
4    "./button": "./dist/button.js",
5    "./modal": "./dist/modal.js"
6  }
7}

样式的按需也要一起考虑。如果组件的 CSS 是一整个全局文件,那不管你 JS 摇得多干净,样式还是整包进来。用 CSS Modules 或把样式随组件拆开,才能做到"用哪个组件、进哪份样式"。这些决定看着是构建配置,实际决定了你的组件库对使用方的首屏友不友好——一个复用性再好、但用一个组件就拖进 200KB 的库,业务是不敢随便用的。

破坏性变更也有红线,别随手改公开 API

组件库和业务代码最大的不同,是它的 API 是契约。业务里改个函数参数只影响自己,组件库改个 prop 的默认行为,可能一次动几十个页面。所以"哪些改动算破坏性"这条红线,比业务代码要严得多。

我给团队定的判断很简单:删 prop、改 prop 默认值、改 DOM 结构导致外部选择器失效、改事件触发时机,这几类都算破坏性,要走大版本。反过来,加一个带默认值的可选 prop、加一个 slot,这类是向后兼容的,可以小版本。真要淘汰一个 prop,先标 deprecated、在开发环境打 warning 提示替代方案,留一到两个版本再删,而不是一刀切。

这也反过来印证了前面那些克制:一开始暴露的 props 越少、语义越稳,将来要背的兼容包袱就越轻。每多暴露一个样式细节,都是给未来的自己多签一份不能随便改的契约。能不暴露的就先不暴露,加 prop 容易,收回来难。

API 一致性:同类概念要用同一套命名

一个容易被忽略的问题是跨组件的一致性。单看每个组件的 API 都合理,放在一起用才发现:这个组件叫 disabled、那个叫 readonly 又暗含了 disabled 的意思;这个尺寸叫 size="md"、另一个叫 size="middle";这个关闭事件是 onClose、那个是 onCancel。使用方每换一个组件都要重新查文档,复用带来的省事就被这些不一致抵消掉了。

所以组件库该有一份自己的命名约定,同类概念全库统一:尺寸一律 sm | md | lg,禁用一律 disabled,变化轴一律 variant,受控值一律 value + onChange。这条约定越早立越好,等几十个组件各写各的再来统一,就是一次大范围破坏性变更。判断一个新 API 该怎么命名,第一步不是想"这个组件里叫什么合适",而是"库里同类的东西已经叫什么了"。

落地时可以把这套约定写成 TypeScript 的共享类型和 lint 规则,让不一致在代码评审甚至编译期就暴露,而不是靠人肉记忆去守。比如所有尺寸 prop 都引用同一个 Size 类型,谁想新造一个 size="middle" 编译就不过。约定光写在文档里没人看,长进类型和工具里才真守得住。

一致性还包括受控/非受控的默认取向、事件命名的时态、布尔 prop 的默认值方向(一般默认 false,disabled 而不是 enabled)。这些看着琐碎,但它们决定了使用方能不能"猜对" API——一套可猜的 API,本身就是最好的文档。

一份可以照着过的判断清单

设计一个组件时,我会顺着问下去:它是基础组件还是业务组件;props 是在表达语义还是在暴露样式细节;默认值有没有覆盖主流场景、有没有把业务默认值塞进基础层;能不能通过组合拆小、有没有哪个 prop 其实在开关一整块子功能;扩展口稳不稳定、是不是逃生通道而非常规路径;有没有让业务依赖内部 DOM 结构;文档有没有写清适用场景和反例。

这份清单不是每条都得逐字过,它更像一组用来判断分寸的问题。具体某个 API 怎么写通常不难,难的是判断"这个变化该不该开放"——开多了组件失控,开少了业务嫌它死板。这条线没有放之四海的标准答案,得结合组件所在的层、变化出现的频率、以及改动的传播范围来判断。

复用不是把所有可能性都塞进一个组件,而是找到稳定的变化轴,把它做成有限的、语义化的选项,其余的变化挡在外面。**组件越基础,越要克制;组件越业务,越要贴近真实场景。**那个把颜色、圆角、阴影全暴露出去的 Button,问题不在参数多不多,是它把设计系统该守住的约束,让给了每个业务页面自己拿主意。