前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

React children 详解,Children 工具方法与 cloneElement 实践

首页2019-09-01 16:10:40Front-End
ReactReact.cloneElementReact.Children组件设计

写一个 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 约束:

fe
  • 一、子组件
  • 二、任何东西都能是一个 child
  • 三、把函数当 child 传进去
  • 四、操作 children
    • 4.1 循环
    • 4.2 计数
    • 4.3 转换为数组
    • 4.4 执行单一 child
  • 五、编辑 children
    • 5.1 改变 children 的属性
    • 5.2 React.cloneElement 克隆元素
  • 六、2026 年再看这套 API
  • 总结
  • 参考

← await 在 forEach 中不生效的原因与四种替代写法Git操作清单 从撤销到rebase的日常命令梳理 →