React children 详解,Children 工具方法与 cloneElement 实践
写一个 RadioGroup 组件,想给里面每个 RadioButton 自动补上同一个 name,最直觉的写法是 this.props.children.map(...)。跑一下,控制台丢出来一句 this.props.children.map is not a function。定睛一看,调用方只传了一个子元素,props.children 这时候压根不是数组。
这篇就是把 props.children 的真实形态、React.Children 那几个工具方法各自能兜住什么、以及 React.cloneElement 怎么给子元素批量注入属性,一次讲清楚。顺带把原文里三处经不起验证的说法改对了,我在 React 19.2.8 上一条条跑过。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
props.children到底是什么类型,为什么它有时是数组有时不是- 任何值都能当 children,JSX 对空白字符做了哪些处理
- 把函数当 children 传进去,也就是 render props 的雏形写法
React.Children的 map、forEach、count、toArray、only 分别兜住哪些边界- 用
React.cloneElement给子元素统一注入属性,实现 RadioGroup 这类联动组件 - 2026 年这个时间点,这套 API 在新项目里还该不该用
# 一、子组件
假设我们有一个组件 <Grid />,里面包含了几个 <Row />。你可能会这么使用它:
<Grid>
<Row />
<Row />
<Row />
</Grid>
这三个 Row 组件都成为了 Grid 的 props.children。父组件用一个表达式容器,就能把它们渲染出来:
class Grid extends React.Component {
render() {
return <div>{this.props.children}</div>
}
}
这里有个容易被忽略的点。children 是通过 props 传进来的,所以它对父组件来说只是一份数据,父组件完全可以选择不渲染,或者渲染之前先加工一遍。下面这个 <Fullstop /> 就把子组件全丢掉了:
class Fullstop extends React.Component {
render() {
return <h1>Hello world!</h1>
}
}
这个行为不是 bug,是设计。React 的组合模型建立在「子元素只是描述,什么时候渲染由父级决定」之上,条件渲染、插槽、包裹层这些能力全从这一条推出来。
# 二、任何东西都能是一个 child
React 中的 children 不一定是组件,它可以是任何东西。比如直接把一段文字传给 <Grid />:
<Grid>Hello world!</Grid>
JSX 在编译这段结构时会自动删除每行开头和结尾的空格以及空行,还会把字符串中间的空白行压缩为一个空格。所以你为了排版好看而缩进的那些空格,不会莫名其妙跑进渲染结果里。
多种类型的 children 也可以混着传:
<Grid>
Here is a row:
<Row />
Here is another row:
<Row />
</Grid>
回到开头那个报错。props.children 的类型完全取决于调用方怎么写:传一个子元素时它就是那个元素本身,传多个时它是数组,传文字时它是字符串,什么都不传时它是 undefined。这四种形态在同一个组件里都可能出现,所以直接当数组用一定会翻车。
这就是 React.Children 这套工具方法存在的全部理由。
# 三、把函数当 child 传进去
我们能够传递任何的 JavaScript 表达式作为 children,包括函数。下面这个组件会把接到的 child 当函数执行:
class Executioner extends React.Component {
render() {
// See how we're calling the child as a function?
// ↓
return this.props.children()
}
}
调用方这么写:
<Executioner>
{() => <h1>Hello World!</h1>}
</Executioner>
这个写法在 2016 年前后挺流行,后来演化成了 render props 模式,再后来大部分场景被自定义 Hook 接管了。它解决的问题是「渲染什么由调用方决定,什么时候渲染、拿什么数据渲染由组件决定」,Formik、React Router 的旧版 API、以及各种虚拟列表库都用过这一套。
我一直觉得这个模式比高阶组件好懂,因为数据是怎么流下来的,你在 JSX 里一眼能看到,不用去猜某个 HOC 到底往 props 里塞了什么。
# 四、操作 children
props.children 可以是任何类型,比如数组、函数、对象、字符串。React 提供了一系列函数助手来让操作 children 变得可控。
# 4.1 循环
两个最常用的助手是 React.Children.map 和 React.Children.forEach。它们在 children 是数组时能起作用,在 children 是单个元素、字符串、数字这类非数组值时也能正常工作,不会像原生数组方法那样直接抛错。
class IgnoreFirstChild extends React.Component {
render() {
const children = this.props.children
return (
<div>
{React.Children.map(children, (child, i) => {
// Ignore the first child
if (i < 1) return
return child
})}
</div>
)
}
}
<IgnoreFirstChild /> 会遍历所有的 children,忽略第一个然后返回其他的:
<IgnoreFirstChild>
<h1>First</h1>
<h1>Second</h1> // <- Only this is rendered
</IgnoreFirstChild>
这种情况下我们当然也可以用 this.props.children.map。但如果调用方传进来的是一个函数呢?this.props.children 就是一个函数而不是数组,.map 直接报错。
用 React.Children.map 就不会:
<IgnoreFirstChild>
{() => <h1>First</h1>} // <- Ignored 💪
</IgnoreFirstChild>
这里要把话说准一点。React.Children.map 面对函数类型的 child,做法不是「把它当成一个 child 遍历一遍」,而是根本不会调用你的回调,直接返回空结果。我在 React 19.2.8 上验证过,回调被触发的次数是 0。所以上面那句「Ignored」的实际含义是这个函数被整个丢掉了,不是被跳过了第一个。
# 4.2 计数
因为 this.props.children 可以是任何类型,检查一个组件有多少个 children 是件麻烦事。直接用 this.props.children.length,在传字符串或者函数时会给出离谱的结果。假设我们有个 child 是 "Hello World!",.length 会告诉你有 12 个。
这就是 React.Children.count 存在的原因:
class ChildrenCounter extends React.Component {
render() {
return <p>{React.Children.count(this.props.children)}</p>
}
}
原文这段代码漏了外层的花括号,写成了 <p>React.Children.count(this.props.children)</p>,那样渲染出来的是这一行字符串本身,不是数字。这个 typo 我改了。
它对元素、字符串、混合结构都能给出合理数字:
// Renders "1"
<ChildrenCounter>
Second!
</ChildrenCounter>
// Renders "2"
<ChildrenCounter>
<p>First</p>
<ChildComponent />
</ChildrenCounter>
原文接下来还给了一个例子,说下面这种混了函数的结构会渲染成 3:
<ChildrenCounter>
{() => <h1>First!</h1>}
Second!
<p>Third!</p>
</ChildrenCounter>
这个结论是错的,实际是 2。原因和上一节一样,React.Children.count 内部走的是同一套遍历逻辑,函数既不是合法元素也不是可迭代对象,所以它不被计入。这个我在 React 19.2.8 上单独跑过一遍确认。
也就是说,React.Children.count 数的是「React 认得的可渲染节点」,不是「你传了几个东西进来」。
# 4.3 转换为数组
如果以上方法都不趁手,你可以用 React.Children.toArray 把 children 转成真正的数组。需要排序、过滤、取前 N 个的时候,这个方法最省事:
class Sort extends React.Component {
render() {
const children = React.Children.toArray(this.props.children)
// Sort and render the children
return <p>{children.sort().join(' ')}</p>
}
}
<Sort>
// We use expression containers to make sure our strings
// are passed as three children, not as one string
{'bananas'}{'oranges'}{'apples'}
</Sort>
上例会渲染为三个排好序的字符串。
toArray 还有一个不太起眼但很重要的副作用,它会给返回数组里的每个元素重新分配 key,加上一段前缀做前缀命名空间。所以你如果先 toArray 再排序再渲染,React 不会因为顺序变了而误判成删除加新建。这个设计是真的舒服,省掉了自己补 key 的活儿。
# 4.4 执行单一 child
回过头看刚才的 <Executioner /> 组件,它只能在传单一 child、而且 child 必须是函数的情况下使用:
class Executioner extends React.Component {
render() {
return this.props.children()
}
}
原文给的第一个方案是用 propTypes 约束: