SOLID 原则在 React 组件库里怎么落地:五个重构案例
一个 Button 组件,300 行。
loading 状态管理、主题色计算、埋点上报、权限校验、tooltip 逻辑,全塞一块儿。改个圆角,心惊胆战。加个 icon,牵一发动全身。
组件库烂掉,不是写的人菜,是第一天就没想过"职责边界在哪"。SOLID 五条原则,后端天天念叨,React 组件库这边?认真逐条对过的人不多。
一条条来。
S --- 单一职责:一个组件只干一件事
问题长什么样
你肯定见过这种"万能组件":
tsx
// ❌ 什么都管的按钮
function Button({ children, loading, theme, track, permission, tooltip, ...props }) {
// 权限校验
if (permission && !checkPermission(permission)) return null
// 埋点
const handleClick = (e) => {
if (track) sendTrackEvent(track)
props.onClick?.(e)
}
// 主题色
const color = theme === 'dark' ? '#fff' : '#333'
// tooltip 包裹
const btn = <button style={{ color }} onClick={handleClick}>{loading ? <Spinner /> : children}</button>
return tooltip ? <Tooltip content={tooltip}>{btn}</Tooltip> : btn
}
五种职责揉一块。改埋点?打开 Button。改权限?还是 Button。
怎么拆
单一职责不是"一个文件只写一个函数",是一个组件只该有一个被修改的理由。
横切关注点甩出去:
tsx
// ✅ Button 只管"长什么样、怎么点"
function Button({ children, loading, variant, ...props }) {
return (
<button className={`btn-${variant}`} {...props}>
{loading ? <Spinner /> : children}
</button>
)
}
// 埋点、权限、tooltip 各自独立
<Trackable event="submit_click">
<PermissionGuard requires="order:create">
<Tooltip content="提交订单">
<Button loading={isPending} onClick={handleSubmit}>提交</Button>
</Tooltip>
</PermissionGuard>
</Trackable>
Button 的修改理由只剩一个:按钮的交互和样式变了。
埋点策略调整?改 Trackable。权限模型换了?改 PermissionGuard。互不干扰。
够不够?问自己一句
"产品经理让我改 X,我要打开几个文件?" 答案应该是一个。超过一个,职责没拆干净,或者抽象层级有问题。
O --- 开闭原则:扩展靠组合,别动源码
问题长什么样
组件库上线半年,需求来了:"按钮前面加 icon。"
tsx
// ❌ 往 Button 里硬塞
function Button({ icon, iconPosition, children, ...props }) {
return (
<button {...props}>
{icon && iconPosition === 'left' && <Icon name={icon} />}
{children}
{icon && iconPosition === 'right' && <Icon name={icon} />}
</button>
)
}
下周又来 badge 角标。再加 prop。下下周 loading 骨架屏。继续塞。
半年后 20 个 prop。看一眼就头疼。
怎么改
开闭原则------对扩展开放,对修改关闭。React 里翻译过来就是:children 和 slot 模式代替 prop 堆叠。
tsx
// ✅ 通过 children 扩展,源码不用碰
function Button({ children, ...props }) {
return <button className="btn" {...props}>{children}</button>
}
// 要 icon?组合
<Button onClick={save}>
<Icon name="check" />
<span>保存</span>
</Button>
// 要 badge?组合
<Button onClick={save}>
<span>消息</span>
<Badge count={3} />
</Button>
// loading 骨架?还是组合
<Button disabled={loading}>
{loading ? <Skeleton width={60} /> : '提交'}
</Button>
Button 源码一行没动过。新需求全靠外面组合搞定。
像 USB 口。不用拆主板就能接新设备,插上就完事。
边界
不是啥都往 children 里塞。80% 使用者都要的功能------比如 disabled------内置成 prop 没毛病。开闭原则管的是那 20% 的扩展需求,它们不该污染核心 API。
L --- 里氏替换:子组件得能无缝替掉父组件
出过什么事
团队基于 Button 封了个 ConfirmButton:
tsx
// ❌ 替换性炸了
function ConfirmButton({ onConfirm, ...props }) {
// onClick 被吞了,外部传的直接失效
const handleClick = () => {
if (window.confirm('确定?')) {
onConfirm?.()
}
}
return <Button {...props} onClick={handleClick} />
}
看着没问题?
tsx
// onClick 被静默吃掉
<ConfirmButton onClick={() => track('clicked')} onConfirm={doDelete}>
删除
</ConfirmButton>
// track('clicked') 永远不执行 💀
ConfirmButton 说自己是 Button 的增强版,行为却不兼容。批量把 Button 换成 ConfirmButton,系统出 bug。
修
关键就一点:子组件得保留父组件的全部契约。
tsx
// ✅ 保留 onClick,增强而不是覆盖
function ConfirmButton({ onConfirm, onClick, ...props }) {
const handleClick = (e) => {
onClick?.(e) // 先执行原始 onClick------契约不能丢
if (window.confirm('确定?')) {
onConfirm?.()
}
}
return <Button {...props} onClick={handleClick} />
}
就多了一行。但这一行决定了你能放心在任何用 Button 的地方换成 ConfirmButton,不炸。
怎么验
写的时候做个思想实验:把所有用基础组件的地方换成增强组件,跑一遍。行为不一致?违反了。
TypeScript 能帮忙:
tsx
// props 必须是基础组件 props 的超集
type ConfirmButtonProps = ButtonProps & {
onConfirm?: () => void
}
类型兼容只是一半,运行时行为也得对齐。缺一不可。
I --- 接口隔离:别逼人接受用不上的 prop
问题
一个"大而全"的 Modal:
tsx
interface ModalProps {
visible: boolean
title: string
onOk: () => void
onCancel: () => void
okText: string
cancelText: string
footer: ReactNode
closable: boolean
mask: boolean
maskClosable: boolean
width: number
centered: boolean
destroyOnClose: boolean
afterClose: () => void
keyboard: boolean
zIndex: number
// ... 还有 15 个
}
30 个 prop。
我就想弹个确认框,IDE 提示列表拉到底都看不完。烦。
拆法
别强迫使用者依赖他们用不着的接口。 按场景拆。
tsx
// ✅ 基础弹层:只管"弹出来、关掉"
interface OverlayProps {
visible: boolean
onClose: () => void
children: ReactNode
}
function Overlay({ visible, onClose, children }: OverlayProps) {
if (!visible) return null
return createPortal(
<div className="overlay-mask" onClick={onClose}>
<div className="overlay-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
)
}
tsx
// ✅ 确认弹框
function ConfirmDialog({ visible, title, onOk, onCancel }) {
return (
<Overlay visible={visible} onClose={onCancel}>
<h3>{title}</h3>
<div className="actions">
<Button onClick={onCancel}>取消</Button>
<Button onClick={onOk}>确定</Button>
</div>
</Overlay>
)
}
tsx
// ✅ 复杂业务弹窗:按需组合
function OrderDetailModal({ visible, orderId, onClose }) {
return (
<Overlay visible={visible} onClose={onClose}>
<OrderDetail id={orderId} />
<CommentSection orderId={orderId} />
<Button onClick={onClose}>关闭</Button>
</Overlay>
)
}
三个场景,三套最小 API。没人被迫接 30 个 prop。
权衡
拆太细也有代价------组件数量膨胀,使用者得自己组合,心智负担跑到消费端去了。
看使用频率。90% 场景是"弹个确认框",那 ConfirmDialog 开箱即用,prop 尽量少。剩下 10% 的复杂场景,给 Overlay 这个底层积木就够了。
别想一个组件通吃。通吃的下场就是 30 个 prop。
D --- 依赖倒置:组件认接口,不认具体实现
问题
UserList 写死了数据获取方式:
tsx
// ❌ 和 fetch 强绑定
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users') // 写死 URL
.then(res => res.json()) // 写死解析
.then(data => setUsers(data.list))
}, [])
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
)
}
换 GraphQL?改组件。接 WebSocket?改组件。Storybook 里 mock?还是得改,要不就拦截网络请求搞一堆花活。
UI 渲染绑死了数据获取的具体实现。耦合得死死的。
怎么解
高层和低层都依赖抽象。 React 里就是:组件不管数据从哪来,你给我数据我来渲染。
tsx
// ✅ 抽象成 hook 接口
interface UseUsersResult {
users: User[]
loading: boolean
error: Error | null
}
// REST
function useUsersFromREST(): UseUsersResult { /* fetch 逻辑 */ }
// GraphQL
function useUsersFromGraphQL(): UseUsersResult { /* useQuery 逻辑 */ }
// Mock
function useUsersMock(): UseUsersResult {
return { users: mockUsers, loading: false, error: null }
}
tsx
// ✅ 组件只认接口
function UserList({ useUsers }: { useUsers: () => UseUsersResult }) {
const { users, loading, error } = useUsers()
if (loading) return <Skeleton />
if (error) return <ErrorBanner message={error.message} />
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
)
}
// 注入具体实现
<UserList useUsers={useUsersFromREST} /> // 线上
<UserList useUsers={useUsersMock} /> // Storybook
换数据源?换个 hook。组件一行不动。
像电器和插座的关系。吹风机不管电是火电来的还是风电来的,它就认 220V 那个口。
Context 注入
不想每个组件手动传 hook,用 Context:
tsx
const DataSourceContext = createContext<{
useUsers: () => UseUsersResult
}>(null!)
function App() {
return (
<DataSourceContext.Provider value={{ useUsers: useUsersFromREST }}>
<UserList />
</DataSourceContext.Provider>
)
}
function UserList() {
const { useUsers } = useContext(DataSourceContext)
const { users } = useUsers()
// ...
}
测试和 Storybook 换个 Provider 就行。真解耦。
代价
多了一层抽象,间接调用多了,debug 调用链变长。小项目三五个组件,fetch 直接写组件里反而清爽。
依赖倒置适合组件库、跨项目复用的模块、需要多环境适配的场景。不是啥代码都得这么写。
五条之间的关系
单一职责拆出独立组件,开闭原则让它们能通过组合扩展。里氏替换保证增强组件能安全替掉基础组件。接口隔离让每个组件的 API 保持最小。依赖倒置让组件和具体实现脱钩。
一环扣一环。
别教条就行。内部管理后台,三个人维护,写得"不够 SOLID"------没事。跑得通、改得动,就是好代码。
SOLID 真正派上用场的时候,是你的组件库被 20 个团队依赖、每周都有新需求往里塞。到那时候你会庆幸当初把职责拆干净了。