React `children`和迭代方法介绍及实例

1,136 阅读3分钟

开发人员在React中使用的最明显和最常见的道具是children 。在大多数情况下,没有必要了解children 道具的样子。但在某些情况下,我们想检查children 道具,也许可以将每个孩子包裹在另一个元素/组件中,或者对它们进行重新排序或切分。在这些情况下,检查children 道具的样子就变得非常重要。

在这篇文章中,我们将看看React的一个实用工具React.Children.toArray ,它可以让我们为检查和迭代准备好children ,它的一些缺点以及如何克服它们--通过一个小型的开源包,让我们的React代码以确定的方式发挥作用,保持性能不变。如果你知道React的基础知识,并且至少对React中的children prop有一个概念,这篇文章就是为你准备的。

在使用React时,除了直接在React组件中使用children 道具外,大多数时候我们都不会再碰它。

function Parent({ children }) {
  return <div className="mt-10">{children}</div>;
}

但有时我们必须对children 道具进行迭代,这样我们就可以增强或改变儿童,而不必让组件的用户自己明确地去做。一个常见的用例是像这样将迭代索引相关的信息传递给父级的子组件。

import { Children, cloneElement } from "react";

function Breadcrumbs({ children }) {
  const arrayChildren = Children.toArray(children);

  return (
    <ul
      style={{
        listStyle: "none",
        display: "flex",
      }}
    >
      {Children.map(arrayChildren, (child, index) => {
        const isLast = index === arrayChildren.length - 1;

        if (! isLast && ! child.props.link ) {
          throw new Error(
            `BreadcrumbItem child no. ${index + 1}
            should be passed a 'link' prop`
          )
        } 

        return (
          <>
            {child.props.link ? (
              <a
                href={child.props.link}
                style={{
                  display: "inline-block",
                  textDecoration: "none",
                }}
              >
                <div style={{ marginRight: "5px" }}>
                  {cloneElement(child, {
                    isLast,
                  })}
                </div>
              </a>
            ) : (
              <div style={{ marginRight: "5px" }}>
                {cloneElement(child, {
                  isLast,
                })}
              </div>
            )}
            {!isLast && (
              <div style={{ marginRight: "5px" }}>
                >
              </div>
            )}
          </>
        );
      })}
    </ul>
  );
}

function BreadcrumbItem({ isLast, children }) {
  return (
    <li
      style={{
        color: isLast ? "black" : "blue",
      }}
    >
      {children}
    </li>
  );
}

export default function App() {
  return (
    <Breadcrumbs>
      <BreadcrumbItem
        link="https://goibibo.com/"
      >
        Goibibo
      </BreadcrumbItem>

      <BreadcrumbItem
        link="https://goibibo.com/hotels/"
      >
        Hotels
      </BreadcrumbItem>

      <BreadcrumbItem>
       A Fancy Hotel Name
      </BreadcrumbItem>
    </Breadcrumbs>
  );
}

在这里,我们正在做以下工作:

  1. 我们正在使用React.Children.toArray 方法来确保children 道具总是一个数组。如果我们不这样做,做children.length 可能会失败,因为children prop可以是一个对象,一个数组,甚至是一个函数。另外,如果我们试图在children 上直接使用数组.map 方法,它可能会被炸毁。
  2. 在父Breadcrumbs 组件中,我们通过使用实用方法React.Children.map 来迭代其子代。
  3. 因为我们可以访问迭代器函数中的indexReact.Children.map 的回调函数的第二个参数),所以我们能够检测到这个孩子是否是最后一个孩子。
  4. 如果它是最后一个孩子,我们就克隆这个元素,并把isLast 道具传给它,这样孩子就可以根据它来设计自己的样式。
  5. 如果它不是最后一个子元素,我们确保所有不是最后一个子元素的子元素都有一个link ,如果它们没有的话,我们就抛出一个错误。我们像第4步那样克隆这个元素,并像之前那样传递isLast 道具,但我们还将这个克隆的元素包裹在一个锚标签中。

BreadcrumbsBreadcrumbItem 的用户不需要担心哪些子元素应该有链接,以及它们应该如何被样式化。在Breadcrumbs 组件内,它自动得到处理。

这种_隐含地_传递道具和/或在父类中拥有state ,并将状态和状态变化器作为道具向下传递给子类的模式被称为复合组件模式。你可能从React Router的Switch 组件中熟悉这种模式,它将Route 组件作为其子代。

// example from react router docs
// https://reactrouter.com/web/api/Switch

import { Route, Switch } from "react-router";

let routes = (
  <Switch>
    <Route exact path="/">
      <Home />
    </Route>
    <Route path="/about">
      <About />
    </Route>
    <Route path="/:user">
      <User />
    </Route>
    <Route>
      <NoMatch />
    </Route>
  </Switch>
);

现在我们已经确定,在某些需求下,我们有时必须对children 道具进行迭代,并且已经使用了两个子组件的实用方法React.Children.mapReact.Children.toArray ,让我们对其中的一个组件进行复习:React.Children.toArray

React.Children.toArray

让我们先通过一个例子来看看这个方法的作用,以及它在哪些方面可能有用。

import { Children } from 'react'

function Debugger({children}) {
  // let’s log some things
  console.log(children);
  console.log(
    Children.toArray(children)
  )
  return children;
}

const fruits = [
  {name: "apple", id: 1},
  {name: "orange", id: 2},
  {name: "mango", id: 3}
]

export default function App() {
  return (
    <Debugger>
        <a
          href="https://css-tricks.com/"
          style={{padding: '0 10px'}}
        >
          CSS Tricks
        </a>

        <a
          href="https://smashingmagazine.com/"
          style={{padding: '0 10px'}}
        >
          Smashing Magazine
        </a>

        {
          fruits.map(fruit => {
            return (
              <div key={fruit.id} style={{margin: '10px'}}>
                {fruit.name}
              </div>
            )
          })
        }
    </Debugger>
  )
}

我们有一个Debugger 组件,它在渲染方面没有做什么--它只是原样返回children 。但它确实记录了两个值:childrenReact.Children.toArray(children)

如果你打开控制台,你就能看到其中的差别。

  • 第一条记录children 的语句,其值的数据结构显示如下。
[
  Object1, ----> first anchor tag
  Object2, ----> second anchor tag
  [
    Object3, ----> first fruit
    Object4, ----> second fruit
    Object5] ----> third fruit
  ]
]
  • 第二条语句记录了React.Children.toArray(children) 的日志。
[
  Object1, ----> first anchor tag
  Object2, ----> second anchor tag
  Object3, ----> first fruit
  Object4, ----> second fruit
  Object5, ----> third fruit
]

让我们阅读React docs中的方法文档,以了解发生了什么。

React.Children.toArray 返回children 不透明的数据结构,作为一个平坦的数组,键分配给每个孩子。如果你想在你的渲染方法中操作孩子的集合,特别是如果你想在向下传递之前对children 进行重新排序或切分,这很有用。

让我们把它分解一下:

  1. children 不透明的数据结构作为一个平面数组返回。
  2. 带有分配给每个孩子的键。

第一点说,那个children (这是一个不透明的数据结构,意味着它可以是一个对象、数组或一个函数,如前所述)被转换为一个平面数组。就像我们在上面的例子中看到的那样。此外,这个GitHub问题评论也解释了它的行为。

它(React.Children.toArray)不会从元素中拉出子元素并将其扁平化,这其实是没有任何意义的。它对嵌套的数组和对象进行扁平化处理,也就是说,这样一来,[['a', 'b'],['c', ['d']]] 就变成了类似于['a', 'b', 'c', 'd'] 的东西。

React.Children.toArray(
  [    ["a", "b"],
    ["c", ["d"]]
  ]
).length === 4;

让我们看看第二点('With keys assigned to each child.')是怎么说的,从例子的前几条日志中各展开一个孩子。

扩展后的孩子来自 console.log(children)

{
  $$typeof: Symbol(react.element),
  key: null,
  props: {
    href: "https://smashingmagazine.com",
    children: "Smashing Magazine",
    style: {padding: "0 10px"}
  },
  ref: null,
  type: "a",
  // … other properties
}

扩展后的子项来自console.log(React.Children.toArray(children))

{
  $$typeof: Symbol(react.element),
  key: ".0",
  props: {
    href: "https://smashingmagazine.com",
    children: "Smashing Magazine",
    style: {padding: "0 10px"}
  },
  ref: null,
  type: "a",
  // … other properties
}

正如你所看到的,除了将children 道具扁平化为一个平面数组,它还为每个子节点添加了唯一的键。来自React文档。

React.Children.toArray() 改变键值以保留嵌套数组的语义,当扁平化子列表的时候。也就是说,toArray 在返回的数组中给每个键加了前缀,这样每个元素的键都是针对包含它的输入数组的。

因为.toArray 方法可能会改变children 的顺序和位置,所以它必须确保为每一个保持唯一的键,以便进行调节和渲染优化

让我们多关注一下 so that each element’s key is scoped to the input array containing it.,看一下第二个数组中每个元素的键(对应于console.log(React.Children.toArray(children)) )。

import { Children } from 'react'

function Debugger({children}) {
  // let’s log some things
  console.log(children);
  console.log(
    Children.map(Children.toArray(children), child => {
      return child.key
    }).join('\n')
  )
  return children;
}

const fruits = [
  {name: "apple", id: 1},
  {name: "orange", id: 2},
  {name: "mango", id: 3}
]

export default function App() {
  return (
    <Debugger>
        <a
          href="https://css-tricks.com/"
          style={{padding: '0 10px'}}
        >
          CSS Tricks
        </a>
        <a
          href="https://smashingmagazine.com/"
          style={{padding: '0 10px'}}
        >
          Smashing Magazine
        </a>
        {
          fruits.map(fruit => {
            return (
              <div key={fruit.id} style={{margin: '10px'}}>
                {fruit.name}
              </div>
            )
          })
        }
    </Debugger>
  )
}
.0  ----> first link
.1  ----> second link
.2:0 ----> first fruit
.2:1 ----> second fruit
.2:2 ----> third fruit

正如你所看到的,这些水果原本是一个嵌套在原始children 数组内的数组,它们的键都是以.2 为前缀。.2 对应于它们是一个数组的一部分的事实。后缀,即:0,:1,:2 是对应于React元素(水果)的默认键。默认情况下,如果没有为列表中的元素指定键,React使用索引作为键。

因此,假设你在children 数组内有三个层次的嵌套,像这样:

import { Children } from 'react'

function Debugger({children}) {
  const retVal = Children.toArray(children)
  console.log(
    Children.map(retVal, child => {
      return child.key
    }).join('\n')
  )
  return retVal
}

export default function App() {
  const arrayOfReactElements = [
    <div key="1">First</div>,
    [
      <div key="2">Second</div>,
      [
        <div key="3">Third</div>
      ]
    ]
  ];
  return (
    <Debugger>
      {arrayOfReactElements}
    </Debugger>
  )
}

键会看起来像

.$1
.1:$2
.1:1:$3

$1,$2,$3 的后缀是因为在数组中的React元素上加了原始键,否则React会抱怨缺少键😉。

从我们目前所读到的内容来看,我们可以得出React.Children.toArray 的两种使用情况:

  1. 如果有绝对的需要,children 应该总是一个数组,你可以用React.Children.toArray(children) 来代替。即使children 是一个对象或一个函数,它也能完美地工作。

  2. 如果你必须对children prop进行排序、过滤或切分,你可以依靠React.Children.toArray ,以始终保持所有子项的唯一键。

React.Children.toArray🤔一个问题。让我们看看这段代码,了解问题出在哪里。

import { Children } from 'react'

function List({children}) {
  return (
    <ul>
      {
        Children.toArray(
          children
        ).map((child, index) => {
          return (
            <li
              key={child.key}
            >
              {child}
            </li>
          )
        })
      }
    </ul>
  )
}

export default function App() {
  return (
    <List>
      <a
        href="https://css-tricks.com"
        style={{padding: '0 10px'}}
      >
        Google
      </a>
      <>
        <a
          href="https://smashingmagazine.com"
          style={{padding: '0 10px'}}
        >
          Smashing Magazine
        </a>
        <a
          href="https://arihantverma.com"
          style={{padding: '0 10px'}}
        >
          {"Arihant’s Website"}
        </a>
      </>
    </List>
  )
}

如果你看到这个片段的子句被渲染,你会发现两个链接都被渲染在一个li 标签中!😱

这是因为 React.Children.toArray 并没有遍历到片段中。那么我们能做什么呢?幸运的是,没有什么😅。我们已经有一个开源的包,叫做 react-keyed-flatten-children.它是一个小函数,可以发挥它的魔力。

让我们看看它是怎么做的。在伪代码中(这些点在下面的实际代码中都有链接),它是这样做的:

  1. 它是一个函数,把children 作为它唯一的必要参数。
  2. 遍历React.Children.toArray(children) ,并将子代收集在一个累加器数组中。
  3. 在迭代过程中,如果一个子节点是一个字符串或一个数字,它就把这个值推到累加器数组中。
  4. 如果子节点是一个有效的React元素,它会克隆它,给它适当的键,并把它推到累积器数组中。
  5. 如果子节点是一个片段,那么函数就以片段的子节点为参数调用自己(这就是它穿越_片段_的方式),并将调用自己的结果推送到累加器数组中。
  6. 在做这一切的时候,它保持着对(片段)遍历深度的跟踪,这样片段中的子代就会有正确的键,就像我们前面看到的键对嵌套数组的作用一样。
import {
  Children,
  isValidElement,
  cloneElement
} from "react";

import { isFragment } from "react-is";

import type {
  ReactNode,
  ReactChild,
} from 'react'

/*************** 1. ***************/
export default function flattenChildren(
  // only needed argument
  children: ReactNode,
  // only used for debugging
  depth: number = 0,
  // is not required, start with default = []
  keys: (string | number)[] = [] 
): ReactChild[] {
  /*************** 2. ***************/
  return Children.toArray(children).reduce(
    (acc: ReactChild[], node, nodeIndex) => {
      if (isFragment(node)) {
        /*************** 5. ***************/
        acc.push.apply(
          acc,
          flattenChildren(
            node.props.children,
            depth + 1,
            /*************** 6. ***************/
            keys.concat(node.key || nodeIndex)
          )
        );
      } else {
        /*************** 4. ***************/
        if (isValidElement(node)) {
          acc.push(
            cloneElement(node, {
              /*************** 6. ***************/
              key: keys.concat(String(node.key)).join('.')
            })
          );
        } else if (
          /*************** 3. ***************/
          typeof node === "string"
          || typeof node === "number"
        ) {
          acc.push(node);
        }
      }
      return acc; 
    },
    /*************** Acculumator Array ***************/
    []
  );
}

让我们重试我们之前的例子,使用这个函数,看看它是否解决了我们的问题。

import flattenChildren from 'react-keyed-flatten-children'
import { Fragment } from 'react'

function List({children}) {
  return (
    <ul>
      {
        flattenChildren(
          children
        ).map((child, index) => {
          return <li key={child.key}>{child}</li>
        })
      }
    </ul>
  )
}
export default function App() {
  return (
    <List>
      <a
        href="https://css-tricks.com"
        style={{padding: '0 10px'}}
      >
        Google
      </a>
      <Fragment>
        <a
          href="https://smashingmagazine.com"
          style={{padding: '0 10px'}}>
          Smashing Magazine
        </a>

        <a
          href="https://arihantverma.com"
          style={{padding: '0 10px'}}
        >
          {"Arihant’s Website"}
        </a>
      </Fragment>
    </List>
  )
}

_Woooheeee!_它是有效的。

作为补充,如果你是测试的新手--就像我在写这篇文章时一样--你可能会对为这个实用函数编写的7个测试感兴趣。阅读这些测试来推断该函数的功能会很有趣。

Children 实用程序的长期问题

"React.Children 是一个泄漏的抽象概念,并且处于维护模式。"

-Dan Abramov

使用Children 方法来改变children 行为的问题是,它们只对组件的一层嵌套起作用。如果我们把我们的一个children 包裹在另一个组件中,我们就失去了可组合性。让我们看看我的意思,拿起我们看到的第一个例子--面包屑:

import { Children, cloneElement } from "react";

function Breadcrumbs({ children }) {
  return (
    <ul
      style={{
        listStyle: "none",
        display: "flex",
      }}
    >
      {Children.map(children, (child, index) => {
        const isLast = index === children.length - 1;
        // if (! isLast && ! child.props.link ) {
        //   throw new Error(`
        //     BreadcrumbItem child no.
        //     ${index + 1} should be passed a 'link' prop`
        //   )
        // } 
        return (
          <>
            {child.props.link ? (
              <a
                href={child.props.link}
                style={{
                  display: "inline-block",
                  textDecoration: "none",
                }}
              >
                <div style={{ marginRight: "5px" }}>
                  {cloneElement(child, {
                    isLast,
                  })}
                </div>
              </a>
            ) : (
              <div style={{ marginRight: "5px" }}>
                {cloneElement(child, {
                  isLast,
                })}
              </div>
            )}
            {!isLast && (
              <div style={{ marginRight: "5px" }}>></div>
            )}
          </>
        );
      })}
    </ul>
  );
}

function BreadcrumbItem({ isLast, children }) {
  return (
    <li
      style={{
        color: isLast ? "black" : "blue",
      }}
    >
      {children}
    </li>
  );

}
const BreadcrumbItemCreator = () =>
  <BreadcrumbItem
    link="https://smashingmagazine.com"
  >
    Smashing Magazine
  </BreadcrumbItem>

export default function App() {
  return (
    <Breadcrumbs>
      <BreadcrumbItem
        link="https://goibibo.com/"
      >
        Goibibo
      </BreadcrumbItem>

      <BreadcrumbItem
        link="https://goibibo.com/hotels/"
      >
        Goibibo Hotels
      </BreadcrumbItem>

      <BreadcrumbItemCreator />

      <BreadcrumbItem>
        A Fancy Hotel Name
      </BreadcrumbItem>
    </Breadcrumbs>
  );
}

虽然我们的新组件<BreadcrumbItemCreator /> ,但我们的Breadcrumb 组件没有办法从其中提取出link 的道具,因为这一点,它不能作为链接呈现。

为了解决这个问题,React团队提供了一个实验性的API,叫做react-call-return,现在已经不存在了。

Ryan Florence的视频详细地解释了这个问题,以及react-call-return 。由于这个包从来没有在任何版本的React中发布过,所以有计划从它那里获得灵感,做出一些可用于生产的东西。

总结

最后,我们了解到:

  1. React.Children 的实用方法。我们看到了其中的两个:React.Children.map ,看看如何用它来制作复合组件,以及React.Children.toArray 的深入。
  2. 我们看到React.Children.toArray 是如何将不透明的children 道具--可以是对象、数组或函数--转换为一个平面数组,这样就可以以必要的方式对其进行操作--排序、过滤、拼接等等。
  3. 我们了解到,React.Children.toArray 并不通过React Fragments进行遍历。
  4. 我们了解了一个叫做react-keyed-flatten-children 的开源包,并理解它是如何解决这个问题的。
  5. 我们看到,Children 实用程序处于维护模式,因为它们不能很好地编排。

你可能也有兴趣阅读Max Stoiber的博文React Children Deep Dive中如何使用其他Children 方法来完成你能用children 做的一切。