前端组件设计实践:抽象边界、受控状态与组合 API

组件设计里最耗人的判断,不是怎么写,是要不要抽、抽到哪一层——这道题我几乎每次动笔前都要先过一遍。

同样两个列表页,摆在你面前:一种做法是各写各的,接受一点重复;另一种是马上提炼一个统一的列表组件,把分页、筛选、空态、加载态全收进去。哪种对?答案不在"要不要复用",而在这两个页面的相似是不是稳定、后面会不会分叉。判断错了方向,后面无论怎么改代码都在还债。

我自己交过最贵的一次学费,就是把一个业务表格越抽越"大"。起点很朴素,只想复用分页和 loading。然后加筛选、加导出、加批量操作、加权限按钮、加空态文案、加固定列、加接口请求,最后 props 多到没人敢动。它确实复用进了十几个页面,可每个页面都被它的抽象反过来限制。经过那一遭,"通用组件"这四个字在我这儿是要打个问号的。

能用不代表可维护,区别在第二次复用

组件第一次写出来往往都挺好用,因为它只服务一个页面、一个场景、一个需求。它该管什么、不该管什么,是自己隐含定下的,没人挑战。

真正的考验在第二次、第三次复用。这时的典型剧情是这样的:

  • 新场景需要一个额外参数
  • 旧交互要兼容新的样式
  • 一个组件开始同时承担多个职责
  • props 越加越多
  • 内部状态越来越难预测

组件没坏,但它在变重。更麻烦的是,这些越来越重的组件常常还挂着"通用组件"的名字,于是后来的业务只能继续往里塞参数——名字骗了所有人。

所以判断一个组件好不好,别看它第一次多顺手,要看它扛过第二次复用之后有没有变形。

该不该抽:三次法则,和"能力小组件"优先

回到最前面那道判断题。我现在的取舍很简单:**重复出现三次以上、且用法稳定,才沉淀成组件;只有两个场景时,先忍着重复。**保留一点重复,通常比抽一个不成熟的组件便宜得多,因为重复是显式的、好删的,而错误的抽象是隐式的、会绑住一堆调用方的。

过早抽象最典型的产物长这样:

1<DataList
2  columns={columns}
3  showFilter={true}
4  filterSlot={filterSlot}
5  loading={loading}
6  emptyText="暂无数据"
7  showToolbar={true}
8  toolbarActions={toolbarActions}
9  pagination={pagination}
10  rowActions={rowActions}
11  customHeader={customHeader}
12/>

参数越堆越多,说白了是在用 props 模拟不同页面。这不是复用,是在造另一个配置系统,而且是没人维护文档的那种。

我的另一条经验是:先抽"能力小组件",别一上来抽"业务大组件"。先沉淀 EmptyStateConfirmDialogToolbarButtonPagination 这种,等多个页面的组合方式稳定了,再谈上层组件。小组件抽错了好替换,大组件抽错了通常绑着一片页面一起遭殃。

比起用一个 DataList 收全部,我更愿意让页面自己拼装:

1function ProductListPage() {
2  const { data, loading } = useProducts();
3
4  return (
5    <div>
6      <ProductFilter />
7      {loading ? <Spinner /> : null}
8      {data.length === 0 ? (
9        <EmptyState text="暂无商品" />
10      ) : (
11        <ProductTable rows={data} />
12      )}
13      <Pagination {...pagination} />
14    </div>
15  );
16}

看起来"没那么复用",但每个小块该管什么都清楚,页面想加个导出按钮、想换个筛选布局,直接在这一层改,不用去动一个所有人共用的大组件。等到三四个列表页拼下来的骨架真的长得一模一样了,再把这个骨架抽成模板也不迟——那时这套划分是被现实验证过的,不是我拍脑袋想出来的。

好组件通常只解决一个明确问题

抽出来之后,怎么算抽对了?稳定的组件有个共同点:职责单一,只解决一个明确问题。

  • Button 只负责按钮语义和样式
  • Modal 只负责弹层显示与关闭
  • Table 只负责表格展示,不掺业务筛选逻辑
  • Upload 只负责选择和上传文件,不决定业务归属

职责清楚,后面每个判断都会变简单:这个需求该不该进组件,这个参数是不是越界了,这段逻辑放外层还是内层。一个克制的按钮组件是这样的:

1type ButtonProps = {
2  variant?: "primary" | "secondary" | "danger";
3  disabled?: boolean;
4  loading?: boolean;
5  children: React.ReactNode;
6  onClick?: () => void;
7};
8
9export function Button({ variant = "primary", loading, disabled, children, onClick }: ButtonProps) {
10  return (
11    <button
12      className={`btn btn-${variant}`}
13      disabled={disabled || loading}
14      onClick={onClick}
15    >
16      {loading ? "处理中..." : children}
17    </button>
18  );
19}

它不关心"保存用户"还是"删除文章",只关心按钮本身的状态和样式。反过来,一个叫 CommonButton 的组件如果里面判断了订单、文章、客户三种业务状态,它早就不通用了。组件通不通用,不看文件名,看它依不依赖具体业务概念。

展示和业务,最好是两个组件

很多组件难复用,根子在它既管 UI 又管业务。比如一个"用户列表组件"里同时干了:请求用户列表、处理分页、判断权限、渲染表格、控制删除弹窗、调删除接口。这组件在当前页面可能很顺,可换个页面只想复用表格展示时,就被接口、权限、弹窗一并绑死了。

拆成容器加展示会清爽很多:

1function UserListContainer() {
2  const { data, loading } = useUsers();
3
4  return (
5    <UserTable
6      users={data}
7      loading={loading}
8      onEdit={openEditDialog}
9      onDelete={confirmDelete}
10    />
11  );
12}

UserListContainer 管业务数据,UserTable 管展示。以后别的页面要展示用户表格,不必背上原来的请求逻辑。这个拆法还顺带救了测试:展示组件用固定 props 做快照或交互测试,不用起 mock server:

1test("空列表展示空态", () => {
2  render(<UserTable users={[]} loading={false} />);
3  expect(screen.getByText("暂无数据")).toBeInTheDocument();
4});

容器组件则专注请求、权限、状态流转,测试时把 useUsers 这个 hook mock 掉就行。两类逻辑揉在一起,你想测一个表格的空态,都得先把一整套请求链路搭起来,测试往往又慢又脆。展示和业务分开之后,测试能不能好写,其实是这条职责划得清不清的一面镜子——难测,通常就是揉在一起了。

props 要表达意图,别用一堆布尔值

props 不是越灵活越好。一个常见的坑是暴露太多布尔:

1<Dialog showFooter showCancel showConfirm danger large centered />

布尔一多,组合状态就爆炸,后面根本说不清哪些组合是合法的。largecentereddanger 到底长什么样,没人敢保证。

更好的是用明确的模式表达意图:

1type DialogVariant = "default" | "danger";
2type DialogSize = "sm" | "md" | "lg";

或者把复杂区域交给组合:

1<Dialog>
2  <Dialog.Header>删除文章</Dialog.Header>
3  <Dialog.Body>删除后不可恢复,确认继续吗?</Dialog.Body>
4  <Dialog.Footer>
5    <Button variant="secondary">取消</Button>
6    <Button variant="danger">删除</Button>
7  </Dialog.Footer>
8</Dialog>

组合式 API 比一个巨大的配置对象更接近 UI 本身,也更好扩展。配置对象不是不能用,表格列、表单 schema 这类结构化场景就很适合。但一旦配置里开始塞函数、插槽、权限、请求、展示条件,就该警惕了:你多半正在用 JSON 写一个小框架。组件 API 服务当前问题就好,别把未来所有可能性提前装进去。

受控还是非受控,状态该交给谁管要一开始就想清楚

组件可以有内部状态,但得分清哪些该由外部控制。Modal 是否打开,通常该父组件说了算:

1<Modal open={open} onOpenChange={setOpen}>
2  ...
3</Modal>

这样父组件能根据路由、权限、提交结果决定弹窗状态。弹窗要是完全自己维护开关,外部想插手就会很别扭。适合藏在组件内部的,是纯 UI 细节:hover、临时动画、输入法组合态;涉及业务流程、数据提交、跨组件同步的,别藏。

有些组件想同时支持受控和非受控,比如 Tabs:

1type TabsProps = {
2  value?: string;
3  defaultValue?: string;
4  onValueChange?: (value: string) => void;
5};

这模式灵活,但实现时最怕"双源状态"。传了 value 就是受控,组件内部只能通过回调通知外部,不能再自作主张改最终状态;否则父子各存一份当前值,迟早不同步。我写这类组件会先抽一个很小的受控状态工具,把判断逻辑钉死:

1function useControllableState<T>({
2  value,
3  defaultValue,
4  onChange,
5}: {
6  value?: T
7  defaultValue: T
8  onChange?: (value: T) => void
9}) {
10  const [innerValue, setInnerValue] = React.useState(defaultValue)
11  const isControlled = value !== undefined
12  const current = isControlled ? value : innerValue
13
14  const setValue = React.useCallback(
15    (next: T) => {
16      if (!isControlled) {
17        setInnerValue(next)
18      }
19      onChange?.(next)
20    },
21    [isControlled, onChange]
22  )
23
24  return [current, setValue] as const
25}

Tabs、Switch、Accordion 都走同一套规则:传了 value 就受控、只发 onChange;没传才用内部状态。省得每个组件重写一遍半受控逻辑,也避开"外部传了 value 内部又 setState"这类同步 bug。还有一处容易漏的规则:defaultValue 只在初始化生效,不该在每次 props 变化时重置内部状态。不少人以为改 defaultValue 能改当前值,但按 React 的组件语义,真要控当前值就该传 value

别忘了给基础组件留一条透传的口子

还有一个基础组件特别容易漏、后来又特别难补的设计:ref 和原生属性的透传。一个封装得很干净的 Input,如果不往下透传 ref...rest,等哪天需要给它挂 onBlurautoFocusaria-*,或者要拿到 DOM 节点做聚焦、做定位,就会发现无路可走,只能回头改组件签名,而这时它可能已经被几十处引用了。

所以基础组件我一般一开始就留好这条口子:

1import React from "react";
2
3type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
4  invalid?: boolean;
5};
6
7export const Input = React.forwardRef<HTMLInputElement, InputProps>(
8  function Input({ invalid, className, ...rest }, ref) {
9    return (
10      <input
11        ref={ref}
12        className={`input ${invalid ? "input-error" : ""} ${className ?? ""}`}
13        {...rest}
14      />
15    );
16  }
17);

forwardRef 让外部能拿到真实节点,...rest 让原生属性自然穿透,className 留给调用方补样式。这几样都是基础组件的"预留接口"——不预留,将来每加一个需求都要动组件本身;预留了,调用方大多数扩展都能在外部完成,不必回来碰你的核心逻辑。这条只对真正的基础组件成立,业务组件反而不该无脑透传,否则又分不清谁该管什么了。

设计系统不是组件仓库

团队做组件库时有个通病:把所有组件都往里扔,最后组件库变成第二个业务仓库。有价值的设计系统,先沉淀的应该是稳定的设计 token、基础组件和交互约定,而不是攒数量。设计 token 这一层尤其值得先立住——把颜色、间距、圆角、字号收成一组变量,基础组件都引这组变量,将来换主题、调规范就是改 token,不必逐个组件去动:

1:root {
2  --color-primary: #2563eb;
3  --color-danger: #dc2626;
4  --radius-md: 6px;
5  --space-2: 8px;
6}

组件本身我会分成三类:

  • 基础组件:按钮、输入框、弹窗、提示
  • 业务组件:用户选择器、组织树、权限面板
  • 页面片段:某页专用的卡片、筛选区

只有基础组件适合进真正的公共层。业务组件可以共享,但要承认它带业务语义;页面片段就留在页面附近,别硬包装成通用能力。硬把业务组件塞进公共库,等于把业务的易变性注入了本该稳定的地基。

回到那道判断题

绕了一圈,还是回到最前面那个"要不要抽"。两个页面相似,不代表抽象已经稳定;三个页面都在用,也不代表这个组件就该继续加参数。真正值得抽的,是那些职责清楚、变化方向稳定、调用方不必理解内部业务流程的东西。

好组件不是"参数最多",也不是"复用次数最多",而是职责清楚、行为稳定、扩展方式自然。组件设计最难的是克制:别过早抽象,别用 props 模拟整个业务系统,别让展示组件背上业务流程。真正好用的组件,往往只解决一个明确问题,再靠组合去适应变化。那张越抽越大的表格,最后我是拆成一个纯展示的 Table 加几个能力小组件,才算把它从"没人敢改"救回来的。