“官方”useInterval示例中的潜在错误
使用间隔
useInterval来自Dan Abramov 的这篇博文(2019 年):
function useInterval(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
一个潜在的错误
可以在提交阶段和调用之间调用间隔回调useEffect,从而导致调用旧的(因此不是最新的)回调。换句话说,这可能是执行顺序:
- 渲染阶段- 的新值
callback。 - 提交阶段- 提交给 DOM 的状态。
- 使用布局效果
- 间隔回调- using
savedCallback.current(),与callback. - 使用效果-
savedCallback.current = callback;
React 的生命周期
为了进一步说明这一点,这里有一张图表,显示了带有钩子的 React 生命周期:
虚线表示异步流(事件循环已释放),您可以在这些点进行间隔回调调用。
该图显示了并发模式:Render和React updates DOM(提交阶段)之间的虚线是异步的。正如此代码和框所示,您只能在useLayoutEffector之后useEffect(但不能在渲染阶段之后)调用间隔回调。
所以你可以在 3 个地方设置回调:
- 渲染 - 不正确,因为状态更改尚未提交到 DOM。
useLayoutEffect- 正确,因为状态更改已提交到 DOM。useEffect- 不正确,因为旧的间隔回调可能会在此之前触发(在布局效果之后)。
演示
这个错误在这个codeandebox 中得到了证明。重现:
- 将鼠标移到灰色 div 上 - 这将导致带有新
callback参考的新渲染。 - 通常,您会看到在少于 2000 次鼠标移动时抛出的错误。
- 间隔设置为 50 毫秒,因此您需要一点运气才能在渲染和效果阶段之间触发。
用例
演示显示当前的回调值可能与useEffect正常的不同,但真正的问题是其中哪一个是“正确的”?
考虑这个代码:
const [size, setSize] = React.useState();
const onInterval = () => {
console.log(size)
}
useInterval(onInterval, 100);
如果onInterval在提交阶段之后但之前调用useEffect,它将打印错误的值。
回答
尽管我理解讨论,但这对我来说看起来不像是一个错误。
上面建议在渲染期间更新 ref 的答案是一个副作用,应该避免,因为它会导致问题。
演示显示当前回调值可能与 useEffect 中的不同,但真正的问题是其中哪一个是“正确”的?
我相信“正确”的就是已经承诺的那个。出于一个原因,提交的效果是唯一可以保证稍后有清理阶段的效果。(这个问题中的间隔不需要清理效果,但其他事情可能会。)
在这种情况下,另一个更令人信服的原因可能是 React 可能会预渲染事物(或者以较低的优先级,或者因为它们“离屏”并且尚不可见,或者在未来的动画 API 中)。像这样的预渲染工作永远不应该修改 ref,因为修改是任意的。(考虑一个未来的动画 API,它预渲染多个可能的未来视觉状态,以响应用户交互更快地进行转换。您不希望最后渲染的那个只是改变当前可见/提交使用的 ref看法。)
编辑 1这个讨论似乎主要是指出,当 JavaScript 不同步(阻塞)时,当它在渲染之间产生时,有可能在两者之间发生其他事情(例如之前安排的计时器/间隔)。这是真的,但我认为如果在渲染期间(在“提交”更新之前)发生这种情况,这不是错误。
如果主要担心回调可能会在 UI 提交后执行并且与屏幕上的内容不匹配,那么您可能需要考虑useLayoutEffect。这种效果类型在提交阶段被调用,在 React 修改了 DOM 之后但在 React 返回给浏览器之前(也就是没有间隔或计时器可以在两者之间运行)。
编辑 2我相信 Dan 最初建议为此使用 ref 和效果(而不仅仅是效果)的原因是因为对回调的更新不会重置间隔。(如果您调用clearInterval并且setInterval每次回调更改,则整体计时将被中断。)