在 useRef() 中存储回调 [英] Store a callback in useRef()

查看:30
本文介绍了在 useRef() 中存储回调的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

这是一个可变引用的例子,它存储了来自 Overreacted 的当前回调博客:

function useInterval(callback, delay) {const savedCallback = useRef();//在第二个效果之前更新 refuseEffect(() => {savedCallback.current = 回调;//将回调保存在可变引用中});useEffect(() => {功能滴答(){//总是可以访问最近的回调值,无需回调 depsaveCallback.current();}让 id = setInterval(tick, delay);返回 () =>clearInterval(id);}, [延迟]);}

然而,React Hook FAQ 指出该模式是 不推荐:

<块引用>

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

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

我发现这种模式对于回调非常有用,但我不明白为什么它会在常见问题解答中出现危险信号.例如,客户端组件可以使用 useInterval 而无需将 useCallback 包裹在回调(更简单的 API)周围.

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

所以,总结一下:

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

解决方案

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

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

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

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

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

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

  • 文档中给出了这个模式作为如何解决在处理程序更改时重复渲染子组件的示例,以及 文档说:

    <块引用>

    最好避免在深处传递回调

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

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

也可能是您的回调在过时时调用是安全的,因为它可能只是捕获了不变的变量.比如useState()返回的setState函数保证不会改变,见这个,所以只要你的回调只使用这样的变量,你就很漂亮.

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

在并发模式下是否安全,如上所示代码(如果没有,为什么)?

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

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

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

在示例中,我修改了您的 setInterval() 以便它在出现某些情况后终止,并且我使用另一个引用来保存真实"num 的值.我使用两个 setInterval()s:

  • 一个简单的记录 num 的值,存储在 ref 和渲染函数局部变量中.
  • 另一个周期性地更新num,同时更新numRef中的值并调用setNum()引起重新渲染并更新局部变量.

现在,如果保证在调用 setNum() 时会立即调用下一次渲染的 useEffect()s,我们希望新的回调能够立即安装,因此不可能调用过时的关闭.然而,我的浏览器中的输出类似于:

[Log] interval pop 0 0 (main.chunk.js, line 62)[Log] 间隔 pop 0 1 (main.chunk.js, line 62, x2)[Log] 间隔 pop 1 1 (main.chunk.js, line 62, x3)[Log] 间隔 pop 2 2 (main.chunk.js, line 62, x2)[Log] 间隔 pop 3 3 (main.chunk.js, line 62, x2)[Log] 间隔 pop 3 4 (main.chunk.js, line 62)[Log] 间隔 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';函数 useInterval(回调,延迟,maxOccurrences){const 出现次数Ref = useRef(0);const savedCallback = useRef();//在第二个效果之前更新 refuseEffect(() => {savedCallback.current = 回调;//将回调保存在可变引用中});useEffect(() => {功能滴答(){//总是可以访问最近的回调值,无需回调 depsaveCallback.current();出现次数Ref.current += 1;如果 (occurrencesRef.current >= maxOccurrences) {console.log(`最大出现次数 (延迟 ${delay})`);clearInterval(id);}}让 id = setInterval(tick, delay);返回 () =>clearInterval(id);}, [延迟]);}功能应用(){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;返回 refNum.current;}), 10, 20);返回 (

<header className="App-header"><h1>数字:</h1></标题>

);}导出默认应用程序;

没有同样问题的替代useInterval().

react 的关键在于知道何时调用处理程序/闭包.如果您天真地将 setInterval() 与任意函数一起使用,那么您可能会遇到麻烦.但是,如果您确保您的处理程序仅在 useEffect() 处理程序被调用时被调用,您就会知道它们是在所有状态更新完成后被调用的,并且您处于一致状态.所以这个实现不会像上面那样受到影响,因为它确保在 useEffect() 中调用不安全处理程序,并且只从 setInterval() 调用安全处理程序代码>:

import { useEffect, useRef, useState } from 'react';函数 useTicker(delay, maxOccurrences) {const [ticker, setTicker] = useState(0);useEffect(() => {const timer = setInterval(() => setTicker((t) => {如果 (t + 1 >= maxOccurrences) {清除间隔(定时器);}返回 t + 1;}), 延迟);返回 () =>清除间隔(定时器);}, [延迟]);返回代码;}函数 useInterval(cbk, delay, maxOccurrences) {const ticker = useTicker(delay, maxOccurrences);const cbkRef = useRef();//总是希望来自调用者的最新回调useEffect(() => {cbkRef.current = cbk;}, [cbk]);//每当计时器弹出/代码增加时调用回调.//这故意不在依赖项中传递 `cbk` 作为//否则将在每次渲染时调用处理程序以及//在定时器弹出useEffect(() => cbkRef.current(), [ticker]);}

Here is an example of a mutable ref storing the current callback from the Overreacted blog:

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

However the React Hook FAQ states that the pattern is not recommended:

Also note that this pattern might cause problems in the concurrent mode. [...]

In either case, we don’t recommend this pattern and only show it here for completeness.

I found this pattern to be very useful in particular for callbacks and don't understand why it gets a red flag in the FAQ. For example, a client component can use useInterval without needing to wrap useCallback around the callback (simpler API).

Also there shouldn't be a problem in concurrent mode, as we update the ref inside useEffect. From my point of view, the FAQ entry might have a wrong point here (or I have misunderstood it).

So, in summary:

  1. Does anything fundamentally speak against storing callbacks inside mutable refs?
  2. Is it safe in concurrent mode when done like it is in the above code, and if not, why not?

解决方案

Minor disclaimer: I'm not a core react dev and I haven't looked at the react code, so this answer is based on reading the docs (between the lines), experience, and experiment

Also this question has been asked since which explicitly notes the unexpected behaviour of the useInterval() implementation

Does anything fundamentally speak against storing callbacks inside mutable refs?

My reading of the react docs is that this is not recommended but may still be a useful or even necessary solution in some cases hence the "escape hatch" reference, so I think the answer is "no" to this. I think it is not recommended because:

  • you are taking explicit ownership of managing the lifetime of the closure you are saving. You are on your own when it comes to fixing it when it gets out of date.

  • this is easy to get wrong in subtle ways, see below.

  • this pattern is given in the docs as an example of how to work around repeatedly rendering a child component when the handler changes, and as the docs say:

    it is preferable to avoid passing callbacks deep down

    by e.g. using a context. This way your children are less likely to need re-rendering every time your parent is re-rendered. So in this use-case there is a better way to do it, but that will rely on being able to change the child component.

However, I do think doing this can solve certain problems that are difficult to solve otherwise, and the benefits from having a library function like useInterval() that is tested and field-hardened in your codebase that other devs can use instead of trying to roll their own using setInterval directly (potentially using global variables... which would be even worse) will outweigh the negatives of having used useRef() to implement it. And if there is a bug, or one is introduced by an update to react, there is just one place to fix it.

Also it might be that your callback is safe to call when out of date anyway, because it may just have captured unchanging variables. For example, the setState function returned by useState() is guaranteed not to change, see the last note in this, so as long as your callback is only using variables like that, you are sitting pretty.

Having said that, the implementation of setInterval() that you give does have a flaw, see below, and for my suggested alternative.

Is it safe in concurrent mode, when done like in above code (if not, why)?

Now I don't exactly know how concurrent mode works (and it's not finalized yet AFAIK), but my guess would be that the window condition below may well be exacerbated by concurrent mode, because as I understand it it may separate state updates from renders, increasing the window condition that a callback that is only updated when a useEffect() fires (i.e. on render) will be called when it is out of date.

Example showing that your useInterval may pop when out of date.

In the below example I demonstrate that the setInterval() timer may pop between setState() and the invocation of the useEffect() which sets the updated callback, meaning that the callback is invoked when it is out of date, which, as per above, may be OK, but it may lead to bugs.

In the example I've modified your setInterval() so that it terminates after some occurrences, and I've used another ref to hold the "real" value of num. I use two setInterval()s:

  • one simply logs the value of num as stored in the ref and in the render function local variable.
  • the other periodically updates num, at the same time updating the value in numRef and calling setNum() to cause a re-render and update the local variable.

Now, if it were guaranteed that on calling setNum() the useEffect()s for the next render would be immediately called, we would expect the new callback to be installed instantly and so it wouldn't be possible to call the out of date closure. However the output in my browser is something like:

[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)

And each time the numbers are different illustrates the callback has been called after the setNum() has been called, but before the new callback has been configured by the first useEffect().

With more trace added the order for the discrepancy logs was revealed to be:

  1. setNum() is called,
  2. render() occurs
  3. "interval pop" log
  4. useEffect() updating ref is called.

I.e. the timer pops unexpectedly between the render() and the useEffect() which updates the timer callback function.

Obviously this is a contrived example, and in real life your component might be much simpler and not actually be able to hit this window, but it's at least good to be aware of it!

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;

Alternative useInterval() that does not have the same problem.

The key thing with react is always to know when your handlers / closures are being called. If you use setInterval() naively with arbitrary functions then you are probably going to have trouble. However, if you ensure your handlers are only called when the useEffect() handlers are called, you will know that they are being called after all state updates have been made and you are in a consistent state. So this implementation does not suffer in the same way as the above one, because it ensures the unsafe handler is called in useEffect(), and only calls a safe handler from 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]);
}

这篇关于在 useRef() 中存储回调的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆