次要免责声明:我不是核心反应开发人员,也没有看过反应代码,所以这个答案是基于阅读文档(字里行间)、经验和实验
也有人问过这个问题useInterval()
,因为它明确指出了实现的意外行为
有什么从根本上反对将回调存储在可变引用中的吗?
我对反应文档的阅读是不推荐这样做,但在某些情况下可能仍然是有用甚至是必要的解决方案,因此是“逃生舱”参考,所以我认为答案是“否”。我认为不建议这样做,因为:
您正在明确拥有管理您正在保存的闭包生命周期的所有权。当它过时时,你自己来修复它。
这很容易以微妙的方式出错,见下文。
这种模式在文档中作为一个示例给出,说明如何在处理程序更改时重复渲染子组件,正如文档所说:
最好避免向下传递回调
例如使用上下文。这样,每次重新渲染父母时,您的孩子就不太可能需要重新渲染。因此,在这个用例中,有一种更好的方法可以做到这一点,但这将依赖于能够更改子组件。
但是,我确实认为这样做可以解决某些其他方式难以解决的问题,并且useInterval()
在您的代码库中测试和现场强化这样的库函数所带来的好处,其他开发人员可以使用,而不是尝试自己使用setInterval
直接(可能使用全局变量......这会更糟)将超过用于useRef()
实现它的负面影响。如果有一个错误,或者一个更新引入了反应,那么只有一个地方可以修复它。
也可能是你的回调在过期时调用是安全的,因为它可能只是捕获了不变的变量。例如,setState
返回的函数useState()
保证不会改变,参见this中的最后一个注释,所以只要你的回调只使用这样的变量,你就坐得很漂亮。
话虽如此,setInterval()
你给出的实现确实有一个缺陷,见下文,以及我建议的替代方案。
在并发模式下是否安全,就像上面的代码一样(如果不是,为什么)?
现在我不完全知道并发模式是如何工作的(它还没有最终确定,AFAIK),但我的猜测是并发模式可能会加剧下面的窗口条件,因为据我了解它可能会将状态更新与渲染分开,增加窗口条件,即只有在useEffect()
触发时(即渲染时)才会更新的回调将在过期时被调用。
示例显示您useInterval
可能会在过期时弹出。
在下面的示例中,我演示了setInterval()
计时器可能会在设置更新回调的调用之间 弹出,这意味着回调在过期时被调用,如上所述,这可能是可以的,但它可能会导致到错误。setState()
useEffect()
在示例中,我修改了您的setInterval()
内容,使其在某些情况下终止,并且我使用了另一个 ref 来保存num
. 我用了两个setInterval()
s:
- 一个简单地记录
num
存储在 ref 和渲染函数局部变量中的值。
- 另一个定期更新
num
,同时更新 in 的值numRef
并调用setNum()
以导致重新渲染并更新局部变量。
现在,如果保证在调用下一个渲染setNum()
的useEffect()
s 时会立即调用,我们希望新的回调会立即安装,因此不可能调用过期的闭包。但是我的浏览器中的输出类似于:
[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()
更新 ref 被调用。
即定时器在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]);
}