组件架构设计

前端组件架构设计——组件分层策略、Compound Components / Headless UI / Render Props 等核心模式、Props API 契约设计、组件边界划分与版本演进,是构建可规模化 UI 系统的工程基础。

组件架构设计

为什么组件架构是前端工程的核心问题

一个中大型前端项目通常包含 200-1000+ 个组件。当组件数量到达这个量级,真正决定项目可维护性的不是单个组件写得多好,而是组件之间的关系是否清晰、边界是否合理、API 契约是否稳定

组件架构要回答的核心问题:

  1. 分层:哪些组件是基础设施,哪些是业务逻辑,哪些是页面级编排?
  2. 复用模式:逻辑复用用什么模式?UI 复用用什么模式?两者如何解耦?
  3. API 设计:组件的 props 接口如何设计才能既灵活又不失控?
  4. 演进:组件 API 如何在不破坏消费者的前提下持续迭代?

这不是一个"写几个好看的 Button"的问题,而是一个系统设计问题


组件分层:从 Atomic Design 到工程实践

Atomic Design 的理论价值

Brad Frost 提出的 Atomic Design 将 UI 分为五层:

层级 定义 示例
Atoms 不可再分的最小 UI 单元 Button、Input、Icon、Badge
Molecules Atom 的简单功能组合 SearchBar(Input + Button)、FormField(Label + Input + Error)
Organisms 独立的功能区块,可包含业务逻辑 Header、ProductCard、CommentThread
Templates 页面级布局骨架,定义内容区域 DashboardLayout、AuthLayout
Pages 具体页面实例,填充真实数据 /dashboard、/settings/profile

Atomic Design 的价值在于建立了一种思考组件粒度的语言。但在实际工程中,它有几个问题:

  • Molecule 和 Organism 的边界模糊——一个 ProductCard 到底是 Molecule 还是 Organism?
  • 没有区分"有业务逻辑"和"无业务逻辑"的组件
  • 没有考虑状态管理、数据获取等工程关切

工程分层:更实用的四层模型

在实际项目中,以下分层更具操作性:

primitives/        ← 零业务逻辑的基础组件
  Button, Input, Modal, Tooltip, Avatar, Badge

patterns/          ← 可复用的交互模式(可能有轻量状态)
  DataTable, FormField, Pagination, Combobox, CommandPalette

features/          ← 绑定业务领域的功能组件
  UserProfile, OrderList, PaymentForm, NotificationCenter

layouts/           ← 页面级布局编排
  DashboardLayout, AuthLayout, SettingsLayout

关键区分标准

层级 是否有业务逻辑 是否可跨项目复用 数据来源
primitives ✅ 完全可复用 纯 props 驱动
patterns ❌ 或极少 ✅ 大部分可复用 props + 内部状态
features ❌ 业务绑定 API 调用、全局状态
layouts ⚠️ 部分可复用 children 组合

这个分层的核心洞察是:primitives 和 patterns 构成组件库(Design System),features 是业务代码,layouts 是页面骨架。三者的变更频率、测试策略、代码审查标准都不同。

分层的工程约束

好的分层不只是目录结构,还需要依赖方向约束

pages → layouts → features → patterns → primitives
                                ↓
                          (hooks / utils)
  • primitives 不能导入 features 或 layouts
  • features 可以导入 primitives 和 patterns,但不能导入其他 features(避免循环依赖)
  • layouts 只关心布局,通过 children 或 slot 接收内容

违反这个依赖方向,就是架构腐化的开始。可以用 ESLint 的 import/no-restricted-pathseslint-plugin-boundaries 来强制执行。


核心设计模式

组件设计模式解决的核心问题是:如何在保持组件封装性的同时,给消费者足够的灵活性?

这本质上是一个 控制权分配 问题——组件作者控制多少,组件消费者控制多少。

Compound Components(复合组件)

Compound Components 是一种声明式组合模式。组件的各个部分作为子组件暴露,消费者通过组合子组件来控制结构和顺序,而状态在内部通过 Context 共享。

// 消费者视角——声明式、灵活
<Select value={selected} onValueChange={setSelected}>
  <Select.Trigger>
    <Select.Value placeholder="Choose a fruit..." />
  </Select.Trigger>
  <Select.Content>
    <Select.Group>
      <Select.Label>Fruits</Select.Label>
      <Select.Item value="apple">Apple</Select.Item>
      <Select.Item value="banana">Banana</Select.Item>
      <Select.Item value="orange">Orange</Select.Item>
    </Select.Group>
  </Select.Content>
</Select>
// 实现侧——通过 Context 共享状态
const SelectContext = createContext<SelectContextValue | null>(null);

function Select({ children, value, onValueChange }: SelectProps) {
  const [open, setOpen] = useState(false);

  return (
    <SelectContext.Provider value={{ value, onValueChange, open, setOpen }}>
      {children}
    </SelectContext.Provider>
  );
}

function SelectItem({ value, children }: SelectItemProps) {
  const ctx = useContext(SelectContext);
  if (!ctx) throw new Error('SelectItem must be used within Select');

  return (
    <div
      role="option"
      aria-selected={ctx.value === value}
      onClick={() => {
        ctx.onValueChange(value);
        ctx.setOpen(false);
      }}
    >
      {children}
    </div>
  );
}

Select.Trigger = SelectTrigger;
Select.Content = SelectContent;
Select.Item = SelectItem;
Select.Value = SelectValue;
Select.Group = SelectGroup;
Select.Label = SelectLabel;

为什么 Compound Components 有效?

  • 控制反转(Inversion of Control):消费者决定渲染什么、以什么顺序渲染,组件只负责状态协调
  • 声明式 API:比 renderHeader/renderFooter 等 render prop 配置更直观
  • 可组合性:可以在子组件之间插入自定义内容,不受组件作者预设的 slot 限制

代表实现:Radix UI、Reach UI、Ariakit。这三个库的核心架构都是 Compound Components。

Headless UI(无头组件)

Headless UI 将组件的逻辑层渲染层完全分离。组件只提供状态管理、键盘交互、ARIA 属性等逻辑,UI 完全由消费者控制。

// Headless hook — 只提供逻辑和 ARIA 属性
function useCombobox<T>({ items, onSelect, filterFn }: ComboboxOptions<T>) {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);

  const filtered = filterFn
    ? items.filter((item) => filterFn(item, query))
    : items;

  const getInputProps = () => ({
    value: query,
    onChange: (e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value),
    onFocus: () => setIsOpen(true),
    role: 'combobox' as const,
    'aria-expanded': isOpen,
    'aria-activedescendant': activeIndex >= 0 ? `option-${activeIndex}` : undefined,
  });

  const getListProps = () => ({
    role: 'listbox' as const,
  });

  const getItemProps = (index: number) => ({
    id: `option-${index}`,
    role: 'option' as const,
    'aria-selected': index === activeIndex,
    onClick: () => onSelect(filtered[index]),
  });

  return { query, isOpen, filtered, activeIndex, getInputProps, getListProps, getItemProps };
}

// 消费者完全控制 UI
function MyCombobox() {
  const { isOpen, filtered, getInputProps, getListProps, getItemProps } = useCombobox({
    items: fruits,
    onSelect: (fruit) => console.log(fruit),
    filterFn: (fruit, query) => fruit.name.toLowerCase().includes(query.toLowerCase()),
  });

  return (
    <div className="relative">
      <input {...getInputProps()} className="border rounded px-3 py-2" />
      {isOpen && (
        <ul {...getListProps()} className="absolute mt-1 bg-white shadow-lg rounded">
          {filtered.map((fruit, i) => (
            <li {...getItemProps(i)} key={fruit.id} className="px-3 py-2 hover:bg-blue-50">
              {fruit.name}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Headless UI 的核心价值

  • 样式零侵入:不绑定任何 CSS 方案,Tailwind、CSS Modules、styled-components 都行
  • 可访问性内置:ARIA 属性、键盘导航、焦点管理由 hook 处理,消费者不需要自己实现
  • 可测试性:逻辑和 UI 分离,hook 可以独立单元测试

代表库:React Aria(Adobe)、Downshift、TanStack Table、Headless UI(Tailwind Labs)

Compound Components vs Headless UI:如何选择?

维度 Compound Components Headless UI
控制粒度 结构级控制(选择哪些子组件、什么顺序) 像素级控制(完全自定义渲染)
学习成本 低(JSX 组合直觉) 中(需要理解 prop getters 模式)
样式绑定 通常有默认样式或主题系统 零样式
适用场景 Design System 内部组件 跨 Design System 的基础设施
可访问性 内置(组件作者负责) 内置(hook 负责)

实际项目中的常见组合:用 React Aria(Headless)作为底层,在其上构建 Compound Components 风格的 Design System 组件。这是 Adobe Spectrum、shadcn/ui 等项目的架构思路。

Render Props 与 HOC:历史演进

Render PropsHigher-Order Components (HOC) 是 Hooks 出现之前的主要逻辑复用模式。理解它们有助于理解为什么 Hooks 是更好的方案。

// Render Props — 通过函数 prop 传递数据
<MouseTracker render={({ x, y }) => (
  <div>Mouse position: {x}, {y}</div>
)} />

// HOC — 包装组件,注入 props
const withAuth = (Component) => (props) => {
  const user = useAuth();
  if (!user) return <Redirect to="/login" />;
  return <Component {...props} user={user} />;
};

为什么 Hooks 取代了它们?

问题 Render Props HOC Hooks
嵌套地狱 ✅ 多层嵌套 ✅ 多层包装 ❌ 扁平调用
Props 命名冲突 ✅ 多个 HOC 可能注入同名 prop
类型推断 困难 非常困难 自然
静态分析 困难 困难 友好

但 Render Props 并没有完全消亡。在某些场景下它仍然有价值:

  • 条件渲染<Feature flag="new-ui">{(enabled) => enabled ? <NewUI /> : <OldUI />}</Feature>
  • Slot 模式:当需要在组件内部的特定位置插入自定义内容时

Props API 设计:组件的公共契约

Props 是组件的公共 API。一个设计良好的 Props 接口,应该让消费者在 80% 的场景下零配置就能用,在 20% 的场景下有足够的扩展点。

核心原则

1. 最小接口原则(Minimal API Surface)

只暴露消费者真正需要的 props。每多一个 prop,就多一个需要维护的契约。

// ❌ 过度暴露
<Button
  text="Submit"
  textColor="#fff"
  backgroundColor="#007bff"
  borderRadius={4}
  fontSize={14}
  fontWeight="bold"
  hoverBackgroundColor="#0056b3"
/>

// ✅ 语义化 + 组合
<Button variant="primary" size="md">
  Submit
</Button>

2. 合理默认值(Sensible Defaults)

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'ghost' | 'destructive'; // 默认 'primary'
  size?: 'sm' | 'md' | 'lg';  // 默认 'md'
  disabled?: boolean;           // 默认 false
  loading?: boolean;            // 默认 false
  children: ReactNode;
}

3. Discriminated Union 约束关联 Props

当某些 props 之间存在互斥或依赖关系时,用 TypeScript 的 Discriminated Union 来约束:

// ❌ 所有 props 都可选,运行时才发现冲突
interface BadLinkProps {
  href?: string;
  onClick?: () => void;
  external?: boolean;
}

// ✅ 类型系统保证正确性
type LinkProps =
  | { as: 'a'; href: string; external?: boolean; onClick?: never }
  | { as: 'button'; onClick: () => void; href?: never; external?: never };

4. 组合优于配置(Composition over Configuration)

// ❌ 配置式——每个 slot 都是一个 render prop
<Card
  renderHeader={() => <h2>Title</h2>}
  renderFooter={() => <Button>Action</Button>}
  renderBadge={() => <Badge>New</Badge>}
/>

// ✅ 组合式——children + 子组件
<Card>
  <Card.Header>
    <h2>Title</h2>
    <Badge>New</Badge>
  </Card.Header>
  <Card.Body>Content here</Card.Body>
  <Card.Footer>
    <Button>Action</Button>
  </Card.Footer>
</Card>

Polymorphic Components(多态组件)

一个常见需求是:同一个组件在不同场景下渲染为不同的 HTML 元素。

// 使用 as prop 实现多态
type PolymorphicProps<E extends ElementType> = {
  as?: E;
} & Omit<ComponentPropsWithoutRef<E>, 'as'>;

function Box<E extends ElementType = 'div'>({
  as,
  ...props
}: PolymorphicProps<E>) {
  const Component = as || 'div';
  return <Component {...props} />;
}

// 使用
<Box as="section" className="p-4">Section content</Box>
<Box as="a" href="/about">Link styled as box</Box>
<Box as={Link} to="/about">Router link</Box>

这个模式在 Chakra UI、Mantine、Radix 等库中广泛使用。TypeScript 的类型推断确保 as="a" 时只能传 <a> 的 props。


组件边界划分的工程判断

什么时候拆分组件?

不是"组件越小越好"。拆分组件的合理理由:

  1. 复用:这段 UI/逻辑在多处使用
  2. 关注点分离:数据获取和 UI 渲染应该分开
  3. 性能:避免不必要的重渲染(React.memo 的边界)
  4. 可测试性:复杂逻辑需要独立测试
  5. 团队协作:不同人负责不同组件

不应该拆分的情况

  • 只是为了"组件小一点"而拆——增加了间接性,没有实际收益
  • 拆分后两个组件之间需要大量 props 传递——说明它们本就是一个整体
  • 过早抽象——只用了一次的组件不需要提取到 patterns 层

Server Components 时代的组件边界

React Server Components(RSC)引入了新的组件边界考量:

Server Component(默认)
├── 可以直接访问数据库、文件系统
├── 不能使用 useState、useEffect
├── 不能使用浏览器 API
└── 零 JS bundle 成本

Client Component('use client')
├── 可以使用所有 React hooks
├── 可以使用浏览器 API
├── 会被打包到客户端 bundle
└── 不能直接 import Server Component

架构原则:将 'use client' 边界推到尽可能低的层级。

// ✅ 只有交互部分是 Client Component
// ProductPage.tsx (Server Component)
async function ProductPage({ id }: { id: string }) {
  const product = await db.product.findUnique({ where: { id } });

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={id} />  {/* 只有这个是 Client */}
    </div>
  );
}

// AddToCartButton.tsx
'use client';
function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);
  // ...
}

组件 API 的版本演进

组件一旦被其他团队或项目使用,其 props 接口就成了公共契约。修改需要谨慎。

向后兼容的变更

  • ✅ 新增可选 prop(有默认值)
  • ✅ 扩展联合类型('sm' | 'md''sm' | 'md' | 'lg'
  • ✅ 放宽类型约束(stringstring | ReactNode

破坏性变更的处理

interface ButtonProps {
  /** @deprecated 使用 variant 代替,将在 v3.0 移除 */
  type?: 'primary' | 'secondary';
  variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
}

function Button({ type, variant, ...props }: ButtonProps) {
  // 兼容旧 API
  const resolvedVariant = variant ?? type ?? 'primary';

  if (type && process.env.NODE_ENV === 'development') {
    console.warn(
      'Button: `type` prop is deprecated. Use `variant` instead. ' +
      'See migration guide: https://...'
    );
  }

  return <button data-variant={resolvedVariant} {...props} />;
}

Codemod 辅助迁移

对于大规模的 API 变更,提供 codemod 是专业做法:

// transform.js — jscodeshift codemod
export default function transformer(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  // 将 <Button type="primary"> 转换为 <Button variant="primary">
  root
    .findJSXElements('Button')
    .find(j.JSXAttribute, { name: { name: 'type' } })
    .forEach((path) => {
      path.node.name.name = 'variant';
    });

  return root.toSource();
}

常见架构反模式

反模式 问题 改进方向
God Component 单个组件 500+ 行,混合数据获取、状态管理、UI 渲染 拆分为 Container + Presentational,或用 hooks 提取逻辑
Prop Drilling props 穿越 5+ 层组件 Context、状态管理库、或 Compound Components
Premature Abstraction 只用了一次就提取为"通用组件" 等到第三次使用时再抽象(Rule of Three)
Boolean Explosion <Modal isOpen isFullscreen isClosable hasOverlay /> 用 variant 或 Compound Components 替代
Leaky Abstraction 组件暴露了内部实现细节(如 CSS class name、DOM 结构) 通过 props 和 ref 提供稳定的公共接口

延展阅读