“官方” useInterval 示例中的潜在错误

IT技术 javascript reactjs react-hooks event-loop
2021-05-09 09:34:38

使用间隔

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,从而导致调用旧的(因此不是最新的)回调。换句话说,这可能是执行顺序:

  1. 渲染阶段- 的新值callback
  2. 提交阶段- 提交给 DOM 的状态。
  3. 使用布局效果
  4. 间隔回调- using savedCallback.current(),与callback.
  5. 使用效果-savedCallback.current = callback;

React 的生命周期

为了进一步说明这一点,这里有一张图表,显示了带有钩子的 React 生命周期

功能组件的 React 生命周期

虚线表示异步流(事件循环已释放),您可以在这些点进行间隔回调调用。

但是请注意,RenderReact 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,它将打印错误的值。

3个回答

尽管我理解讨论,但这对我来说似乎不是错误。

上面建议在渲染期间更新 ref 的答案是一个副作用,应该避免,因为它会导致问题

演示显示当前回调值可能与 useEffect 中的不同,但真正的问题是其中哪一个是“正确”的?

我相信“正确”的是已经Promise的那个。出于一个原因,提交的效果是唯一可以保证稍后进行清理阶段的效果。(这个问题中的间隔不需要清理效果,但其他事情可能会。)

在这种情况下,另一个更令人信服的原因可能是 React 可能会预渲染事物(或者以较低的优先级,或者因为它们“离屏”且尚不可见,或者在未来的动画 API 中)。像这样的预渲染工作永远不应该修改 ref,因为修改是任意的。(考虑一个未来的动画 API,它预渲染多个可能的未来视觉状态,以响应用户交互更快地进行转换。您不希望最后渲染的那个只是改变当前可见/提交使用的 ref看法。)


编辑 1这个讨论似乎主要是指出,当 JavaScript 不同步(阻塞)时,当它在渲染之间产生,有可能在两者之间发生其他事情(例如之前安排的计时器/间隔)。这是真的,但我认为如果在渲染期间(在“提交”更新之前)发生这种情况这不是错误

如果主要担心回调可能会在 UI 提交后执行并且与屏幕上的内容不匹配,那么您可能需要考虑useLayoutEffect这种效果类型在提交阶段被调用,在 React 修改了 DOM 之后但在 React 返回给浏览器之前(也就是没有间隔或计时器可以在两者之间运行)。


编辑 2我相信 Dan 最初建议为此使用 ref 和效果(而不仅仅是效果)的原因是因为对回调的更新不会重置间隔。(如果您调用clearInterval并且setInterval每次回调更改,则整体计时将被中断。)

尝试严格回答您的最后一个问题:

我看不到任何逻辑的伤害更新的回调render(),而不是useEffect()useEffect()除了 after 之外永远不会被调用render(),并且无论它被调用什么都将是最后一个渲染被调用的东西,所以逻辑上唯一的区别是回调在调用时可能已经过时了useEffect()

即将到来的并发模式可能会加剧这种情况,如果在调用render()之前可能有多个调用useEffect(),但我什至不确定它是这样工作的。

但是:我会说这样做会产生维护成本:这意味着可以在render(). 一般来说,这不是一个好主意,所有必要的副作用都应该在 中完成useEffect(),因为,正如文档所说

render 方法本身不应该引起副作用……我们通常希望在 React 更新 DOM 之后执行我们的效果

因此,我建议将任何副作用放入 auseEffect()并将其作为编码标准,即使在某些情况下也可以。特别是在一个 react 核心开发人员的博客文章中被复制和粘贴 “引导”很多人树立正确的榜样很重要;-P

替代方案

至于如何才能解决您的问题,我只是复制和粘贴我建议实施的setInterval()这个答案,应该通过调用回调函数在一个单独的消除不确定性useEffect(),此时所有的状态应该是一致的,你没有担心哪个是“正确的”。将它放入您的沙箱似乎可以解决问题。

function useTicker(delay) {
  const [ticker, setTicker] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => setTicker(t => t + 1), delay);
    return () => clearInterval(timer);
  }, [delay]);

  return ticker;
}

function useInterval(cbk, delay) {
  const ticker = useTicker(delay);
  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]);
}

这是您的示例的修改,表明两种/两种方法都是正确的:https : //codesandbox.io/s/useintervalbug-neither-are-correct-zu2zt?file= /src/ App.js

refs 的使用不是您在现实中会做的,但有必要轻松检测和报告问题。它们不会对行为产生实质性影响。

在这个例子中,父组件创建了新的“正确”回调,完成了渲染并希望子组件和计时器使用新的回调。

最终,在“正确”回调最终被传递到useInterval与浏览器决定调用回调之间存在竞争条件我认为不可能避免这种情况。

如果您记住回调没有任何区别,除非它当然没有依赖项并且永远不会改变。