"official" useInterval 示例中的潜在错误

Potential bug in "official" useInterval example

使用间隔

useInterval 来自 this blog post by 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. useLayoutEffect
  4. 间隔回调 - 使用savedCallback.current(),不同于callback.
  5. useEffect - savedCallback.current = callback;

React 的生命周期

为了进一步说明这一点,这里是 a diagram showing React's Life Cycle with hooks:

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

但是请注意, RenderReact updates DOM(提交阶段)之间的虚线很可能是 a mistake. As this codesandbox 演示的,您可以仅在 useLayoutEffectuseEffect 之后调用间隔回调(但不在渲染阶段之后)。

所以你可以在3个地方设置回调:

演示

codesandebox 中演示了此错误。重现:

用例

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

考虑这段代码:

const [size, setSize] = React.useState();

const onInterval = () => {
  console.log(size)
}

useInterval(onInterval, 100);

如果在提交阶段之后但在 useEffect 之前调用 onInterval,它将打印错误的值。

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

useEffect() 相比,我看不出在 render() 中更新回调有任何逻辑上的危害。除了在 render() 之后,useEffect() 永远不会被调用,无论它被调用什么都将是最后一个渲染被调用的东西,所以逻辑上唯一的区别是回调可能更不合时宜调用 useEffect() 时的日期。

即将到来的 concurrent mode 可能会加剧这种情况,如果在调用 useEffect() 之前可能会多次调用 render(),但我不会甚至确定它是那样工作的。

但是:我会说这样做会产生维护成本:这意味着在[=11]中引起副作用是可以的=]。一般来说,这不是一个好主意,所有必要的副作用都应该在 useEffect() 中完成,因为 the docs say:

the render method itself shouldn’t cause side effects ... we typically want to perform our effects after React has updated the DOM

所以我建议将 any 副作用放在 useEffect() 中并将其作为编码标准,即使在某些情况下也可以。特别是在博客 post 中,由一个反应核心开发人员 复制和粘贴 “指导”许多人,树立正确的榜样很重要;-P

备选方案

至于如何解决您的问题,我将复制并粘贴 this answer 中我建议的 setInterval() 实现,这应该通过在单独的 [=] 中调用回调来消除歧义12=],此时所有状态应该是一致的,你不必担心哪个是“正确的”。将它放入您的沙箱似乎可以解决问题。

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]);
}

虽然我理解讨论,但在我看来这不像是错误。

上面的答案建议在渲染期间更新 ref 会产生副作用,should be avoided because it will cause problems

The demo shows that the current callback value may differ from that in useEffect alright, but the real question is which one of them is the "correct" one?

我认为“正确”的是已提交的那个。出于一个原因,提交的效果是唯一保证稍后有清理阶段的效果。 (本题中的间隔不需要清理效果,但其他东西可能。)

在这种情况下,另一个更有说服力的原因可能是 React 可能 pre-render 事物(要么处于较低的优先级,要么因为它们处于“屏幕外”且尚未可见,或者在未来的动画 APIs 中)。像这样的预渲染工作不应该修改 ref,因为修改是任意的。 (考虑一个未来的动画 API,它预渲染了多种可能的未来视觉状态,以便更快地响应用户交互。您不希望碰巧最后渲染的动画只改变您使用的 ref目前 visible/committed 观看。)


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

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


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

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

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

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

最终在“正确”回调最终传递给 useInterval 和浏览器决定调用回调之间存在竞争条件。我认为无法避免这种情况。

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