设计模式不是"看起来高级"的代码风格。

好的组件设计,是从 React Runtime 的约束中推导出来的。

这篇文章不是设计模式大全。它是:从五个 Law 出发,理解为什么这些模式是正确的。


一、Compound Components

1.1 问题

// ❌ 朴素实现
function Select({ options, value, onChange }) {
  return (
    <div className="select">
      <div className="select-trigger">{value}</div>
      <div className="select-dropdown">
        {options.map((opt) => (
          <div
            key={opt.value}
            className={`select-option ${opt.value === value ? 'selected' : ''}`}
            onClick={() => onChange(opt.value)}
          >
            {opt.label}
          </div>
        ))}
      </div>
    </div>
  )
}

问题:

  1. 灵活性差:想在 option 里加图标?想在 dropdown 里加搜索框?改 props 改到死
  2. 类型不安全:options 的类型是 { value: string, label: string }[],扩展困难
  3. 样式不灵活:className 写死,覆盖困难

1.2 Compound Components 模式

<Select value={value} onChange={onChange}>
  <Select.Trigger>{value || '请选择'}</Select.Trigger>
  <Select.Options>
    <Select.Option value="react">React</Select.Option>
    <Select.Option value="vue">Vue</Select.Option>
    <Select.Option value="angular">Angular</Select.Option>
  </Select.Options>
</Select>

使用者完全控制结构。想加图标?直接加:

<Select.Option value="react">
  <ReactIcon /> React
</Select.Option>

1.3 实现原理:隐式 Context

const SelectContext = createContext(null)

function Select({ value, onChange, children }) {
  return (
    <SelectContext.Provider value={{ value, onChange }}>
      <div className="select">{children}</div>
    </SelectContext.Provider>
  )
}

Select.Trigger = function Trigger({ children }) {
  const { value } = useContext(SelectContext)
  return <div className="select-trigger">{children}</div>
}

Select.Options = function Options({ children }) {
  return <div className="select-dropdown">{children}</div>
}

Select.Option = function Option({ value, children }) {
  const { value: selected, onChange } = useContext(SelectContext)
  return (
    <div
      className={`select-option ${value === selected ? 'selected' : ''}`}
      onClick={() => onChange(value)}
    >
      {children}
    </div>
  )
}

关键:Select 和 Select.Option 通过 Context 隐式通信,使用者不需要手动传 props。

1.4 与 Law 3 (Ownership) 的关系

Select(父组件)
├── 拥有:value 状态
├── 通过 Context 暴露给子组件
└── 子组件(Select.Option)通过 Context 读写

这是 Ownership 的隐式传递:父组件拥有状态,子组件通过 Context 访问。使用者不需要手动管理 props drilling。


二、Controlled vs Uncontrolled

2.1 问题

// Controlled:父组件控制状态
function Form() {
  const [value, setValue] = useState('')
  return <input value={value} onChange={(e) => setValue(e.target.value)} />
}

// Uncontrolled:组件自己管理状态
function Form() {
  const ref = useRef()
  return <input defaultValue="initial" ref={ref} />
}

什么时候用哪个?

2.2 从 Ownership 推导

Controlled

父组件
├── 拥有:value 状态
├── 传递:value + onChange 给子组件
└── 子组件:只是"展示",不拥有状态

Uncontrolled

子组件
├── 拥有:内部状态
├── 父组件:只给初始值(defaultValue)
└── 父组件:通过 ref 读取当前值

决策标准:谁需要拥有这个状态?

// ✅ 父组件需要实时知道值 → Controlled
function SearchBox() {
  const [query, setQuery] = useState('')

  // 需要在父组件里用 query 做搜索
  const results = useSearch(query)

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results results={results} />
    </>
  )
}

// ✅ 父组件只在提交时需要值 → Uncontrolled
function LoginForm() {
  const emailRef = useRef()
  const passwordRef = useRef()

  function handleSubmit() {
    // 只在提交时读取值
    const email = emailRef.current.value
    const password = passwordRef.current.value
    login(email, password)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={emailRef} defaultValue="" />
      <input ref={passwordRef} type="password" defaultValue="" />
      <button type="submit">登录</button>
    </form>
  )
}

2.3 同时支持两者

function Input({ value, defaultValue, onChange, ...props }) {
  const [internalValue, setInternalValue] = useState(defaultValue)

  // 判断是 Controlled 还是 Uncontrolled
  const isControlled = value !== undefined
  const currentValue = isControlled ? value : internalValue

  function handleChange(e) {
    const newValue = e.target.value

    if (!isControlled) {
      setInternalValue(newValue)
    }

    onChange?.(e)
  }

  return <input value={currentValue} onChange={handleChange} {...props} />
}

2.4 与 Law 3 (Ownership) 的关系

Controlled vs Uncontrolled 的本质是 Ownership 的选择

  • Controlled:父组件拥有状态,子组件只是展示
  • Uncontrolled:子组件拥有状态,父组件通过 ref 访问

选择哪种,取决于谁需要在什么时候访问这个状态


三、forwardRef

3.1 问题

// ❌ 不能直接给函数组件传 ref
function MyInput(props) {
  return <input {...props} />
}

function App() {
  const ref = useRef()
  return <MyInput ref={ref} /> // Warning: Function components cannot be given refs
}

ref 是 React 的特殊 prop,不会自动透传。

3.2 forwardRef 解决方案

const MyInput = forwardRef(function MyInput(props, ref) {
  return <input ref={ref} {...props} />
})

function App() {
  const ref = useRef()
  return <MyInput ref={ref} />
}

forwardRef 让函数组件可以接收 ref,并把它转发给内部的 DOM 节点。

3.3 什么时候需要 forwardRef

场景 1:需要暴露 DOM 节点

// 父组件需要直接操作 DOM(focus、measure)
function Form() {
  const inputRef = useRef()

  function handleClick() {
    inputRef.current.focus()
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>Focus Input</button>
    </>
  )
}

场景 2:需要暴露命令式方法

// 通过 useImperativeHandle 暴露自定义方法
const MyInput = forwardRef(function MyInput(props, ref) {
  const inputRef = useRef()

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    shake: () => {
      inputRef.current.classList.add('shake')
      setTimeout(() => inputRef.current.classList.remove('shake'), 500)
    },
  }))

  return <input ref={inputRef} {...props} />
})

function Form() {
  const inputRef = useRef()

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={() => inputRef.current.shake()}>Shake</button>
    </>
  )
}

3.4 React 19 的变化

React 19 中,ref 会自动作为 prop 传递,不再需要 forwardRef:

// React 19:ref 直接作为 prop
function MyInput({ ref, ...props }) {
  return <input ref={ref} {...props} />
}

forwardRef 仍然支持,但不再是必需的。

3.5 与 Law 3 (Ownership) 的关系

ref 是一种 Escape Hatch:当声明式模型不够用时,提供命令式访问。

React 的声明式模型
├── 描述 UI 应该是什么
└── 不直接操作 DOM

ref 的命令式访问
├── 直接操作 DOM 节点
└── 用于 focus、measure、动画等

forwardRef 解决的是 Ownership 的边界问题:子组件需要把 DOM 节点的所有权暴露给父组件。


四、Render Props vs Hooks

4.1 Render Props 模式

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 })

  useEffect(() => {
    function handleMouseMove(e) {
      setPosition({ x: e.clientX, y: e.clientY })
    }
    window.addEventListener('mousemove', handleMouseMove)
    return () => window.removeEventListener('mousemove', handleMouseMove)
  }, [])

  return render(position)
}

// 使用
;<MouseTracker
  render={({ x, y }) => (
    <div>
      鼠标位置:{x}, {y}
    </div>
  )}
/>

4.2 Hooks 模式

function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 })

  useEffect(() => {
    function handleMouseMove(e) {
      setPosition({ x: e.clientX, y: e.clientY })
    }
    window.addEventListener('mousemove', handleMouseMove)
    return () => window.removeEventListener('mousemove', handleMouseMove)
  }, [])

  return position
}

// 使用
function App() {
  const { x, y } = useMousePosition()
  return (
    <div>
      鼠标位置:{x}, {y}
    </div>
  )
}

4.3 为什么 Hooks 赢了

维度Render PropsHooks
嵌套容易 callback hell扁平
类型推导复杂简单
组合难(多层嵌套)易(多个 hook 并列)
条件调用可以不行(hook 规则)

Hooks 在几乎所有场景都更好用。Render Props 还有存在的必要吗?

4.4 Render Props 仍然有用的场景

场景:需要在 JSX 中访问动态计算的值

// ❌ Hooks 不行:不能在循环/条件中调用
function UserList({ users }) {
  return users.map((user) => {
    // ❌ 这不行
    const position = useMousePosition()
    return <UserCard key={user.id} user={user} mousePosition={position} />
  })
}

// ✅ Render Props 可以
function UserList({ users }) {
  return (
    <MouseTracker
      render={({ x, y }) =>
        users.map((user) => <UserCard key={user.id} user={user} mousePosition={{ x, y }} />)
      }
    />
  )
}

场景:需要组件控制渲染内容

// Hooks 给的是数据,Render Props 给的是渲染控制
<Transition in={show} duration={300}>
  {(state) => <div className={`fade fade-${state}`}>{state === 'entered' && <Content />}</div>}
</Transition>

4.5 与 Law 2 (Description) 的关系

Render Props 和 Hooks 都是获取数据的方式,但返回的东西不同:

  • Hooks:返回数据,你决定怎么渲染
  • Render Props:返回渲染结果,组件控制渲染

Hooks 更符合 Description 模型:数据和渲染分离。 Render Props 在某些场景下更直接:直接给渲染结果


五、组合模式的选择

5.1 决策树

需要共享状态吗?

├── 否 → 普通 props 传递

└── 是 → 需要跨越多层组件吗?

    ├── 否 → props 直接传递

    └── 是 → 状态更新频率高吗?

        ├── 否 → Context

        └── 是 → 需要精确订阅吗?

            ├── 否 → Context + memo

            └── 是 → External Store (Zustand/Jotai)

5.2 组件 API 设计原则

原则 1:最小化 props

// ❌ 太多 props
<Button
  size="md"
  variant="primary"
  disabled={false}
  loading={false}
  icon={<Icon />}
  iconPosition="left"
  onClick={handleClick}
/>

// ✅ 合理的 props
<Button variant="primary" onClick={handleClick}>
  <Icon /> Submit
</Button>

children 比 icon + iconPosition 更灵活。

原则 2:合理使用组合

// ❌ 一个组件做太多事
<UserCard
  user={user}
  showAvatar={true}
  showEmail={true}
  showActions={true}
  onEdit={handleEdit}
  onDelete={handleDelete}
/>

// ✅ 组合多个小组件
<UserCard>
  <UserAvatar user={user} />
  <UserName user={user} />
  <UserEmail user={user} />
  <UserActions user={user} onEdit={handleEdit} onDelete={handleDelete} />
</UserCard>

每个组件职责单一,组合灵活。

原则 3:类型安全

// ❌ 字符串 props
;<Button variant="primary" size="md" />

// ✅ 类型约束
type ButtonVariant = 'primary' | 'secondary' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'

interface ButtonProps {
  variant?: ButtonVariant
  size?: ButtonSize
  children: React.ReactNode
}

六、与五个 Law 的关系

Law组件设计的体现
Law 1 (Environment)组件在 Server/Client 的行为差异
Law 2 (Description)组件返回 Description,Renderer 实现
Law 3 (Ownership)谁拥有状态?谁拥有 ref?谁拥有副作用?
Law 4 (Lane)状态更新的优先级由触发方式决定
Law 5 (Cost)组件划分影响渲染成本、bailout 范围

七、总结

组件设计模式的本质是 Ownership 的设计

  • Compound Components:父组件通过 Context 共享 Ownership
  • Controlled/Uncontrolled:选择谁拥有状态
  • forwardRef:把 DOM Ownership 暴露给父组件
  • Render Props vs Hooks:数据 Ownership 的不同获取方式

好的组件设计,不是"看起来高级"。

而是:

从 Runtime 的约束中推导出来的正确设计。

理解了五个 Law,面对任何组件设计问题,你都能推导出正确的方案。

这就是 Thinking in React。