React 组件有时会在状态不变的情况下渲染两次
React component sometimes renders twice with unchanged state
我正在使用 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>
发生了什么
- ✅ 点击 "Set to Yes"。由于
yes
的值已经是 "yes",state 没有改变,因此不会重新渲染组件。
- ✅ 点击 "Toggle"。
yes
设置为 "no"。状态已更改,因此重新渲染组件。
- ✅ 点击 "Set to Yes"。
yes
设置为 "yes"。状态又变了,所以重新渲染组件。
- ⛔ 再次点击"Set to Yes"。状态没有改变,但是组件仍然重新渲染。
- ✅ 对 "Set to Yes" 的后续点击不会按预期导致重新渲染。
预计会发生什么
在第 4 步中,不应重新渲染组件,因为状态未更改。
更新
作为React docs状态,useEffect
是
suitable for the many common side effects, like setting up
subscriptions and event handlers...
一个这样的用例可能是监听浏览器事件,例如 online
和 offline
。
在此示例中,我们在组件首次呈现时调用 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>
发生了什么
- ✅ 组件首先呈现在屏幕上,消息记录在控制台中。
- ✅ 点击 "Toggle"。
online
设置为 false
。状态已更改,因此重新渲染组件。
- ⛔ 打开开发工具,在网络面板中切换到 "offline"。
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>
发生了什么
- ✅ 点击 "Set to Yes"。由于
yes
的值已经是 true
,state 没有改变,因此不会重新渲染组件。
- ✅ 点击 "Toggle"。
yes
设置为 false
。状态已更改,因此重新渲染组件。
- ✅ 点击 "Set to Yes"。
yes
设置为 true
。状态又变了,所以重新渲染组件。
- ⛔ 再次点击"Set to Yes"。状态 未 更改,尽管组件通过调用函数启动渲染过程。尽管如此,React 会在过程中间的某个地方退出渲染,并且不会调用效果。
- ✅ 后续点击 "Set to Yes" 不会按预期导致重新渲染(函数调用)。
问题
为什么组件仍然重新渲染?我做错了什么,或者这是一个 React 错误?
这似乎是预期的行为。
来自React docs:
Bailing out of a state update
If you update a State Hook to the same
value as the current state, React will bail out without rendering the
children or firing effects. (React uses the Object.is
comparison
algorithm.)
Note that React may still need to render that specific component again
before bailing out. That shouldn’t be a concern because React won’t
unnecessarily go “deeper” into the tree. If you’re doing expensive
calculations while rendering, you can optimize them with useMemo
.
因此,React 确实 在第一个演示的第 4 步和第二个演示的第 3 步重新渲染组件。因此它执行函数内的所有代码,并为组件的每个子组件调用 React.createElement()
。
但是它不会渲染组件的任何后代,也不会触发效果。
这仅适用于函数组件。对于纯 class 组件,如果状态未更改,则不会调用 render
方法。
我们无法避免重新运行。用 memo()
记住函数也无济于事,因为 it only checks for props changes,而不是状态。所以我们只需要考虑到这种情况。
这并没有回答 React 为什么以及何时 运行 函数退出,以及什么时候它根本不 运行 函数的问题。如果您知道原因,请添加您的答案。
答案是 React 使用一组启发式方法来确定它是否可以避免再次调用渲染函数。这些启发式方法可能会在版本之间发生变化,并且不能保证在状态相同时总是会退出。 React 提供的唯一保证是 如果状态相同,它不会重新渲染子组件。
你的渲染函数应该是纯净的。因此,他们运行多少次应该无关紧要。如果您在渲染函数中计算昂贵的东西并且担心调用它的次数超过必要,您可以将该计算包装在 useMemo
.
中
一般来说,"counting renders"在React中是没有用的。 React 何时调用你的函数取决于 React 本身,并且确切的时间将在版本之间不断变化。这不是合同的一部分。
我正在使用 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>
发生了什么
- ✅ 点击 "Set to Yes"。由于
yes
的值已经是 "yes",state 没有改变,因此不会重新渲染组件。 - ✅ 点击 "Toggle"。
yes
设置为 "no"。状态已更改,因此重新渲染组件。 - ✅ 点击 "Set to Yes"。
yes
设置为 "yes"。状态又变了,所以重新渲染组件。 - ⛔ 再次点击"Set to Yes"。状态没有改变,但是组件仍然重新渲染。
- ✅ 对 "Set to Yes" 的后续点击不会按预期导致重新渲染。
预计会发生什么
在第 4 步中,不应重新渲染组件,因为状态未更改。
更新
作为React docs状态,useEffect
是
suitable for the many common side effects, like setting up subscriptions and event handlers...
一个这样的用例可能是监听浏览器事件,例如 online
和 offline
。
在此示例中,我们在组件首次呈现时调用 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>
发生了什么
- ✅ 组件首先呈现在屏幕上,消息记录在控制台中。
- ✅ 点击 "Toggle"。
online
设置为false
。状态已更改,因此重新渲染组件。 - ⛔ 打开开发工具,在网络面板中切换到 "offline"。
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>
发生了什么
- ✅ 点击 "Set to Yes"。由于
yes
的值已经是true
,state 没有改变,因此不会重新渲染组件。 - ✅ 点击 "Toggle"。
yes
设置为false
。状态已更改,因此重新渲染组件。 - ✅ 点击 "Set to Yes"。
yes
设置为true
。状态又变了,所以重新渲染组件。 - ⛔ 再次点击"Set to Yes"。状态 未 更改,尽管组件通过调用函数启动渲染过程。尽管如此,React 会在过程中间的某个地方退出渲染,并且不会调用效果。
- ✅ 后续点击 "Set to Yes" 不会按预期导致重新渲染(函数调用)。
问题
为什么组件仍然重新渲染?我做错了什么,或者这是一个 React 错误?
这似乎是预期的行为。
来自React docs:
Bailing out of a state update
If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the
Object.is
comparison algorithm.)Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with
useMemo
.
因此,React 确实 在第一个演示的第 4 步和第二个演示的第 3 步重新渲染组件。因此它执行函数内的所有代码,并为组件的每个子组件调用 React.createElement()
。
但是它不会渲染组件的任何后代,也不会触发效果。
这仅适用于函数组件。对于纯 class 组件,如果状态未更改,则不会调用 render
方法。
我们无法避免重新运行。用 memo()
记住函数也无济于事,因为 it only checks for props changes,而不是状态。所以我们只需要考虑到这种情况。
这并没有回答 React 为什么以及何时 运行 函数退出,以及什么时候它根本不 运行 函数的问题。如果您知道原因,请添加您的答案。
答案是 React 使用一组启发式方法来确定它是否可以避免再次调用渲染函数。这些启发式方法可能会在版本之间发生变化,并且不能保证在状态相同时总是会退出。 React 提供的唯一保证是 如果状态相同,它不会重新渲染子组件。
你的渲染函数应该是纯净的。因此,他们运行多少次应该无关紧要。如果您在渲染函数中计算昂贵的东西并且担心调用它的次数超过必要,您可以将该计算包装在 useMemo
.
一般来说,"counting renders"在React中是没有用的。 React 何时调用你的函数取决于 React 本身,并且确切的时间将在版本之间不断变化。这不是合同的一部分。