在 React 中,children 是一个 prop,存放的是你写在组件开始标签和结束标签之间的所有内容。一个在输出中某处渲染了 {children} 的组件就成了包装组件:它提供外框,调用方提供内容。
两张卡片共用同样的边框、内边距和标题样式,但装的内容完全不同。再加一张里面带链接的 <Card title="Help">,它也能免费得到同样的外框。
children 如何传到组件中
JSX 会把嵌套的内容变成一个 prop。下面这两行生成的元素完全相同:
<Card title="Profile"><p>Ada</p></Card>
<Card title="Profile" children={<p>Ada</p>} />
所以 children 是一个普通的 prop,只是传递的方式比较特别。你可以像解构其他 prop 一样解构它(function Card({ children })),也可以用 props.children 读取它。props 一页对 props 有全面的介绍。
children 中收到什么,取决于你在标签之间写了什么。一个子元素就原样传入。多个子元素会以数组形式传入。文本以字符串形式传入,什么都不写则传入 undefined。
控制台会显示 object、array of 2、string 和 undefined。你很少需要关心这些:渲染 {children} 能处理所有这些情况,而 null、undefined、true 和 false 什么也不会渲染。只有当你试图检查或修改 children 时,它的结构才有意义,而这正是最后一节要提醒你避免的做法。
布局组件
children 最常见的用法是布局组件:页面外壳、侧边栏布局、居中的单栏。组件负责结构和间距,每个使用它的页面传入自己的内容。
function PageLayout({ children }) {
return (
<div className="page">
<Header />
<main className="page-content">{children}</main>
<Footer />
</div>
);
}
function AboutPage() {
return (
<PageLayout>
<h1>About us</h1>
<p>We teach people to code.</p>
</PageLayout>
);
}
PageLayout 不知道也不关心“关于我们”页面里有什么。这种分离正是关键所在:只要改一次页头,所有用这个布局包裹的页面都会随之更新。
用命名 props 实现多个插槽
children 只是一个插槽。当组件需要在多个位置放置内容时,就把额外的部分作为命名 props 传入。任何 prop 都可以存放 JSX,所以一个模态框可以接收 title、footer,再把正文作为 children。
模态框外壳控制三个区域的边框、内边距和顺序,调用方负责填充每个区域。其他框架把这些叫作插槽(slot);在 React 中,它们就是 props。两个按钮外面的 <>...</> 是一个 Fragment,它把按钮组合在一起,又不会多出一个元素。真正的对话框还会用 portal 渲染到 document.body 中,让它浮在页面之上。
把 <p> 移到 title prop 中,正文就会出现在页头里:组件决定每个 prop 放在哪里,调用方只决定里面放什么。
把 children 当作函数
有时包装组件持有某些状态,而应该由调用方决定如何把它画出来。这时调用方传给 children 的不是元素,而是一个函数,包装组件再把值作为参数调用它。这种模式叫作 render prop。
同样的逻辑,一个 Toggle 画出按钮,另一个画出复选框。在 hooks 出现之前,render prop 是共享有状态逻辑的主要方式。如今,像 useToggle() 这样的自定义 Hook 通常读起来更清晰,但在列表、表单和动画相关的库中,你仍然会见到 render prop。
Children API 和 cloneElement
React 导出了一个 Children 对象,里面有遍历 children 的辅助函数:Children.map、Children.forEach、Children.count、Children.toArray 和 Children.only。再加上能用新 props 复制元素的 cloneElement,父组件就可以检查和修改它收到的内容。React 团队把它们列为旧版 API:它们仍然能用,但新代码应该避免使用。
原因在于,它们只能看到直接写在标签之间的元素,看不到这些元素渲染出来的内容。
列表显示了四项,但控制台显示 Children.count = 3,因为不管 TwoMore 渲染多少项,它都只是一个子元素。cloneElement 还把 style prop 传给了 TwoMore,而 TwoMore 会忽略它,所以第三项和第四项没有隔行变色。任何人把几个 <li> 元素重构成一个组件,都会在完全没碰父组件的情况下把它弄坏。
解决办法是不再深入 children 内部,而是改为传递数据:
function NumberedList({ items }) {
return (
<ol>
{items.map((item, i) => (
<li key={item.id} style={{ color: i % 2 ? 'gray' : 'black' }}>
{item.label}
</li>
))}
</ol>
);
}
现在列表自己负责渲染每一行,也不再依赖调用方是怎么写 JSX 的。当父组件需要和深层嵌套的子组件共享值(比如选中的标签页、主题)时,用 context,而不是把 props 克隆到它们身上。
在 TypeScript 中为 children 标注类型
在 TypeScript 中,把 children 的类型标注为 React.ReactNode。它涵盖了 React 能渲染的一切:元素、字符串、数字、由它们组成的数组、null、undefined 和布尔值。
import type { ReactNode } from 'react';
type CardProps = {
title: string;
children: ReactNode;
};
function Card({ title, children }: CardProps) {
return (
<section>
<h3>{title}</h3>
{children}
</section>
);
}
对于 render prop,就按它本来的函数类型标注:children: (on: boolean, toggle: () => void) => ReactNode。如果组件在没有内容时也有意义,就把 children 设为可选(children?: ReactNode)。
常见错误
忘了渲染 children。如果一个组件接收 children,却从不在输出中放入 {children},内容就会悄无声息地消失。不会有任何警告。
把不是函数的 children 当作函数调用。只有当调用方传入的是函数时,children() 才能正常工作。如果组件期望的是 render prop,就在它的名字或文档中说明,或者接收一个像 render 这样的命名 prop,让意图一目了然。
修改 children。元素是只读的。React 在开发环境中会冻结它们,所以在那里给 children.props 赋值会抛出错误。应该构建新的输出。
常见问题
React 中的 props.children 是什么?
它就是写在组件开始标签和结束标签之间的内容。在 <Card><p>Hi</p></Card> 中,Card 函数会以 props.children 的形式收到 <p>,并决定把它渲染在哪里。
children 是特殊的 prop 吗?
只在传递方式上特殊。JSX 会把嵌套的内容放进一个名为 children 的 prop,但在组件内部它就是一个普通的 prop。你也可以显式传入它,比如 <Card children={<p>Hi</p>} />,效果完全一样。
如何向组件传递多块内容?
用命名 props 传递额外的内容块。任何 prop 都可以像 children 一样存放 JSX,所以 <Modal title={<h2>Delete?</h2>} footer={<button>OK</button>}>Body</Modal> 就给了组件三个插槽。
应该使用 Children.map 和 cloneElement 吗?
在新代码中避免使用。它们只能看到直接写在标签之间的元素,看不到这些元素渲染出来的内容,所以一旦有人把某个子元素包进另一个组件,它们就会失效。改为通过 prop 传入一个数据数组,或者使用 context。
在 TypeScript 中 children 是什么类型?
用 React.ReactNode。它涵盖了 React 能渲染的一切:元素、字符串、数字、数组、null、undefined 和布尔值。