Skip to content
This page has been auto-translated and may contain errors.View in English

组合式组件

菜单组件可以写成一个黑盒:传入 buttonTextitems 数组,它自己渲染所有内容。这个版本能用,直到它不能用为止。调用方无法改变菜单项的渲染方式,无法重新排列各个部分,无法在别处复用按钮,而且每个 prop 都要通过 Menu 中转,即使 Menu 本身根本不使用它们。组合式组件走的是相反的路:几个小组件设计成相互配合,调用方像编写 HTML 标签一样组织它们。

HTML 从一开始就是这样运作的。<select> 离不开它的 <option><ul> 会为内部的 <li> 应用样式,<form> 跟踪它包裹的输入框,整个表格系列是一套生态系统,各个元素只有放在一起才有意义。组合式组件把这种父子约定带到你自己的 React 组件中。

Props 中转的问题

单体菜单在小规模上就存在中转:Menu 接收 buttonTextitems 仅仅是为了传递给 MenuButtonMenuDropdown。它本身根本不关心这些值。这种中转就是 props 中转,状态提升涵盖了什么时候一两层的中转是正常的,什么时候中转的层数太多需要换个工具。跨越很多层时,组件就变成了快递员,修改任何中间层都意味着要重新梳理整条链路。

有时候最好的办法就是什么都不做。中转一两层比仓促的抽象要便宜得多,仓促的抽象花费的成本往往超过它消除的重复。下面的模式在中转真的很严重时才值得采用。

扁平化结构

组合式版本把 Menu 变成一个只渲染子元素的包裹层,把内部的各个部分提升到调用方的层级:

jsx
const sports = ['网球', '毽球', '壁球', '软式壁球']

<Menu>
  <MenuButton>运动</MenuButton>
  <MenuDropdown>
    {sports.map(sport => (
      <MenuItem key={sport}>{sport}</MenuItem>
    ))}
  </MenuDropdown>
</Menu>

每个部分都很小,专注做一件事,这些标签后面的代码基本上都是一行:

jsx
function Menu({ children }) {
  return <div className="menu">{children}</div>
}

function MenuButton({ children }) {
  return <Button>{children}</Button>
}

function MenuDropdown({ children }) {
  return <div className="menu-dropdown">{children}</div>
}

function MenuItem({ children }) {
  return <div className="menu-item">{children}</div>
}

MenuButton 复用了组合章节中的 Button。原本隐在 Menu 内部的结构现在在调用处清晰可见,数据直接流向使用它的组件:数组在需要的地方直接映射,不用通过 Menu 中转。想让每一项都变成链接而不是纯文本?在 MenuItem 里放个 <a> 就行。组件的作者根本不用预料到这个需求。

代价是用详细程度换取透明度。调用方写更多 JSX,对每一层有细致的控制;黑盒消失了,从两个角度都是如此。

这些部分是独立的组件,所以调用方需要为每个都导入。库通常会把子组件作为属性附加到父组件上再导出,这是简单的 JavaScript:函数是对象,所以可以携带属性。

jsx
Menu.Button = MenuButton
Menu.Dropdown = MenuDropdown
Menu.Item = MenuItem
export default Menu

一个导入就能用上整个家族,调用处的 <Menu.Button> 表明这些部分属于一起。点号语法只是命名,别的没有。让这些部分真正协作的东西必须从某处来,这就是下一节要讲的缺失部分。

缺失的部分:隐式状态

扁平化的菜单有个漏洞:按钮再也打不开下拉菜单了。打开/关闭的布尔值和切换函数住在 Menu 里,而 Menu 只渲染它的包裹 div 和 {children}。没有 prop 被传给 MenuButton,所以没地方挂切换函数。这些部分需要共享状态,但不能让调用方在每个标签上都写一遍,社区把这叫做隐式状态:组合式部分在幕后协调,而调用方只需组织它们。

React 有一个 API 可以模拟这个。Children.map 遍历组件的直接子元素(props.children 没有保证的形状:多个子元素时是数组,单个子元素时是元素本身,没有子元素时是 undefined,所以调用 .map() 在大部分情况下都能工作,直到某个情况下不工作),cloneElement 复制一个元素并注入额外的 props:

jsx
import { Children, cloneElement, useState } from 'react'

function Menu({ children }) {
  const [open, setOpen] = useState(false)
  const toggle = () => setOpen(prevOpen => !prevOpen)

  return (
    <div className="menu">
      {Children.map(children, child =>
        cloneElement(child, { open, toggle })
      )}
    </div>
  )
}

现在 Menu 的每个直接子元素都默默地收到了 opentoggle,按钮又能工作了。但只要你稍微一动它,就碎了,这才是真正的教训。

Children.map 的不足

有两个缺陷,都是结构性的。首先,它很脆弱:映射只触及直接子元素,所以如果调用方用一个样式 <div> 包裹 MenuDropdown,这个 div 就会收到 toggleopen,React 会在控制台抱怨一个函数不是 DOM 属性的有效值,菜单就停止工作了。一个禁止包裹其部分的组合式组件已经放弃了使它成立的灵活性。

其次,它深度有限:MenuItem 是孙子级,所以它什么都收不到。要把状态传到它那里,需要在 MenuDropdown 里也重复 Children.mapcloneElement 的舞蹈,每层一次中转,这就是 props 中转穿了件马甲。

这个模式真正需要的是从 Menu 传送状态到任何后代的方法,不管多深,不管怎么包裹。这正是context做的事,context 章节会拿同一个菜单继续往下讲:Menu 向它包裹的所有东西提供 { open, toggle }MenuButtonuseContext 读取切换函数,不管它们之间隔着什么。

版本说明

React 文档现在把 ChildrencloneElement 列为旧版 API,推荐在新代码中用其他方式,context 就是其中之一。它们仍然能用,在很多现存代码中也仍然存在,所以值得认得出来。课程课文和大部分较早的代码用默认导入的方式写成 React.ChildrenReact.cloneElement

决定一个组件是否应该是组合式的,归根结底取决于谁需要控制中间部分。内部布局固定且不太可能改变的组件用单体形式配几个 props 更好;当调用方不断要求一个额外的渲染选项,prop 列表变成了配置语言时,组合式模式才能收回成本。信息展示组件、菜单、标签页、手风琴都在这个范围内,这就是为什么每个主流无头 UI 库都以组合式形式提供它们。

发布一个组件家族有两个成本值得评估。把部分附加到父组件会为打包工具绑定它们,所以导入 Menu 会把所有子组件都拉进来,不管页面是否渲染它们,一个少用的部分不管怎样都会跟着块大小增加。

第二个成本是组合式 API 可以描述它期望的结构而根本不强制它:没什么能阻止调用方在没有上面 MenuButton 的情况下渲染 MenuDropdown,通过比较子元素的 type 和组件身份来检查会在有包裹层或一个构建中有两份模块副本时就坏了。能撑住的版本记录了本来的形状,当形状被破坏时让每个部分自己表现合理。

Juno能配合的小部分 与其用一个大菜单组件接一大堆 props 然后自己渲染所有东西,不如用组合式组件给你几个小标签,Menu、MenuButton、MenuDropdown、MenuItem,你自己把它们排列起来,就像在 HTML 里排列 select 和它的 option 一样。你能在写代码的地方看到整个结构,每个部分直接得到它的内容。

问题是这些部分仍然需要共享一些东西比如"菜单打开了吗",context 章节会展示它们协作的干净方式。

Juno能配合的小部分 当一个单独组件的 props 变成配置语言时就可以用组合式组件:暴露这些部分,让调用方组织它们,在调用处映射数据而不是通过包裹层中转。

Children.mapcloneElement 能向直接子元素注入共享状态,但在包裹 div 下会坏,也永远到不了孙子元素,所以把它当跳脚石,真正的协作用 context。

Juno能配合的小部分 组合式组件用透明的可组合 API 交换不透明的单组件 API;当调用方需要控制中间层时做这个交换,当布局固定时就跳过。

通过作用域 context provider 让这些部分协调,而不是 Children.mapcloneElement,它们是旧版 API,在包裹下很脆弱,设计上深度受限。

把这些部分作为一个家族发布是有代价的:把它们附加到父组件会让打包工具没法丢掉页面从不渲染的部分,而且 API 可以记录期望的结构而根本不强制它。

下一步:Context,这个工具让那些部分无论多深都能共享状态。