组件架构设计
为什么组件架构是前端工程的核心问题
一个中大型前端项目通常包含 200-1000+ 个组件。当组件数量到达这个量级,真正决定项目可维护性的不是单个组件写得多好,而是组件之间的关系是否清晰、边界是否合理、API 契约是否稳定。
组件架构要回答的核心问题:
- 分层:哪些组件是基础设施,哪些是业务逻辑,哪些是页面级编排?
- 复用模式:逻辑复用用什么模式?UI 复用用什么模式?两者如何解耦?
- API 设计:组件的 props 接口如何设计才能既灵活又不失控?
- 演进:组件 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-paths 或 eslint-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 Props 和 Higher-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。
组件边界划分的工程判断
什么时候拆分组件?
不是"组件越小越好"。拆分组件的合理理由:
- 复用:这段 UI/逻辑在多处使用
- 关注点分离:数据获取和 UI 渲染应该分开
- 性能:避免不必要的重渲染(React.memo 的边界)
- 可测试性:复杂逻辑需要独立测试
- 团队协作:不同人负责不同组件
不应该拆分的情况:
- 只是为了"组件小一点"而拆——增加了间接性,没有实际收益
- 拆分后两个组件之间需要大量 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') - ✅ 放宽类型约束(
string→string | 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 提供稳定的公共接口 |
延展阅读
- Patterns.dev — React Component Patterns — Addy Osmani & Lydia Hallie 的设计模式合集,涵盖 Compound、Render Props、Hooks 等
- Kent C. Dodds — Advanced React Patterns — Compound Components 和 Inversion of Control 的经典教程
- Radix UI — Architecture — Compound Components + Headless 架构的工业级实现
- React Aria — Adobe — Headless UI hooks 的标杆实现,深度集成可访问性
- Atomic Design — Brad Frost — 组件分层理论的原始出处