cloneElement

Pitfall

使用 cloneElement 并不常见,而且可能导致代码脆弱。查看常见替代方案。

cloneElement 允许你基于另一个元素作为起点来创建一个新的 React 元素。

const clonedElement = cloneElement(element, props, ...children)

参考

cloneElement(element, props, ...children)

调用 cloneElement 可基于 element 创建一个 React 元素,但使用不同的 propschildren

import { cloneElement } from 'react';

// ...
const clonedElement = cloneElement(
<Row title="Cabbage">
Hello
</Row>,
{ isHighlighted: true },
'Goodbye'
);

console.log(clonedElement); // <Row title="Cabbage" isHighlighted={true}>Goodbye</Row>

查看更多示例。

参数

  • elementelement 参数必须是一个有效的 React 元素。例如,它可以是像 <Something /> 这样的 JSX 节点、调用 createElement 的结果,或者另一次 cloneElement 调用的结果。

  • propsprops 参数必须是一个对象或者 null。如果你传入 null,克隆后的元素将保留原始 element.props 的全部内容。否则,对于 props 对象中的每个 prop,返回的元素都会“优先”采用 props 中的值,而不是 element.props 中的值。其余的 props 将从原始的 element.props 中补齐。如果你传入 props.keyprops.ref,它们将替换原有的值。

  • 可选 ...children:零个或多个子节点。它们可以是任何 React 节点,包括 React 元素、字符串、数字、portal、空节点(nullundefinedtruefalse)以及 React 节点数组。如果你没有传入任何 ...children 参数,原始的 element.props.children 将被保留。

返回值

cloneElement 返回一个带有若干属性的 React 元素对象:

  • type:与 element.type 相同。
  • props:将 element.props 与你传入的覆盖 props 进行浅合并后的结果。
  • ref:原始的 element.ref,除非它被 props.ref 覆盖。
  • key:原始的 element.key,除非它被 props.key 覆盖。

通常,你会从组件中返回这个元素,或者把它作为另一个元素的子元素。虽然你可以读取元素的属性,但最好在元素创建后把它当作不透明对象,只负责渲染它。

注意事项

  • 克隆一个元素不会修改原始元素。

  • 只有在 children **都是静态已知的情况下,才应将它们作为多个参数传入 cloneElement,**例如 cloneElement(element, null, child1, child2, child3)。如果 children 是动态的,请将整个数组作为第三个参数传入:cloneElement(element, null, listItems)。这样可以确保 React 会对任何动态列表中缺失的 key 发出警告。对于静态列表来说,这不是必须的,因为它们不会重新排序。

  • cloneElement 会让追踪数据流变得更困难,因此请尝试改用 替代方案


用法

覆盖元素的 props

要覆盖某个 React 元素 的 props,请将它传给 cloneElement,并带上你想要覆盖的 props

import { cloneElement } from 'react';

// ...
const clonedElement = cloneElement(
<Row title="Cabbage" />,
{ isHighlighted: true }
);

这里得到的 克隆元素 将是 <Row title="Cabbage" isHighlighted={true} />

让我们通过一个例子来看看它什么时候有用。

假设有一个 List 组件,它将自己的 children 渲染为可选择的行列表,并带有一个“Next”按钮,用于改变当前选中的行。List 组件需要以不同方式渲染被选中的 Row,因此它会克隆收到的每个 <Row> 子元素,并额外添加一个 isHighlighted: trueisHighlighted: false prop:

export default function List({ children }) {
const [selectedIndex, setSelectedIndex] = useState(0);
return (
<div className="List">
{Children.map(children, (child, index) =>
cloneElement(child, {
isHighlighted: index === selectedIndex
})
)}

假设 List 接收到的原始 JSX 如下所示:

<List>
<Row title="Cabbage" />
<Row title="Garlic" />
<Row title="Apple" />
</List>

通过克隆它的 children,List 可以向内部的每个 Row 传递额外信息。结果看起来像这样:

<List>
<Row
title="Cabbage"
isHighlighted={true}
/>
<Row
title="Garlic"
isHighlighted={false}
/>
<Row
title="Apple"
isHighlighted={false}
/>
</List>

注意点击“Next”后会更新 List 的状态,并高亮另一行:

import { Children, cloneElement, useState } from 'react';

export default function List({ children }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  return (
    <div className="List">
      {Children.map(children, (child, index) =>
        cloneElement(child, {
          isHighlighted: index === selectedIndex
        })
      )}
      <hr />
      <button onClick={() => {
        setSelectedIndex(i =>
          (i + 1) % Children.count(children)
        );
      }}>
        Next
      </button>
    </div>
  );
}

总之,List 克隆了它接收到的 <Row /> 元素,并向它们添加了一个额外的 prop。

Pitfall

克隆 children 会让你难以看清数据在应用中的流动方式。请尝试使用替代方案。


替代方案

使用 render prop 传递数据

与其使用 cloneElement,不如考虑接收一个类似 renderItemrender prop。在这里,ListrenderItem 作为 prop 接收。List 会为每个条目调用 renderItem,并将 isHighlighted 作为参数传入:

export default function List({ items, renderItem }) {
const [selectedIndex, setSelectedIndex] = useState(0);
return (
<div className="List">
{items.map((item, index) => {
const isHighlighted = index === selectedIndex;
return renderItem(item, isHighlighted);
})}

renderItem 这个 prop 被称为“render prop”,因为它是一个用来指定如何渲染某物的 prop。例如,你可以传入一个 renderItem 的实现,它会使用给定的 isHighlighted 值来渲染一个 <Row>

<List
items={products}
renderItem={(product, isHighlighted) =>
<Row
key={product.id}
title={product.title}
isHighlighted={isHighlighted}
/>
}
/>

最终结果与使用 cloneElement 时相同:

<List>
<Row
title="Cabbage"
isHighlighted={true}
/>
<Row
title="Garlic"
isHighlighted={false}
/>
<Row
title="Apple"
isHighlighted={false}
/>
</List>

不过,你可以清楚地追踪 isHighlighted 的值来自哪里。

import { useState } from 'react';

export default function List({ items, renderItem }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  return (
    <div className="List">
      {items.map((item, index) => {
        const isHighlighted = index === selectedIndex;
        return renderItem(item, isHighlighted);
      })}
      <hr />
      <button onClick={() => {
        setSelectedIndex(i =>
          (i + 1) % items.length
        );
      }}>
        Next
      </button>
    </div>
  );
}

这种模式比 cloneElement 更推荐,因为它更明确。


通过 context 传递数据

cloneElement 的另一种替代方案是通过 context 传递数据。

例如,你可以调用 createContext 来定义一个 HighlightContext

export const HighlightContext = createContext(false);

你的 List 组件可以把它渲染的每个条目包裹在一个 HighlightContext provider 中:

export default function List({ items, renderItem }) {
const [selectedIndex, setSelectedIndex] = useState(0);
return (
<div className="List">
{items.map((item, index) => {
const isHighlighted = index === selectedIndex;
return (
<HighlightContext key={item.id} value={isHighlighted}>
{renderItem(item)}
</HighlightContext>
);
})}

采用这种方式后,Row 根本不需要接收 isHighlighted prop。相反,它直接读取 context:

export default function Row({ title }) {
const isHighlighted = useContext(HighlightContext);
// ...

这样调用方组件就不需要知道,也不用担心把 isHighlighted 传给 <Row>

<List
items={products}
renderItem={product =>
<Row title={product.title} />
}
/>

取而代之的是,ListRow 通过 context 协同实现高亮逻辑。

import { useState } from 'react';
import { HighlightContext } from './HighlightContext.js';

export default function List({ items, renderItem }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  return (
    <div className="List">
      {items.map((item, index) => {
        const isHighlighted = index === selectedIndex;
        return (
          <HighlightContext
            key={item.id}
            value={isHighlighted}
          >
            {renderItem(item)}
          </HighlightContext>
        );
      })}
      <hr />
      <button onClick={() => {
        setSelectedIndex(i =>
          (i + 1) % items.length
        );
      }}>
        Next
      </button>
    </div>
  );
}

了解更多关于通过 context 传递数据的内容。


将逻辑提取到自定义 Hook 中

你还可以尝试的另一种方法是将“非可视化”逻辑提取到你自己的 Hook 中,并使用 Hook 返回的信息来决定渲染什么。例如,你可以编写一个 useList 自定义 Hook,如下所示:

import { useState } from 'react';

export default function useList(items) {
const [selectedIndex, setSelectedIndex] = useState(0);

function onNext() {
setSelectedIndex(i =>
(i + 1) % items.length
);
}

const selected = items[selectedIndex];
return [selected, onNext];
}

然后你可以像这样使用它:

export default function App() {
const [selected, onNext] = useList(products);
return (
<div className="List">
{products.map(product =>
<Row
key={product.id}
title={product.title}
isHighlighted={selected === product}
/>
)}
<hr />
<button onClick={onNext}>
Next
</button>
</div>
);
}

数据流是显式的,但状态位于 useList 自定义 Hook 内,你可以在任何组件中使用它:

import Row from './Row.js';
import useList from './useList.js';
import { products } from './data.js';

export default function App() {
  const [selected, onNext] = useList(products);
  return (
    <div className="List">
      {products.map(product =>
        <Row
          key={product.id}
          title={product.title}
          isHighlighted={selected === product}
        />
      )}
      <hr />
      <button onClick={onNext}>
        Next
      </button>
    </div>
  );
}

如果你想在不同组件之间复用这段逻辑,这种方法尤其有用。