Kodokon kodokon.com

专家模式:复合组件、控制反转

用复合组件、状态归约器,以及在 render props 与 hooks 之间的正确取舍,来设计灵活的组件 API。

10 分钟 · 3 题

在 Kodokon 中打开本课

一个 <Tabs items={...} labels={...} icons={...} /> 组件最终总会被配置 props 淹没。复合组件(compound components)模式颠覆了这种做法:多个组件通过一个 context 协作并共享隐式状态,而用户则保留对 JSX 组合(顺序、嵌套、穿插的元素)的控制权。这正是 HTML 中 <select><option> 的模型,也是 Radix 或 Headless UI 这类库所采用的模型。

JSX
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>
  );
}
父组件通过一个 memo 化的 context 暴露共享状态。

每个子组件读取 context,完全不需要任何接线 props:用户可以自由组合,而这个模式保证了一致性。注意 context 值被包裹在 useMemo 中:没有它,父组件的每次渲染都会创建一个新对象,并强制所有消费者重新渲染。

JSX
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>
无需接线 props:context 建立起连接。

第二个模式:控制反转(inversion of control),其最精炼的形式是由 Kent C. Dodds 在 Downshift 中推广的状态归约器(state reducer)。与其为每一个新需求添加一个布尔 prop,hook 转而暴露它的状态转换:调用方提供一个 reducer,它接收每一个 action,可以放行、修改,或取消它。库保留其默认行为;细粒度的控制权则交给消费者。

JSX
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 插入调用方的树中。

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();
用 render prop 委托渲染,用 hook 处理逻辑。

知识检测

确认你已牢记本课的重点内容。

  1. 哪种机制让复合组件在没有显式 props 的情况下共享它们的状态?
    • 一个共享的模块变量
    • 由父组件提供的一个 React context
    • 在每个子组件中复制这份状态
    • 通过 cloneElement 向下传递的 refs
  2. 状态归约器模式究竟反转了什么?
    • 父子之间数据流的方向
    • 对状态转换的控制权,交给了调用方
    • hooks 的执行顺序
    • 渲染 fallback 的职责
  3. 在哪种情况下 render prop 仍然优于 hook?
    • 当你想在组件之间共享逻辑时
    • 当调用方必须提供要插入到组件树中的 JSX 时
    • 当你需要 memo 化一个昂贵的计算时
    • 当组件没有状态时