利用组合式组件与插槽解决 React 属性过载问题
了解为何配置繁重的 React props 会带来维护负担,以及控制反转、组合模式和插槽机制如何打造真正可复用的组件。
来源: Your React Components Aren’t Reusable. They’re Just Complicated
探讨如何通过配置驱动的属性悄然积累维护债务,以及如何借助控制反转与组合模式打造真正模块化的 UI 组件。
想象一下,有位团队成员提交了一个拉取请求,自豪地介绍了他们所称的“通用数据表”。
其目标是一次性解决代码库中所有与表格渲染相关的需求。
该组件包含了三十四个独立的属性:
<UniversalDataTable
data={users}
columns={columns}
enablePagination={true}
paginationType="cursor"
enableSorting={true}
showFilterRow={true}
hasExportToCsvButton={true}
exportButtonText="Download CSV"
headerBackground="light-gray"
denseRows={false}
enableRowSelection={true}
selectionMode="multiple"
renderCustomRowActions={(row) => <DropdownMenu row={row} />}
onRowClick={(row) => navigate(`/users/${row.id}`)}
/>
其内部实际上有大约1,200行逻辑代码。
在渲染用户列表时,它的表现相当不错。
几周后,市场部要求在公开网站上添加一个价格对比表。
需求包括:三列、每行功能旁都有对勾图标,以及中间那一列有高亮的“热门”边框。
有人直接使用 <UniversalDataTable />,以为它可以直接正常工作。
但问题就在这时出现了。
无法自定义单个单元格的边框。
表头对齐方式被硬编码为特定的弹性布局。
若要获得整洁的输出结果,就必须传入 hasExportToCsvButton={false} 以及其他几项覆盖参数。
于是团队又添加了四个属性:
isPricingTable
popularColumnIndex
customHeaderRenderer
disableGlobalStyles
文件行数随之增至1,450行。
此时它已经完全无法重复使用了。
原本作为抽象概念的东西,如今却变成了一堆伪装成单一组件的特例。
1. 配置属性陷阱
当团队希望某个组件具备“可复用性”时,通常会首先考虑通过属性来进行配置。
每一个新的使用场景都会在 props 接口上新增一个标志、字符串枚举、回调函数或样式覆盖项。
一个典型的例子:
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
hasLeftIcon?: boolean;
leftIcon?: React.ReactNode;
hasRightIcon?: boolean;
rightIcon?: React.ReactNode;
isFullWidth?: boolean;
tooltipText?: string;
tooltipPosition?: 'top' | 'bottom';
rounded?: boolean;
}
表面上看这似乎具有很好的适应性。
问题在于,基于配置的灵活性会带来一些你无法立即察觉的代价。
1. 组合爆炸
仅十个布尔型属性在理论上就已经能产生1,024种可能的组合。
无需测试所有组合,就能明白情况会多快失控。
某些组合彼此矛盾。
还有些组合将永远无人使用。
有些组合的渲染结果会因视口宽度、周围内容或组件嵌套深度的不同而出现差异。
久而久之,得到的只会是一堆边缘情况,而非一个连贯且可预测的 API。
2. Prop Drilling 问题
假设某个工具提示设置需要传递到组件树中深埋的嵌套容器里。
沿途的每一个中间组件都不得不传递一个它们实际上并不需要的属性。
配置之所以要逐层传递,完全是因为结构上的要求,而非因为这样做有实际意义。
这就是属性钻取机制的原理。
3. 严格的布局假设
正是这里,基于配置的组件通常会彻底失效。
假设新的设计要求在两行按钮文本之间放置一个图标。
添加类似这样的代码:
iconPosition="between"
或许能暂时解决问题。
但一旦设计再次发生变化,又会怎样呢?
不久之后你就得维护一整套不同的变体版本:
iconPosition="left"
iconPosition="right"
iconPosition="top"
iconPosition="between"
iconPosition="absolute"
该列表会不断扩展,因为组件的内部 JSX 仍然直接决定着布局。
真正的问题不在于属性的数量有多少。
而是组件承担了过多与其职责范围之外相关的事务。
2. 控制反转:更佳的思维模型
真正的可重用性并非通过提前预测所有未来需求来实现的。
它源于应用控制反转(IoC)原则。
这个概念很简单:
不必让组件决定其内部渲染的所有内容,而应由使用该组件的开发者来控制那些需要修改的部分。
这正是组合模式在 React 中如此重要的原因。
不要去创建一个内置了工具提示、图标、旋转加载器、徽章以及各种布局变体功能的按钮,而应设计一个仅以实现按钮基本功能为目标的组件。
例如:
export function Button({
children,
variant = 'primary',
className = '',
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} ${className}`}
{...props}
>
{children}
</button>
);
}
内容的控制权现在在使用方手中:
<Tooltip content="Save your profile">
<Button>
<SaveIcon />
<span>Save Changes</span>
</Button>
</Tooltip>
注意那些不再必要的元素。
该按钮完全不知道工具提示的存在。
没有 hasLeftIcon 属性。
没有 leftIcon 属性。
没有 iconSpacing 属性。
没有 hasSpinner 属性。
需要加载旋转器?直接在按钮内部放置即可。
<Button>
<Spinner />
Saving...
</Button>
这样的设计让组件的体积更小,同时提升了灵活性。
这种权衡正是这里的关键所在。
可复用组件无需在内部处理所有可能的场景。它需要一个定义明确的边界,以便其他组件能够基于该边界进行组合。
3. 插槽让复杂组件更易于扩展
使用children进行组合适用于简单元素,但对于卡片、对话框、控制面板或个人资料面板这类更为复杂的组件又该如何处理呢?
这时插槽与复合组件就派上用场了。
想象一下以一堆属性为基础构建的传统个人资料卡片:
<UserProfileCard
avatarUrl="/avatar.jpg"
name="Sarah Connor"
role="Tech Lead"
status="Active"
bio="Building reliable frontend systems."
primaryActionLabel="Send Message"
onPrimaryAction={sendMessage}
secondaryActionLabel="View Profile"
onSecondaryAction={viewProfile}
badgeColor="green"
/>
乍看之下,这种方式似乎很方便。
但随后需求开始发生变化。
营销团队希望将用户的社交账号显示在其姓名旁边。企业账户的管理员团队则要求在卡片上添加“已暂停”字样的横幅。另一个产品团队希望使用三个操作按钮而非两个。还有设计师认为头像应放在右侧而非左侧。
这些需求每一种都会变成另一个需要处理的元素。
更好的方法是把布局拆分成可组合的独立部分:
<Card>
<Card.Header>
<Avatar
src={user.avatarUrl}
alt={user.name}
/>
<div>
<Card.Title>{user.name}</Card.Title>
<Card.Subtitle>{user.role}</Card.Subtitle>
</div> <Badge variant={user.statusColor}>
{user.status}
</Badge>
</Card.Header> <Card.Body>
<p>{user.bio}</p>
</Card.Body> <Card.Actions>
<Button
variant="secondary"
onClick={viewProfile}
>
View Profile
</Button> <Button
variant="primary"
onClick={sendMessage}
>
Send Message
</Button>
</Card.Actions>
</Card>
这样你就能在JSX中清晰地看到卡片的结构。既然能看得到,自然就可以进行修改。
想要多一个操作按钮?直接添加即可。需要不同的徽章?换上新的就行。必须再加一条横幅?放到合适的位置即可。想要完全不同的标题布局?只需编写一个新的标题部分。
为支持一个新变体,无需修改那些臃肿的共享组件。
4. 组合优于配置
这两种方法本质上是在探讨不同的问题。
配置会问:
“该组件应提供哪组选项?”
组合会问:
“用户可以用哪些小型构建块来组合出他们真正需要的东西?”
配置会将越来越多的决策责任压在组件本身上,而组合则将这些决策权交还给使用该组件的用户——通常来说,这样的安排更有利于履行这些责任。
以以下对比为例:
<Button
hasLeftIcon
leftIcon={<SaveIcon />}
hasTooltip
tooltipText="Save your profile"
showSpinner={isSaving}
spinnerPosition="left"
/>
与……相比:
<Tooltip content="Save your profile">
<Button>
{isSaving && <Spinner />}
<SaveIcon />
Save Profile
</Button>
</Tooltip>
第二个代码片段确实包含了更多JSX,但那些彼此无需知晓的组件之间的耦合度也低得多。这种权衡通常是值得的——从长远来看,在调用处添加少量额外标记,往往比在共享组件上再添加一个永久性功能要便宜。
5. 向无头库学习
一些现代前端库将这一理念体现得极为出色。
Radix UI、TanStack Table和shadcn/ui等项目在推广这种以组合为核心的思维方式方面发挥了重要作用。
TanStack Table就是最典型的例子之一。它不会强制你使用固定的表格设计,而是为以下功能提供逻辑和状态管理:
- 排序
- 过滤
- 分页
- 行选择
如何将这种状态转换为实际的标记格式完全由你决定。例如:
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{/* Your own rendering logic */}
</tbody>
</table>
API的具体形式并非重点,关键在于底层架构:表格引擎负责处理行为逻辑,而你则负责界面呈现。由于这两者是分开的,你可以自由地重新设计界面外观,而无需让核心表格逻辑变得过于复杂。
6. 将行为与外观分离
这自然引出了另一条值得遵循的准则:
将组件的功能与其呈现方式分开。
一个组件确实可能需要处理相当多的内部复杂性,包括以下方面:
- 键盘导航
- 焦点管理
- 选择
- 排序
- 过滤
- 状态
- 无障碍性
- 定位
要妥善处理所有这些问题绝非易事。然而,这些要求并不会强制某个组件必须决定最终结果的呈现方式。
这正是大多数无头UI系统背后的核心理念:行为可在多种视觉实现中重复使用,而呈现层则保持完全开放。这种分离方式通常比试图构建一个包含数十个视觉配置选项的庞大组件能带来更强的可重用性。
7. 何时使用Props才是合理的
这并不意味着Props是一种糟糕的设计模式。
它们是React提供给你的最基础的工具之一。
问题不在于Props的存在,而在于用它们来编码组件可能需要的所有结构变化。
像这样的例子完全没问题:
<Button variant="primary" />
<Button size="sm" />
<Input disabled />
<Card className="featured" />
一旦某个组件开始积累这样的列表,就会出现红色标记:
<Component
showHeader
showFooter
showBadge
showIcon
showActions
iconPosition="left"
badgePosition="right"
footerAlignment="center"
actionLayout="horizontal"
compact
dense
bordered
rounded
disableAnimation
customHeaderRenderer={...}
customFooterRenderer={...}
/>
一旦发现这种模式,值得暂停下来思考:
“这些真的应该作为属性存在,还是其中一些更适合以组合子组件的形式出现?”
只要提出这个问题,往往就能避免日后出现许多不必要的复杂性。
8. 过度抽象究竟会带来什么代价
配置过度的组件造成的真正危害并非外观上的问题。
而是修改它们会变得十分困难。
被广泛使用的组件会影响的范围很广。如果代码库中有二十个地方依赖它,那么新增的每一个属性都可能影响到这二十个使用它的部分。
工程师们就会变得不愿去修改它。
为它编写文档变得越来越困难。
测试它也变得愈发棘手。
查找其中的错误更加不易。
最终,这个组件会变得如此“可复用”,以至于实际上没人愿意再使用它。
这正是这种模式中固有的矛盾之处。
过度抽象反而会削弱它原本应实现的复用性。
在实际使用中,一个规模小、范围明确的简单组件往往比那些为应对所有可能变化而设计的庞大组件更具复用性。
构建组件的实用框架
在再次使用配置属性之前,先进行以下检查。
1. 我面对的是行为层面还是表现层面?
如果是行为相关的问题,使用属性或自定义钩子可能是合适的解决方案。如果纯粹是视觉结构方面的问题,那么组合方式通常更为合适。
2. 该组件真的需要这些信息吗?
如果要求一个组件同时理解工具提示、分析钩子、徽章、图标、加载指示器以及其所有子元素的排列方式,那它承担的责任几乎肯定超出了应有的范围。
3. 用children来处理这个问题可以吗?
如果可行,那就选择组合方式,而非再引入一个新的与内容相关的属性。
4. 命名插槽能简化 API 吗?
对于结构较为复杂的组件,定义明确的区域,例如:
<Card.Header />
<Card.Body />
<Card.Footer />
这样就能让相关内容更易于理解。
5. 能将逻辑与标记分离吗?
如果是这样,那么无头架构或以逻辑优先的设计方式值得考虑。
6. 这个功能是解决实际需求,还是臆想出来的需求?
这个问题比看上去更重要。
应避免添加如下内容:
futureFeature={true}
仅仅因为将来可能有人会需要它。
应基于有实际证据的需求进行设计,而非凭猜测。
总结
构建可复用的 React 组件并非为了创造能做任何事情的工具,而是为了打造能够以多种不同方式组合使用的组件。这一区别看似细微,但从架构层面来看却能改变一切。
一个依赖三十个属性的组件表面上看起来可能很灵活,但每一个属性都会增加额外的责任、需要管理的状态,以及最终必须有人来支持的组合情况。
相比之下,那些接口简洁且组成边界明确的组件看起来可能较为普通。
但实际上,这类组件随着时间推移往往表现更好。
所以下次当你想要再给一个共享组件添加属性时,先停下来问自己:
“我这样做是让它更易复用,还是只是让它更易配置?”
这两者是不可互换的。
一个组件并非通过积累三十个选项就能变得可复用。
它要成为可复用的组件,就要把某项任务做好,明确合理的边界,其余时间则不干扰其他功能。
稳健的组件架构并非旨在预测所有未来的需求。
它的目的是让未来出现的任何需求都能轻松组合实现。