React 组件库与设计系统:从基础组件到业务组件层

把 React 写成一堆散组件不难,难的是把它们抽象成一套能长期维护的组件库和设计系统。
这里从几个层次往下拆:基础组件、组合组件、业务组件,以及它们和设计规范之间的关系。

可以先把组件大致分成三层:

  • 基础组件(Basic)

    • Button / Input / Select / Modal / Tabs / Popover 等;
    • 对应设计系统里的最小交互单元和基础 pattern。
  • 组合组件(Composite)

    • 由多个基础组件拼出来的通用结构,比如表单布局、搜索栏、数据表格(不含业务字段)、步骤条等;
    • 强调整体行为和布局,而不是具体字段。
  • 业务组件(Business)

    • 直接绑定某个业务领域,例如「订单列表表格」「权限配置面板」「审批流编辑器」;
    • 对特定业务字段/流程有强依赖。

分清这三层之后,一个直接的好处是:
当需求变化时,先判断这是设计规范变化,还是业务规则变化,再决定动哪一层。

基础组件尽量对齐设计系统中的 Token(颜色、间距、圆角、字号等),而不是在组件内写死各种 magic number。

 1type ButtonVariant = 'primary' | 'secondary' | 'ghost';
 2type ButtonSize = 'sm' | 'md' | 'lg';
 3
 4type ButtonProps = {
 5  variant?: ButtonVariant;
 6  size?: ButtonSize;
 7} & React.ButtonHTMLAttributes<HTMLButtonElement>;
 8
 9function Button({ variant = 'primary', size = 'md', className, ...rest }: ButtonProps) {
10  const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
11
12  return <button className={classes} {...rest} />;
13}

这里的 variant / size 对应的应该是设计系统里已经约定好的几种按钮风格。
一旦设计规范发生调整,只需要在样式层面更新对应 Token,组件 API 可以保持稳定。

组合组件主要工作是把基础组件按某一种高频交互模式组织起来:

  • 搜索栏:Input + Select + Button + 条件布局;
  • 表单布局:多列栅格、表单项标签、错误展示的统一规则;
  • 数据表格:分页、排序、筛选、批量操作等。

例如一个通用搜索栏:

 1type SearchBarProps = {
 2  value: string;
 3  placeholder?: string;
 4  onChange: (value: string) => void;
 5  onSearch: () => void;
 6  extra?: React.ReactNode;
 7};
 8
 9function SearchBar({ value, placeholder, onChange, onSearch, extra }: SearchBarProps) {
10  return (
11    <div className="search-bar">
12      <input
13        value={value}
14        placeholder={placeholder}
15        onChange={e => onChange(e.target.value)}
16      />
17      <button onClick={onSearch}>搜索</button>
18      {extra && <div className="search-extra">{extra}</div>}
19    </div>
20  );
21}

业务方只需要往 extra 里塞筛选项,就能复用「搜索栏长什么样、按钮放哪儿、间距如何」这些约定。

业务组件不可避免要理解具体领域概念,但可以尽量做到:

  • API 围绕业务对象设计,而不是围绕某个 UI 库;
  • 避免直接暴露第三方库的细节(比如直接把 antd 的 props 全挂上来)。

例如一个「订单列表」组件:

 1type Order = {
 2  id: string;
 3  status: 'pending' | 'paid' | 'cancelled';
 4  amount: number;
 5  createdAt: string;
 6};
 7
 8type OrderListProps = {
 9  orders: Order[];
10  onViewDetail?: (id: string) => void;
11};
12
13function OrderList({ orders, onViewDetail }: OrderListProps) {
14  // 内部可以用任意表格实现,但对外只暴露 Order 相关的 API
15}

这样,即使未来从某个 UI 框架迁移到另一个,业务组件的使用方感知到的变化也尽量少。

设计系统里经常会有多主题或暗色模式需求。
比较常见的做法是:

  • 在样式层定义一套 Token(通过 CSS 变量、SASS 变量等);
  • 用顶层的 <ThemeProvider>className 切换主题;
  • 组件内部尽量只引用 Token,而不是硬编码颜色值。

例如:

 1// 全局样式里
 2:root {
 3  --color-bg: #ffffff;
 4  --color-text: #222222;
 5}
 6
 7[data-theme='dark'] {
 8  --color-bg: #121212;
 9  --color-text: #f5f5f5;
10}

组件里只关心变量:

1function Card({ children }: { children: React.ReactNode }) {
2  return <div className="card">{children}</div>;
3}

切换主题时,只需要在顶层切换 data-theme 即可:

1document.documentElement.dataset.theme = 'dark';

组件库一旦要被广泛复用,可访问性最好在组件层就考虑进去,而不是留给每个业务页面自己填坑。

典型的工作包括:

  • 对话框:管理好 ARIA 属性和焦点陷阱,关闭后把焦点恢复到触发按钮;
  • 表单:错误提示和字段关联;
  • 键盘交互:Tab、方向键操作列表、菜单、选项卡等。

实践上可以:

  • 尽量复用已经处理好 a11y 的底层库(如 Radix UI、Headless UI 等);
  • 或者在自己实现这些基础能力时给出清晰的约定和示例。

很多团队会在现有 UI 库(如 antd、MUI、Chakra 等)之上再包一层自己的组件。这一层常常充当:

  • 统一主题与风格的入口;
  • 封装与业务相关的默认行为;
  • 屏蔽不同版本/不同库之间的差异。

典型方式是:

  • 底层选一个稳定的 UI 库;
  • 在其之上建立自己的 @company/ui 封装层,对外只暴露这一层;
  • 真正使用时,业务项目全部从 @company/ui 引入组件。

这样做可以给未来的技术栈调整留出空间。

  • 把组件划分成基础、组合和业务三层,有助于在需求变化时知道该动哪一块。
  • 基础组件紧贴设计 Token,组合组件固化常见交互模式,业务组件围绕领域对象设计 API。
  • 主题与暗色模式应通过 Token 和顶层主题切换实现,组件内部尽量不关心具体颜色值。
  • 组件库如果希望长期稳定可用,a11y 和与第三方 UI 库的边界都需要在设计时提前想清楚。