在 useRef() 中存储回调

IT技术 javascript reactjs react-hooks react-ref react-concurrent
2021-04-29 13:41:06

下面是一个可变 ref 的示例,该示例存储来自Overreacted 博客的当前回调

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 指出不推荐使用该模式

另请注意,此模式可能会导致并发模式出现问题[...]

在任何一种情况下,我们都不推荐这种模式,只是为了完整起见在此处显示它。

我发现这种模式对于回调非常有用,但我不明白为什么它会在 FAQ 中出现危险信号。例如,客户端组件可以使用useInterval而无需useCallback环绕回调(更简单的 API)。

在并发模式下也不应该有问题,因为我们更新了useEffect. 从我的角度来看,FAQ 条目在这里可能有一个错误的观点(或者我误解了它)。

所以,总结一下:

  1. 从根本上反对在可变引用中存储回调吗?
  2. 像上面的代码那样在并发模式下是否安全,如果不是,为什么不呢?
1个回答

次要免责声明:我不是核心react开发人员,我没有看过react代码,所以这个答案是基于阅读文档(字里行间)、经验和实验

有人问过这个问题,因为它明确指出了useInterval()实现的意外行为

从根本上反对在可变引用中存储回调吗?

我对 react 文档的阅读是不推荐这样做,但在某些情况下可能仍然是有用的甚至是必要的解决方案,因此“逃生舱口”参考,所以我认为答案是否定的。我认为不推荐,因为:

  • 您明确拥有管理您正在保存的闭包的生命周期的所有权。当它过时时,你需要自己修复它。

  • 这很容易以微妙的方式出错,见下文。

  • 文档中给出了此模式作为如何解决在处理程序更改时重复呈现子组件的示例,正​​如文档所说

    最好避免在深处传递回调

    通过例如使用上下文。这样,您的孩子每次重新渲染时都不太可能需要重新渲染。所以在这个用例中有一个更好的方法来做到这一点,但这将依赖于能够改变子组件。

但是,我确实认为这样做可以解决某些其他方式难以解决的问题,并且useInterval()在您的代码库中测试和现场强化了这样的库函数的好处,其他开发人员可以使用,而不是尝试自己使用setInterval直接(可能使用全局变量......这会更糟)将超过曾经useRef()实现它的负面影响如果有一个错误,或者一个由更新引入的react,只有一个地方可以修复它。

也可能是您的回调在过时时可以安全调用,因为它可能只是捕获了不变的变量。例如,由setState返回函数useState()保证不会改变,请参阅this 中的最后一个注释,因此只要您的回调仅使用这样的变量,您就很漂亮。

话虽如此,setInterval()您提供的实施确实存在缺陷,请参见下文,以及我建议的替代方案。

像上面的代码一样在并发模式下安全吗(如果不是,为什么)?

现在我不完全知道并发模式是如何工作的(而且它还没有最终确定 AFAIK),但我的猜测是并发模式很可能会加剧下面的窗口条件,因为据我所知它可能会将状态更新与渲染分开,增加窗口条件,即仅在useEffect()触发时(即渲染时)更新的回调将在过期时调用。

显示您useInterval可能会在过时时弹出的示例

在下面的示例中,我演示了setInterval()计时器可能会 设置更新回调的setState()和 的调用之间弹出useEffect(),这意味着回调在过期时被调用,如上所述,这可能没问题,但可能导致到错误。

在示例中,我修改了您的内容setInterval(),使其在出现某些情况后终止,并且我使用了另一个 ref 来保存num. 我使用两个setInterval()

  • 一个简单地记录num存储在 ref 和渲染函数局部变量中的值。
  • 另一个定期更新num,同时更新中的值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()

添加更多跟踪后,差异日志的顺序显示为:

  1. setNum() 叫做,
  2. render() 发生
  3. “间隔弹出”日志
  4. 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]);
}