下面是一个可变引用的示例,它存储了来自过度反应博客的当前回调:
function useInterval(callback, delay) {
const savedCallback = useRef();
// update ref before 2nd effect
useEffect(() => {
savedCallback.current = callback; // save the callback in a mutable ref
});
useEffect(() => {
function tick() {
// can always access the most recent callback value without callback dep
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
但是,React Hook FAQ 指出不建议使用该模式:
另请注意,此模式可能会导致并发模式下出现问题。[...]
无论哪种情况,我们都不建议使用此模式,仅在此处显示以保持完整。
我发现这种模式对于回调非常有用,并且不明白为什么它在常见问题解答中会出现危险信号。例如,客户端组件可以使用useInterval
,而无需将useCallback
包装在回调(更简单的 API)周围。
在并发模式下也不应该有问题,因为我们在useEffect
中更新了 ref .从我的角度来看,FAQ条目在这里可能有一个错误的观点(或者我误解了它)。
所以,总结一下:
- 有什么从根本上反对在可变引用中存储回调吗?
- 像上面的代码一样在并发模式下是否安全,如果不是,为什么不呢?
次要免责声明:我不是核心反应开发人员,也没有看过反应代码,所以这个答案是基于阅读文档(字里行间)、经验和实验
此外,这个问题也被问了出来,因为它明确指出了useInterval()
实现的意外行为
有什么从根本上反对在可变引用中存储回调吗?
我对 react 文档的阅读是,不建议这样做,但在某些情况下可能仍然是一个有用甚至必要的解决方案,因此"逃生舱口"参考,所以我认为答案是"否"。我认为不建议这样做,因为:
您明确拥有管理要保存的闭包的生存期的所有权。当它过时时,当它过时时,你只能靠自己修复它。
这很容易以微妙的方式出错,见下文。
此模式在文档中作为如何在处理程序更改时重复呈现子组件的示例给出,并且如文档所述:
最好避免在内心深处传递回调
例如,通过使用上下文。这样,您的孩子就不太可能在每次重新渲染父级时都需要重新渲染。因此,在此用例中,有更好的方法可以做到这一点,但这将依赖于能够更改子组件。
但是,我确实认为这样做可以解决某些难以解决的问题,并且拥有像useInterval()
这样的库函数的好处,该函数在您的代码库中经过测试和现场强化,其他开发人员可以使用,而不是尝试直接使用setInterval
滚动自己的函数(可能使用全局变量......这将更糟)将超过使用useRef()
实施它的负面影响。如果存在错误,或者更新引入了一个错误来做出反应,只有一个地方可以修复它。
此外,您的回调在过期时可能是安全的,因为它可能只是捕获了不变的变量。例如,useState()
返回的setState
函数保证不会更改,请参阅此处的最后一个注释,因此只要您的回调只使用这样的变量,您就坐得很漂亮。
话虽如此,您给出的setInterval()
的实施确实存在缺陷,请参阅下文以及我建议的替代方案。
在并发模式下安全吗,当像上面的代码一样完成时(如果不是,为什么)?
现在我不完全知道并发模式是如何工作的(而且它还没有最终确定 AFAIK),但我的猜测是下面的窗口条件很可能会被并发模式加剧,因为据我了解,它可能会将状态更新与渲染分开,增加窗口条件,即仅在useEffect()
触发时(即在渲染时)更新的回调将在过期时调用。
显示您的useInterval
在过期时可能会弹出的示例。
在下面的示例中,我演示了setInterval()
计时器可能会在setState()
和设置更新回调的useEffect()
调用之间弹出,这意味着回调在过期时被调用,如上所述,这可能是可以的,但它可能会导致错误。
在示例中,我修改了您的setInterval()
,使其在某些事件后终止,并且我使用了另一个 ref 来保存num
的"真实"值。我使用两个setInterval()
:
- 一个只是记录存储在 ref 和渲染函数局部变量中的
num
值。 - 另一个定期更新
num
,同时更新numRef
中的值并调用setNum()
以重新渲染并更新局部变量。
现在,如果保证在调用setNum()
时会立即调用下一个渲染的useEffect()
,我们希望新回调会立即安装,因此无法调用过期闭包。但是,我的浏览器中的输出是这样的:
[Log] interval pop 0 0 (main.chunk.js, line 62)
[Log] interval pop 0 1 (main.chunk.js, line 62, x2)
[Log] interval pop 1 1 (main.chunk.js, line 62, x3)
[Log] interval pop 2 2 (main.chunk.js, line 62, x2)
[Log] interval pop 3 3 (main.chunk.js, line 62, x2)
[Log] interval pop 3 4 (main.chunk.js, line 62)
[Log] interval pop 4 4 (main.chunk.js, line 62, x2)
每次数字不同时,说明回调是在调用setNum()
之后,但在第一个useEffect()
配置新回调之前调用的。
添加更多跟踪后,差异日志的顺序显示为:
setNum()
被称为,render()
发生- "间隔弹出"日志
- 调用
useEffect()
更新引用。
即计时器在render()
和更新计时器回调函数的useEffect()
之间意外弹出。
显然,这是一个人为的例子,在现实生活中,您的组件可能要简单得多,实际上无法点击此窗口,但至少意识到这一点是件好事!
import { useEffect, useRef, useState } from 'react';
function useInterval(callback, delay, maxOccurrences) {
const occurrencesRef = useRef(0);
const savedCallback = useRef();
// update ref before 2nd effect
useEffect(() => {
savedCallback.current = callback; // save the callback in a mutable ref
});
useEffect(() => {
function tick() {
// can always access the most recent callback value without callback dep
savedCallback.current();
occurrencesRef.current += 1;
if (occurrencesRef.current >= maxOccurrences) {
console.log(`max occurrences (delay ${delay})`);
clearInterval(id);
}
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
function App() {
const [num, setNum] = useState(0);
const refNum = useRef(num);
useInterval(() => console.log(`interval pop ${num} ${refNum.current}`), 0, 60);
useInterval(() => setNum((n) => {
refNum.current = n + 1;
return refNum.current;
}), 10, 20);
return (
<div className="App">
<header className="App-header">
<h1>Num: </h1>
</header>
</div>
);
}
export default App;
没有相同问题的替代useInterval()
。
react 的关键始终是知道何时调用处理程序/闭包。如果你天真地使用任意函数setInterval()
,那么你可能会遇到麻烦。但是,如果确保仅在调用useEffect()
处理程序时调用处理程序,则您将知道在进行所有状态更新后调用它们,并且您处于一致状态。因此,此实现不会以与上述实现相同的方式受到影响,因为它确保在useEffect()
中调用不安全处理程序,并且仅从setInterval()
调用安全处理程序:
import { useEffect, useRef, useState } from 'react';
function useTicker(delay, maxOccurrences) {
const [ticker, setTicker] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTicker((t) => {
if (t + 1 >= maxOccurrences) {
clearInterval(timer);
}
return t + 1;
}), delay);
return () => clearInterval(timer);
}, [delay]);
return ticker;
}
function useInterval(cbk, delay, maxOccurrences) {
const ticker = useTicker(delay, maxOccurrences);
const cbkRef = useRef();
// always want the up to date callback from the caller
useEffect(() => {
cbkRef.current = cbk;
}, [cbk]);
// call the callback whenever the timer pops / the ticker increases.
// This deliberately does not pass `cbk` in the dependencies as
// otherwise the handler would be called on each render as well as
// on the timer pop
useEffect(() => cbkRef.current(), [ticker]);
}