设计模式不是"看起来高级"的代码风格。
好的组件设计,是从 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>
)
}
问题:
- 灵活性差:想在 option 里加图标?想在 dropdown 里加搜索框?改 props 改到死
- 类型不安全:options 的类型是
{ value: string, label: string }[],扩展困难 - 样式不灵活: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 Props | Hooks |
|---|---|---|
| 嵌套 | 容易 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。