SOLID 原则在 React 组件库里怎么落地:五个重构案例

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 个团队依赖、每周都有新需求往里塞。到那时候你会庆幸当初把职责拆干净了。

相关推荐
本末倒置1832 小时前
Bun 内置模块全解析:告别第三方依赖,提升开发效率
前端·javascript·node.js
进击的尘埃2 小时前
中介者模式:把面板之间的蜘蛛网拆干净
javascript
Hilaku3 小时前
OpenClaw 很爆火,但没人敢聊它的权限安全🤷‍♂️
前端·javascript·程序员
兆子龙4 小时前
React Native 完全入门:从原理到实战
前端·javascript
SuperEugene4 小时前
Vite 实战教程:alias/env/proxy 配置 + 打包优化避坑|Vue 工程化必备
前端·javascript·vue.js
兆子龙4 小时前
一文彻底搞懂 OpenClaw 的架构设计与运行原理(万字长文)
javascript
boooooooom5 小时前
别再用错 ref/reactive!90%程序员踩过的响应式坑,一文根治
javascript·vue.js·面试
德育处主任5 小时前
『NAS』一句话生成网页,在NAS部署UPage
前端·javascript·aigc
张元清5 小时前
Astro 6.0:被 Cloudflare 收购两个月后,这个"静态框架"要重新定义全栈了
前端·javascript·面试