React组件有时会呈现两次,但状态不变。

7
我正在使用Redux订阅一个存储并更新组件。这是一个没有Redux的简化示例,它使用虚拟存储来进行订阅和分发操作。请按照片段下面的步骤重现问题。 编辑:请跳到“更新”下面的第二个演示片段,以获得更加简洁和接近实际场景的示例。问题并不涉及Redux,而涉及React的setState函数标识在某些情况下导致重新渲染,即使状态没有改变。 编辑2: 在“更新2”下添加了更简明的演示。

const {useState, useEffect} = React;

let counter = 0;

const createStore = () => {
 const listeners = [];
 
 const subscribe = (fn) => {
  listeners.push(fn);
  return () => {
   listeners.splice(listeners.indexOf(fn), 1);
  };
 }
 
 const dispatch = () => {
  listeners.forEach(fn => fn());
 };
 
 return {dispatch, subscribe};
};

const store = createStore();

function Test() {
 const [yes, setYes] = useState('yes');
 
 useEffect(() => {
  return store.subscribe(() => {
   setYes('yes');
  });
 }, []);
 
 console.log(`Rendered ${++counter}`);
 
 return (
  <div>
   <h1>{yes}</h1>
   <button onClick={() => {
    setYes(yes === 'yes' ? 'no' : 'yes');
   }}>Toggle</button>
   <button onClick={() => {
    store.dispatch();
   }}>Set to Yes</button>
  </div>
 );
}

ReactDOM.render(<Test />, document.getElementById('root'));
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

正在发生什么

  1. ✅ 点击“设置为是”。由于yes的值已经是“yes”,状态没有变化,因此组件不会重新渲染。
  2. ✅ 点击“切换”。yes被设置为“no”。状态已更改,因此组件会重新渲染。
  3. ✅ 点击“设置为是”。yes被设置为“yes”。状态再次更改,因此组件会重新渲染。
  4. ⛔ 再次点击“设置为是”。状态更改,但组件仍然重新渲染。
  5. ✅ 随后点击“设置为是”将不会导致重新渲染,符合预期。

预期发生什么

在第4步中,由于状态未更改,因此不应重新渲染组件。

更新

正如React文档所述,useEffect适用于许多常见副作用,例如设置订阅和事件处理程序...

其中一个用例可能是监听浏览器事件,例如onlineoffline

在此示例中,我们通过向其传递一个空数组[]来调用useEffect内部的函数一次,当组件首次渲染时。该函数为在线状态更改设置事件侦听器。

假设,在应用程序的界面中,我们还有一个按钮可以手动切换在线状态。

请按照代码段下面的步骤重现问题。

const {useState, useEffect} = React;

let counter = 0;

function Test() {
 const [online, setOnline] = useState(true);
 
 useEffect(() => {
  const onOnline = () => {
   setOnline(true);
  };
  const onOffline = () => {
   setOnline(false);
  };
  window.addEventListener('online', onOnline);
  window.addEventListener('offline', onOffline);
  
  return () => {
   window.removeEventListener('online', onOnline);
   window.removeEventListener('offline', onOffline);
  }
 }, []);
 
 console.log(`Rendered ${++counter}`);
 
 return (
  <div>
   <h1>{online ? 'Online' : 'Offline'}</h1>
   <button onClick={() => {
    setOnline(!online);
   }}>Toggle</button>
  </div>
 );
}

ReactDOM.render(<Test />, document.getElementById('root'));
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

正在发生什么

  1. ✅ 组件首先在屏幕上呈现,消息会在控制台中记录。
  2. ✅ 点击“切换”。online被设置为false。状态已更改,因此组件将被重新呈现。
  3. ⛔ 打开开发者工具,在网络面板中切换到“离线”。online已经是false,因此状态没有改变,但组件仍然被重新呈现。

期望发生的事情

在第3步中,由于状态未更改,组件不应该被重新呈现。

更新2

const {useState, useEffect} = React;

let counterRenderComplete = 0;
let counterRenderStart = 0;


function Test() {
  const [yes, setYes] = useState('yes');

  console.log(`Component function called ${++counterRenderComplete}`);
  
  useEffect(() => console.log(`Render completed ${++counterRenderStart}`));

  return (
    <div>
      <h1>{yes ? 'yes' : 'no'}</h1>
      <button onClick={() => {
        setYes(!yes);
      }}>Toggle</button>
      <button onClick={() => {
        setYes('yes');
      }}>Set to Yes</button>
    </div>
  );
}

ReactDOM.render(<Test />, document.getElementById('root'));

 
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

正在发生什么

  1. ✅ 点击“设置为是”。由于yes的值已经是true,状态未改变,因此组件不会重新渲染。
  2. ✅ 点击“切换”。yes被设置为false。状态已经改变,所以组件会重新渲染。
  3. ✅ 点击“设置为是”。yes被设置为true。状态再次改变,所以组件会重新渲染。
  4. ⛔ 再次点击“设置为是”。尽管组件通过调用函数开始渲染过程,但状态没有改变。然而,React在渲染过程中的某个地方停止了渲染,并且效果也没有被调用。
  5. ✅ 预期的是,随后的多次点击“设置为是”不会导致重新渲染(函数调用)。

问题

为什么组件仍然重新渲染?我做错了什么吗,还是这是React的一个bug?


我相信,在调用setState方法时避免不必要的重新渲染只是一种性能优化,而不是语义上的保证……我没有看过源代码,但我的第一个猜测是,如果从嵌套在useEffect内的函数的调用堆栈中调用了setState,它将被选择退出(但也可能是完全不同的原因)。 - Aprillion
2个回答

14
答案是React使用一组启发式算法来确定是否可以避免再次调用渲染函数。这些算法可能在版本之间发生变化,并不能保证在状态相同时总是能够避免重新渲染。React提供的唯一保证是如果状态相同,它不会重新渲染子组件
您的渲染函数应该是纯净的。因此,它不应该关心它运行了多少次。如果您在渲染函数中计算了一些昂贵的东西,并担心多次调用它,则可以将该计算包装在useMemo中。
一般来说,在React中没有“计算渲染次数”的用处。React自己决定何时调用您的函数,确切的时间会随着版本的变化而改变。这不是合同的一部分。

2
谢谢。这基本上回答了我的问题。 函数组件在这方面的行为与类组件有些奇怪。对于一个纯类组件,如果状态没有改变,则不会调用render方法。使用memo()来记忆化函数组件只会检查props的更改,而不是state。因此,据我所知,目前没有办法使函数组件在props和state方面都是纯的。 正如你指出的那样,这不应该成为一个问题,因为我们应该避免在函数调用期间产生副作用和重计算。 - Tigran

4

看起来这是一种预期行为。

React文档中可以看到:

退出状态更新

如果你将State Hook更新为与当前状态相同的值,React会放弃渲染子元素或触发效果。(React使用Object.is比较算法。)

请注意,React在退出之前可能仍然需要重新渲染特定的组件。这不应该成为问题,因为React不会不必要地“深入”树中。如果你在渲染时进行了昂贵的计算,可以用useMemo进行优化。

因此,在第一个演示的第4步和第二个演示的第3步,React确实会重新渲染组件。因此它执行函数内的所有代码,并为每个组件的子元素调用React.createElement()

然而,它不会渲染组件的任何后代,并且不会触发效果。

这仅适用于函数组件。对于纯类组件,如果状态没有改变,则永远不会调用render方法。

我们无法避免重新运行。使用memo()记忆函数也无济于事,因为它只检查props更改,而不是状态。因此,我们必须考虑这种情况。

这并没有回答React何时运行该函数但退出,以及何时根本不运行该函数的问题。如果您知道原因,请添加您的答案。


1
@YevgenGorbunkov,这正是发生的情况。我在问题中添加了第三个示例以证明这一点。单击仅调用setState(true),即使状态已经为true,渲染消息也会像之前一样出现。 - Tigran

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接