16

下面是一个可变 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 声明不推荐使用该模式:

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

无论哪种情况,我们都不推荐这种模式,只是为了完整性而在此处显示。

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

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

所以,总结一下:

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

1 回答 1

8

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

也有人问过这个问题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()

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

  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]);
}
于 2021-06-23T17:42:59.557 回答