用复合组件、状态归约器,以及在 render props 与 hooks 之间的正确取舍,来设计灵活的组件 API。
在 Kodokon 中打开本课一个 <Tabs items={...} labels={...} icons={...} /> 组件最终总会被配置 props 淹没。复合组件(compound components)模式颠覆了这种做法:多个组件通过一个 context 协作并共享隐式状态,而用户则保留对 JSX 组合(顺序、嵌套、穿插的元素)的控制权。这正是 HTML 中 <select> 和 <option> 的模型,也是 Radix 或 Headless UI 这类库所采用的模型。
const TabsContext = createContext(null);
function Tabs({ defaultTab, children }) {
const [active, setActive] = useState(defaultTab);
const value = useMemo(
() => ({ active, setActive }),
[active]
);
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
}每个子组件读取 context,完全不需要任何接线 props:用户可以自由组合,而这个模式保证了一致性。注意 context 值被包裹在 useMemo 中:没有它,父组件的每次渲染都会创建一个新对象,并强制所有消费者重新渲染。
function Tab({ id, children }) {
const ctx = useContext(TabsContext);
return (
<button
aria-selected={ctx.active === id}
onClick={() => ctx.setActive(id)}
>
{children}
</button>
);
}
// <Tabs defaultTab="a">
// <Tab id="a">Profile</Tab>
// <Tab id="b">Settings</Tab>
// </Tabs>第二个模式:控制反转(inversion of control),其最精炼的形式是由 Kent C. Dodds 在 Downshift 中推广的状态归约器(state reducer)。与其为每一个新需求添加一个布尔 prop,hook 转而暴露它的状态转换:调用方提供一个 reducer,它接收每一个 action,可以放行、修改,或取消它。库保留其默认行为;细粒度的控制权则交给消费者。
function defaultReducer(state, action) {
if (action.type === 'toggle') {
return { on: !state.on };
}
return state;
}
function useToggle({ reducer = defaultReducer } = {}) {
const [state, dispatch] = useReducer(reducer, {
on: false,
});
const toggle = () => dispatch({ type: 'toggle' });
return { on: state.on, toggle };
}最后,是那个永恒的 render props 对决 hooks 的争论。在共享逻辑方面,hooks 已经胜出:组件树中没有虚假的层级,没有金字塔式的包裹层,能自然地组合多个来源。但 render prop 仍然保有一片合理的领地:当变化点关乎渲染本身时。一个虚拟化列表询问你如何绘制每一行,一个把全部 JSX 委托出去的 headless 组件:在这些场景下,作为 prop 传入的渲染函数仍然是最直接的工具,因为 hook 无法决定要在哪里把 JSX 插入调用方的树中。
function MouseTracker({ render }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
const onMove = (e) =>
setPos({ x: e.clientX, y: e.clientY });
return (
<div onMouseMove={onMove}>{render(pos)}</div>
);
}
// For the logic alone, a hook is enough:
// const pos = useMousePosition();