React 组件有哪些常见设计模式?容器/展示组件、复合组件和受控模式怎样用?
容器/展示组件把取数据和展示拆开;复合组件把一个复杂组件拆成 Tabs、Tabs.List、Tabs.Panel 这样一组配合使用的组件,通过 Context 共享状态;受控模式让关键状态由父组件通过 props 决定,非受控则由组件自己保存;children 和 render props 用来让使用者决定渲染什么。选择时看谁需要控制状态、使用者需要多大的定制空间。
简答
写一个 Tabs 组件时会碰到几个问题:数据从哪里来,状态归谁管,使用者能定制到什么程度。几种常见的组件模式就是在回答这些问题。
容器/展示组件是把取数据、处理状态的部分和纯展示的部分拆成两个组件,展示组件只接收 props 渲染,容易复用和测试。复合组件是把一个复杂组件拆成
Tabs、TabList、TabPanel这样一组组件,使用者自己组合摆放,它们之间通过 Context 共享当前选中项。受控和非受控回答的是状态归谁管。react.dev 的说法是,关键状态由 props 驱动的组件叫受控组件,由自己的局部状态决定的叫非受控组件;非受控组件用起来配置少,受控组件更灵活,但需要父组件把 props 配齐。很多组件库两种都支持:传了
value就受控,没传就用defaultValue自己管。定制渲染内容用
children或 render props。react.dev 提醒,用ChildrenAPI 去遍历和改写 children 容易让代码变脆弱,更推荐暴露多个组件、接收对象数组或者用 render props。复用状态逻辑现在主要用自定义 Hook。
容器组件和展示组件怎样拆
先看展示组件。它只接收 props,不关心数据从哪来:
function UserList({ users, onSelect }) {
return (
<ul>
{users.map((user) => (
<li key={user.id} onClick={() => onSelect(user.id)}>
{user.name}
</li>
))}
</ul>
);
}
容器组件负责取数据、保存状态,再把结果交给展示组件:
function UserListContainer() {
const users = useUsers();
const [selectedId, setSelectedId] = useState(null);
return <UserList users={users} onSelect={setSelectedId} />;
}
这样拆的好处是,UserList 可以在别的页面换一批数据复用,测试时也只需要传入假数据。可是这种拆法不要做成硬性规定:取数据的逻辑现在通常写成自定义 Hook,比如上面的 useUsers,react.dev 说自定义 Hook 用来在组件之间共享有状态的逻辑。组件很小时,直接在组件里调用 Hook 就够了,没必要为了模式多套一层。
复合组件解决什么问题
如果 Tabs 设计成接收一个大配置对象,标题里想加个图标、面板之间想插个广告,都得给组件加新参数。复合组件的做法是把 Tabs 拆成几个小组件,让使用者像写 HTML 一样组合:
const TabsContext = createContext(null);
function Tabs({ children, defaultValue }) {
const [value, setValue] = useState(defaultValue);
return (
<TabsContext value={{ value, setValue }}>{children}</TabsContext>
);
}
function Tab({ value, children }) {
const ctx = useContext(TabsContext);
return (
<button
role="tab"
aria-selected={ctx.value === value}
onClick={() => ctx.setValue(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }) {
const ctx = useContext(TabsContext);
return ctx.value === value ? <div role="tabpanel">{children}</div> : null;
}
使用时写成 <Tabs defaultValue="a"><Tab value="a">基本信息</Tab><TabPanel value="a">...</TabPanel></Tabs>。
为什么用 Context,而不是在 Tabs 里遍历 children、给每个子元素塞 props?react.dev 的 Children 文档明确说,用 Children 方法操作 children 常常导致代码脆弱,推荐的替代做法之一就是暴露多个组件让使用者自己组合。用 Context 时,Tab 包在别的元素里也能拿到状态,而遍历 children 只能看到直接子元素。Context 的用法见《useContext 怎样用?Context 变化为什么会让所有消费组件重新渲染?》。上面的写法 <TabsContext value={...}> 是 React 19 的语法,更早的版本要写 <TabsContext.Provider value={...}>。
受控和非受控怎样同时支持
react.dev 在“组件间共享状态”一节里讲:一个 Panel 用自己的 isActive 状态时是非受控的,父组件没法影响它;改成接收 isActive prop 后就由父组件控制了。非受控组件在父组件里用起来配置少,受控组件最灵活,但要求父组件用 props 完整配置它。
组件库常见的做法是两种都支持:传了 value 就听父组件的,没传就用 defaultValue 初始化内部状态。
function useControllableState(value, defaultValue, onChange) {
const [inner, setInner] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : inner;
function setValue(next) {
if (!isControlled) setInner(next);
onChange?.(next);
}
return [current, setValue];
}
受控时不改内部状态,只通知父组件,由父组件决定要不要更新 value。要注意同一个组件实例不要在受控和非受控之间切换,否则内部状态和外部值会对不上。表单输入框里的受控与非受控见《React 中的受控组件和非受控组件是什么?》。
children 和 render props 用来定制什么
children 是最简单的定制方式。react.dev 举的例子是 Card 组件接收 children,不管里面放的是头像还是文字,Card 只负责外框。使用者写在标签中间的 JSX 会作为 children prop 传进去。
当组件需要把内部数据交给使用者、让使用者决定怎么渲染时,用 render prop。react.dev 的解释是,像 renderContent 这样指定如何渲染一部分界面的 prop 就叫 render prop,因为它是函数,组件可以把信息作为参数传给它。比如列表组件传出每一项和它是否高亮,renderItem={(item, isHighlighted) => ...}。
render props、高阶组件和自定义 Hook 三种复用方式怎样选,见《高阶组件、Render Props 和自定义 Hook 怎样选择?》。
常见追问
复合组件和传一个配置数组,哪个更好?
看定制需求。配置数组写起来短,适合结构固定的场景,react.dev 也把“接收对象数组作为 prop”列为替代
ChildrenAPI 的做法之一。需要在中间插入任意内容、改变结构时,复合组件更灵活,代价是使用者要写更多 JSX。
组件库的 Select 为什么同时有 value 和 defaultValue?
为了同时支持受控和非受控。只关心最终结果的场景,传
defaultValue让组件自己管状态,配置最少;需要联动、校验或者外部重置时,传value和onChange,状态完全由父组件掌握。