使用 JSX 的地图时如何避免卸载 children 组件?

How to avoid unmounting of children components when using JSX's map?

这是 a question I raised previously 的更简洁版本。希望它能得到更好的解释和更容易理解。

这是一个小应用程序,它有 3 个需要输入数字的输入(请忽略您也可以输入 non-numbers,这不是重点)。它计算所有显示数字的总和。如果您将其中一个输入更改为另一个数字,则总和会更新。

这是它的代码:

import { useCallback, useEffect, useState } from 'react';

function App() {

  const [items, setItems] = useState([
    { index: 0, value: "1" },
    { index: 1, value: "2" },
    { index: 2, value: "3" },
  ]);

  const callback = useCallback((item) => {
    let newItems = [...items];
    newItems[item.index] = item;
    setItems(newItems);
  }, [items]);

  return (
    <div>
      <SumItems items={items} />
      <ul>
        {items.map((item) =>
          <ListItem key={item.index} item={item} callback={callback} />
        )}
      </ul>
    </div>
  );
}


function ListItem(props) {

  const [item, setItem] = useState(props.item);

  useEffect(() => {
    console.log("ListItem ", item.index, " mounting");
  })

  useEffect(() => {
    return () => console.log("ListItem ", item.index, " unmounting");
  });

  useEffect(() => {
    console.log("ListItem ", item.index, " updated");
  }, [item]);

  const onInputChange = (event) => {
    const newItem = { ...item, value: event.target.value };
    setItem(newItem);
    props.callback(newItem);
  }

  return (
    <div>
      <input type="text" value={item.value} onChange={onInputChange} />
    </div>);
};

function SumItems(props) {
  return (
    <div>Sum : {props.items.reduce((total, item) => total + parseInt(item.value), 0)}</div>
  )

}

export default App;

下面是启动时以及将第二个输入 2 更改为 4 后的控制台输出:

ListItem  0  mounting App.js:35
ListItem  0  updated App.js:43
ListItem  1  mounting App.js:35
ListItem  1  updated App.js:43
ListItem  2  mounting App.js:35
ListItem  2  updated App.js:43
ListItem  0  unmounting react_devtools_backend.js:4049:25
ListItem  1  unmounting react_devtools_backend.js:4049:25
ListItem  2  unmounting react_devtools_backend.js:4049:25
ListItem  0  mounting react_devtools_backend.js:4049:25
ListItem  1  mounting react_devtools_backend.js:4049:25
ListItem  1  updated react_devtools_backend.js:4049:25
ListItem  2  mounting react_devtools_backend.js:4049:25

如您所见,更新单个输入时,所有children不是re-rendered,它们先卸载,然后re-mounted。太浪费了,所有的输入都已经是正确的状态,只需要更新总和。想象一下有数百个这样的输入。

如果只是re-rendering的问题,我可以看看memoization。但这行不通,因为 callback 的更新正是因为 items 发生了变化。不,我的问题是关于 all 和 children.

的卸载

问题 1: 可以避免卸载吗?

如果我相信this article by Kent C. Dodds,答案就是不(强调我的):

React's key prop gives you the ability to control component instances. Each time React renders your components, it's calling your functions to retrieve the new React elements that it uses to update the DOM. If you return the same element types, it keeps those components/DOM nodes around, even if all* the props changed.

(...)

The exception to this is the key prop. This allows you to return the exact same element type, but force React to unmount the previous instance, and mount a new one. This means that all state that had existed in the component at the time is completely removed and the component is "reinitialized" for all intents and purposes.

问题 2 :如果这是真的,那么我应该考虑什么样的设计来避免看似不必要并导致问题的应用程序 in my real 因为每个输入都发生异步处理组件?

As you can see, when a single input is updated, all the children are not re-rendered, they are first unmounted, then re-mounted. What a waste, all the input are already in the right state, only the sum needs to be updated. And imagine having hundreds of those inputs.

不,您从 useEffect 看到的日志不代表组件 mount/unmount。您可以检查 DOM 并验证只有一个输入被更新,即使所有三个组件都被重新渲染。

If it was just a matter of re-rendering, I could look at memoization. But that wouldn't work because the callback is updated precisely because items change. No, my question is about the unmounting of all the children.

在这里您可以使用功能状态更新来访问以前的状态和 return 新状态。

const callback = useCallback((item) => {
    setItems((prevItems) =>
      Object.assign([...prevItems], { [item.index]: item })
    );
}, []);

现在,您可以使用 React.memo,因为回调不会改变。这是更新后的演示:

如您所见,只有相应的输入日志被记录,而不是当其中一个被更改时记录所有三个。

首先让我们澄清一些术语:

  • “重新挂载”是指 React 删除组件的内部表示,即子项(“隐藏 DOM”)和状态。重新挂载也是重新渲染,因为清理了效果并渲染了新安装的组件
  • “重新渲染”是当 React 调用 render 方法或函数组件再次调用函数本身时,将返回值与存储的子项进行比较,并根据先前渲染的结果更新子项

您观察到的不是“重新安装”,而是“重新渲染”,因为 useEffect(fn) 调用在每次重新渲染时传递的函数。要登录卸载,请使用 useEffect(fn, [])。当您正确使用密钥 属性 时,不会重新安装组件,只是重新渲染。这也可以在应用程序中轻松观察到:输入未重置(状态保持不变)。

现在你要防止的是如果道具没有改变的重新渲染。这可以通过将组件包装在 React.memo:

中来实现
const ListItem = React.memo(function ListItem() {
  // ...
}); 

请注意,通常重新渲染和区分子项通常“足够快”,并且通过使用 React.memo 如果道具已更改但组件未更新(由于不正确的 areEqual 第二个参数)。因此,如果您遇到性能问题,请谨慎使用 React.memo