组合式组件
菜单组件可以写成一个黑盒:传入 buttonText 和 items 数组,它自己渲染所有内容。这个版本能用,直到它不能用为止。调用方无法改变菜单项的渲染方式,无法重新排列各个部分,无法在别处复用按钮,而且每个 prop 都要通过 Menu 中转,即使 Menu 本身根本不使用它们。组合式组件走的是相反的路:几个小组件设计成相互配合,调用方像编写 HTML 标签一样组织它们。
HTML 从一开始就是这样运作的。<select> 离不开它的 <option>,<ul> 会为内部的 <li> 应用样式,<form> 跟踪它包裹的输入框,整个表格系列是一套生态系统,各个元素只有放在一起才有意义。组合式组件把这种父子约定带到你自己的 React 组件中。
Props 中转的问题
单体菜单在小规模上就存在中转:Menu 接收 buttonText 和 items 仅仅是为了传递给 MenuButton 和 MenuDropdown。它本身根本不关心这些值。这种中转就是 props 中转,状态提升涵盖了什么时候一两层的中转是正常的,什么时候中转的层数太多需要换个工具。跨越很多层时,组件就变成了快递员,修改任何中间层都意味着要重新梳理整条链路。
有时候最好的办法就是什么都不做。中转一两层比仓促的抽象要便宜得多,仓促的抽象花费的成本往往超过它消除的重复。下面的模式在中转真的很严重时才值得采用。
扁平化结构
组合式版本把 Menu 变成一个只渲染子元素的包裹层,把内部的各个部分提升到调用方的层级:
const sports = ['网球', '毽球', '壁球', '软式壁球']
<Menu>
<MenuButton>运动</MenuButton>
<MenuDropdown>
{sports.map(sport => (
<MenuItem key={sport}>{sport}</MenuItem>
))}
</MenuDropdown>
</Menu>每个部分都很小,专注做一件事,这些标签后面的代码基本上都是一行:
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:函数是对象,所以可以携带属性。
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:
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 的每个直接子元素都默默地收到了 open 和 toggle,按钮又能工作了。但只要你稍微一动它,就碎了,这才是真正的教训。
Children.map 的不足
有两个缺陷,都是结构性的。首先,它很脆弱:映射只触及直接子元素,所以如果调用方用一个样式 <div> 包裹 MenuDropdown,这个 div 就会收到 toggle 和 open,React 会在控制台抱怨一个函数不是 DOM 属性的有效值,菜单就停止工作了。一个禁止包裹其部分的组合式组件已经放弃了使它成立的灵活性。
其次,它深度有限:MenuItem 是孙子级,所以它什么都收不到。要把状态传到它那里,需要在 MenuDropdown 里也重复 Children.map 加 cloneElement 的舞蹈,每层一次中转,这就是 props 中转穿了件马甲。
这个模式真正需要的是从 Menu 传送状态到任何后代的方法,不管多深,不管怎么包裹。这正是context做的事,context 章节会拿同一个菜单继续往下讲:Menu 向它包裹的所有东西提供 { open, toggle },MenuButton 用 useContext 读取切换函数,不管它们之间隔着什么。
版本说明
React 文档现在把 Children 和 cloneElement 列为旧版 API,推荐在新代码中用其他方式,context 就是其中之一。它们仍然能用,在很多现存代码中也仍然存在,所以值得认得出来。课程课文和大部分较早的代码用默认导入的方式写成 React.Children 和 React.cloneElement。
select 和它的 option 一样。你能在写代码的地方看到整个结构,每个部分直接得到它的内容。 问题是这些部分仍然需要共享一些东西比如"菜单打开了吗",context 章节会展示它们协作的干净方式。
下一步:Context,这个工具让那些部分无论多深都能共享状态。

