什么是 React 复合组件

7,949 阅读3分钟

本文翻译自文章 React Hooks: Compound Components,略有增减。

如果你还没有接触过复合组件,那么阅读完本文就会对它有初步认识。

复合组件是 React 的一个高级模式,通常是由两个或两个以上的组件共同来实现某项功能。其中一个组件作为父组件,其余组件作为它的子组件,利用这种显式父子关系来共享隐式状态。

复合组件支持组件中的状态和逻辑的共享,可以帮助开发人员构建更直观和灵活性的 API,避免了在子组件间使用 props 进行通信。

复合组件可以类比 HTML 中的 <select><option>

<select>
  <option value="value1">key1</option>
  <option value="value2">key2</option>
  <option value="value3">key3</option>
</select>

如果单独使用其中的一个标签,并不能实现正常的功能。此外,它们是一组非常棒的 API。如果不是这种复合组件式的 API(这是 HTML,而不是 JSX),它很可能是下面的样子:

<select options="key1:value1;key2:value2;key3:value3"></select

在这种实现方式中,API 一点都不优雅,特别是考虑到它还要支持 disabled 等其它属性。因此,复合组件为我们提供了一种表达组件间关系的方法。

这其中的另一个重要概念是“隐式状态”。<select> 元素隐式存储了关于所选 <option> 的状态,并与它的子组件共享该状态,以便它们根据该状态来渲染自己。但这种状态的共享是隐式的,因为在 HTML 代码中没有任何途径可以访问这个状态(而且也不需要去访问)。

好了,让我们来看看一个真实的 React 组件,以进一步了解复合组件的原理。下面是 Reach UI 中的 <Menu /> 组件 的一个示例,它提供复合组件 API:

function App() {
  return (
    <Menu>
      <MenuButton>
        Actions <span aria-hidden></span>
      </MenuButton>
      <MenuList>
        <MenuItem onSelect={() => alert('Download')}>Download</MenuItem>
        <MenuItem onSelect={() => alert('Copy')}>Create a Copy</MenuItem>
        <MenuItem onSelect={() => alert('Delete')}>Delete</MenuItem>
      </MenuList>
    </Menu>
  )
}

在这个例子中,<Menu> 封装了一些可以共享的隐式状态,而且 <MenuButton><MenuList><MenuItem> 组件都可以访问和操作这个状态,并且这些都是封装在组件内部的。这样可以让组件的 API 更加优雅。

那么如何实现这样的组件呢?通常有两种方法:第一种是对 children 使用 React.cloneElement;第二种是使用 React context。在本篇文章中,主要介绍如何使用 React context 创建一组简单的复合组件。

接下来,我们以 <Toggle> 组件为例来介绍实现过程。<Toggle> 组件包含了 <ToggleOn><ToggleOff><ToggleButton /> 三个组件,当 <ToggleButton> 被点击时,会根据当前状态来展示 <ToggleOn><ToggleOff> 中的内容。该组件的使用方式如下:

function App() {
  return (
    <Toggle onToggle={on => console.log(on)}>
      <ToggleOn>The button is on</ToggleOn>
      <ToggleOff>The button is off</ToggleOff>
      <ToggleButton />
    </Toggle>
  )
}

具体效果如下:

接下来,我们来看一下使用 context 和 hooks 来实现 <Toggle> 的完整代码:

import * as React from 'react'
// this switch implements a checkbox input and is not relevant for this example
import {Switch} from '../switch'

const ToggleContext = React.createContext()

function useEffectAfterMount(cb, dependencies) {
  const justMounted = React.useRef(true)
  React.useEffect(() => {
    if (!justMounted.current) {
      return cb()
    }
    justMounted.current = false
  }, dependencies)
}

function Toggle(props) {
  const [on, setOn] = React.useState(false)
  const toggle = React.useCallback(() => setOn(oldOn => !oldOn), [])
  useEffectAfterMount(() => {
    props.onToggle(on)
  }, [on])
  const value = React.useMemo(() => ({on, toggle}), [on])
  return (
    <ToggleContext.Provider value={value}>
      {props.children}
    </ToggleContext.Provider>
  )
}

function useToggleContext() {
  const context = React.useContext(ToggleContext)
  if (!context) {
    throw new Error(
      `Toggle compound components cannot be rendered outside the Toggle component`,
    )
  }
  return context
}

function ToggleOn({children}) {
  const {on} = useToggleContext()
  return on ? children : null
}

function ToggleOff({children}) {
  const {on} = useToggleContext()
  return on ? null : children
}

function ToggleButton(props) {
  const {on, toggle} = useToggleContext()
  return <Switch on={on} onClick={toggle} {...props} />
}

在代码里,我们使用 React 创建了一个 context,用于存储和更新状态;而 <Toggle> 组件负责将这个 context 提供给其他子组件。

希望本文能让您对复合组件有一定的了解。