深入理解React中的props.children

您好,我是沧沧凉凉,是一名前端开发者,目前在掘金知乎以及个人博客上同步发表一些学习前端时遇到的趣事和知识,欢迎关注。


我们在封装组件的时候通常都会使用到props.children这个属性,就像我一样,使用了React非常长的时间了,但是从来没有想过去看一下props.children里面到底有什么。

那么你是否好奇props.children里面到底是什么,我们直接看一下下面的3种情况,你可能就会对props.children里面是什么有个大概的印象。

import React from 'react';

function TestComponent({ children }: { children?: React.ReactNode }) {
  console.log(children);

  return <>{children}</>;
}

export default () => {
  return (
    <>
      <TestComponent />
      <TestComponent>这是一个子元素</TestComponent>
      <TestComponent>
        <div>这是一个子元素</div>
      </TestComponent>
      <TestComponent>
        <div>这是第一个个子元素</div>
        <div>这是第二个子元素</div>
      </TestComponent>
      <TestComponent>
        {new Array(3).fill(0).map((item, index) => (
          <span key={index}/>
        ))}
        <div>这是第一个个子元素</div>
        <div>这是第二个子元素</div>
      </TestComponent>
    </>
  );
};

运行一下代码,可以看到浏览器中会打印出下面的结果:

image-20220321214718777

综上所诉props.children的数据结构分为下面3种情况

  1. 当前组件没有子节点数据类型就是undefined
  2. 有一个子节点数据类型就是object
  3. 有多个子节点的时候数据类型为array

React提供的方法

看过上面的例子后,就会明白其实props.children的数据非常的不透明,拥有多种数据结构,如果要自己对props.children遍历那么就得分别处理这些不同的情况。

而React可能考虑到了这一点,提供了下面的五种方法用于处理这种不透明的数据结构。

React.Children.map

最常用的方法之一,顾名思义,跟数组中的map方法如出一则,会返回一个新的数组。

如果 children 是一个数组,它将被遍历并为数组中的每个子节点调用该函数。如果子节点为 null 或是 undefined,则此方法将返回 null 或是 undefined,而不会返回数组。

即使children中只有一个元素,React.Children.map依然会返回一个数组。

该方法可以配合cloneElementisValidElement向被包裹的React子元素中注入props,

import React from 'react';

function TestComponent({ children }: { children?: React.ReactNode }) {
  const child = React.Children.map(children, (child) => {
    // 是React元素的时候才进行注入
    if (React.isValidElement(child))
      return React.cloneElement(child, { name: '张三' });

    return child;
  });

  return <>{child}</>;
}

function NameBox({ name }: { name?: string }) {
  return <>{name}</>;
}

export default () => {
  return (
    <>
      <TestComponent>
        <NameBox />
      </TestComponent>
    </>
  );
};

用React Developer Tools查看该组件中的props值:

image-20220321220205518

注意:这种方法注入的props只会在子组件中,在孙组件中无法完成注入。

<TestComponent>
  <NameBox />
  <div>
    <NameBox />
  </div>
</TestComponent>

用React Developer Tools查看嵌套的NameBox组件中的props的值:

image-20220321220321513

Children.forEach

React.Children.map一样,但是它不会返回一个新数组。

Children.count

返回 children 中的组件总数量,等同于通过 mapforEach 调用回调函数的次数。

image-20220321222837659

Children.only

验证 children 是否只有一个子节点(一个 React 元素),如果有则返回它,否则此方法会抛出错误。

如果不是React元素,则也会报错:

import React from 'react';

function TestComponent({ children }: { children?: React.ReactNode }) {
  const onlyChildren = React.Children.only(children);

  return <>{onlyChildren}</>;
}

export default () => {
  return (
    <>
      <TestComponent>
        这是第一个个子元素
      </TestComponent>
    </>
  );
};

Children.toArray

children 这个复杂的数据结构以数组的方式扁平展开并返回,并为每个子节点分配一个 key。当你想要在渲染函数中操作子节点的集合时,它会非常实用,特别是当你想要在向下传递 this.props.children 之前对内容重新排序或获取子集时。

React.Children.toArray() 在拉平展开子节点列表时,更改 key 值以保留嵌套数组的语义。也就是说,toArray 会为返回数组中的每个 key 添加前缀,以使得每个元素 key 的范围都限定在此函数入参数组的对象内。

其实从源码上看,该方法内部内部也是调用的这个方法React.Children.map,所以在React.Children.map中同样也会更改key值。

import React from "react";

function TestComponent({children}: { children?: React.ReactNode }) {
  const child = React.Children.map(children, item => item);

  return <>{child}</>;
}

export default () => {
  return (
    <>
      <TestComponent>
        {new Array(3).fill(0).map((item, index) => (
          <span key={index}/>
        ))}
        <div>
          <div>
            这是第一个个子元素
          </div>
        </div>
        <div>这是第二个子元素</div>
      </TestComponent>
    </>
  );
};

在webstorm的调试模式下查看:

image-20220321221608425

源码分析

从源码上看,Children.forEach、Children.count、Children.toArray,这3个方法都是基于Children.map进行实现的,所以在这里仅仅只会分析Children.map是如何进行实现的。

那么React.Children.map到底是如何实现的呢?为什么它能够安全的遍历这种不透明的数据结构:

其实在源码中,map主要是通过mapIntoArray这个方法进行实现的,

function mapIntoArray(
  children: ?ReactNodeList,
  array: Array<React$Node>,
  escapedPrefix: string,
  nameSoFar: string,
  callback: (?React$Node) => ?ReactNodeList,
): number

我个人觉得mapIntoArray这个函数还是挺繁琐的,有兴趣可以直接看一下源码,大致原理就是mapIntoArray方法内部进行了传入的children参数类型的各种判断,然后根据不同的情况进行递归,这里指的提一下的是,在mapIntoArray中,如果检测到children为React元素,那么会对该元素的key值做一定的处理。

源码里面还有这么一段:

if (children === null) {
  invokeCallback = true;
} else {
  switch (type) {
    case 'string':
    case 'number':
      invokeCallback = true;
      break;

    case 'object':
      switch (children.$$typeof) {
        case REACT_ELEMENT_TYPE:
        case REACT_PORTAL_TYPE:
          invokeCallback = true;
      }

  }
}

当传入的children类型为object的时候,会去判断children.$$typeof,至于$$typeof这个值具体是干嘛的,我现在还没有深入研究,不过每一个React元素都有这个值。

这里涉及到了React提供的另一个方法createElement,所以这个$$typeof属性我们就放在讲解createElement这个方法的文章中。

最后

在读mapIntoArray源码的过程中,其实我依然有很多不理解的地方,比如$$typeof这个属性,还有REACT_ELEMENT_TYPEREACT_PORTAL_TYPE这两个常量是干什么,不过我感觉随着我对React的深入研究,这些问题终将在后面会得到解答。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!