setTimeout在使用this.state和useState时的区别

9

当我使用类组件时,我的代码是:

setTimeout(() => console.log(this.state.count), 5000);

当我使用 Hook 时:

const [count, setCount] = useState(0);
setTimeout(() => console.log(count), 5000);

如果我在触发 setTimeout之后,在超时时间(5000ms)之前将count更改为1,那么对于class组件,它将console.log(1)(最新值),而对于useState则是console.log(0)(注册timeout时的值)。 为什么会这样呢?
4个回答

14

更新版本:

问题:函数组件类组件中,在 setTimeout / setInterval 内使用 React State 变量的行为有何区别?

情况1: 函数组件中的 State 变量(陈旧闭包):

const [value, setValue] = useState(0)

useEffect(() => {
  const id = setInterval(() => {
    // It will always print 0 even after we have changed the state (value)
    // Reason: setInterval will create a closure with initial value i.e. 0
    console.log(value)
  }, 1000)
  return () => {
    clearInterval(id)
  }
}, [])

案例2:类组件中的状态变量(没有过时闭包):

constructor(props) {
  super(props)
  this.state = {
    value: 0,
  }
}

componentDidMount() {
  this.id = setInterval(() => {
    // It will always print current value from state
    // Reason: setInterval will not create closure around "this"
    // as "this" is a special object (refernce to instance)
    console.log(this.state.value)
  }, 1000)
}

案例3:让我们尝试创建一个固化的闭包来包含this

// Attempt 1

componentDidMount() {
  const that = this // create a local variable so that setInterval can create closure
  this.id = setInterval(() => {
    console.log(that.state.value)
    // This, too, always print current value from state
    // Reason: setInterval could not create closure around "that"
    // Conclusion: Oh! that is just a reference to this (attempt failed)
  }, 1000)
}
案例 4: 让我们再次尝试在类组件中创建一个陈旧的闭包。
// Attempt 2

componentDidMount() {
  const that = { ...this } // create a local variable so that setInterval can create closure
  this.id = setInterval(() => {
    console.log(that.state.value)
    // Great! This always prints 0 i.e. the initial value from state
    // Reason: setInterval could create closure around "that"
    // Conclusion: It did it because that no longer is a reference to this,
    // it is just a new local variable which setInterval can close around
    // (attempt successful)
  }, 1000)
}

案例5:让我们再次尝试在类组件中创建一个过期的闭包。

// Attempt 3

componentDidMount() {
  const { value } = this.state // create a local variable so that setInterval can create closure
  this.id = setInterval(() => {
    console.log(value)
    // Great! This always prints 0 i.e. the initial value from state
    // Reason: setInterval created closure around value
    // Conclusion: It is easy! value is just a local variable so it will be closed
    // (attempt successful)
  }, 1000)
}

案例 6: 类已经获胜(没有额外的努力来避免陈旧的闭包)。但是,如何在函数组件中避免呢

// Let's find solution

const value = useRef(0)

useEffect(() => {
  const id = setInterval(() => {
    // It will always print the latest ref value
    // Reason: We used ref which gives us something like an instance field.
    // Conclusion: So, using ref is a solution
    console.log(value.current)
  }, 1000)
  return () => {
    clearInterval(id)
  }
}, [])

案例6: 让我们为函数组件寻找另一个解决方案

source-1, source-2

useEffect(() => {
  const id = setInterval(() => {
    // It will always print the latest state value
    // Reason: We used updater form of setState (which provides us latest state value)
    // Conclusion: So, using updater form of setState is a solution
    setValue((prevValue) => {
      console.log(prevValue)
      return prevValue
    })
  }, 1000)
  return () => {
    clearInterval(id)
  }
}, [])

问题是由于闭包引起的,可以通过使用ref来解决。但这里有一个解决方法,即使用setState的"更新器"形式访问最新的state值:

function App() {

  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    setTimeout(() => console.log('count after 5 secs: ', count, 'Wrong'), 5000)
  }, [])

  React.useEffect(() => {
    setTimeout(() => {
      let count
      setCount(p => { 
        console.log('p: ', p)
        count = p
        return p
       })
      console.log('count after 5 secs: ', count, 'Correct')
    }, 5000);
  }, [])

  return (<div>
    <button onClick={() => setCount(p => p+1)}>Click me before 5 secs</button>
    <div>Latest count: {count}</div>
  </div>)
}

ReactDOM.render(<App />, document.getElementById('mydiv'))
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<body>
<div id="mydiv"></div>
</body>


这是一个有趣的解决方案。缺点是使用setter获取最新值也会导致重新渲染,因此整个setTimeout副作用会导致不必要的渲染。在某些情况下,例如useEffect依赖于更改后的计数,这可能导致无限循环。 - deckele
@deckele setState(prevValue => prevValue) 不会导致重新渲染,因为返回的是相同的值。React 会执行:应该渲染:Object.is(oldValue, newValue) => false。因此,如果我们执行 setCount(p => { // do_something; return p}),就不会重新渲染。 - Ajeet Shah
文档中说,如果setState的结果与先前的值相同,则不会进行其他渲染。然而,这并不完全正确,整个函数会重新渲染第二次,然后才退出渲染其子元素:https://github.com/facebook/react/issues/14994 尽管当前值与先前的值相同,但在渲染之外使用此方法将导致无限循环。但是,在useEffect内部使用setState是安全的,您的示例是正确的。 - deckele
@deckele 好的,也许吧。当我说“如果我们使用setCount(p => { // do_something; return p}),则不会重新渲染”时,我没有阅读文档。我在本地测试过,然后才想写下来。我不知道真相是什么。无论如何,我提供的示例仅用于理解目的(它们很少是真实世界的案例)。除此之外,可能我们使用setTimeout不必担心无限重新渲染。 - Ajeet Shah
@deckele,我刚看到你的编辑:“但在渲染之外使用此方法会导致无限循环。即使当前值与上一个值相同”:我测试了一下,是的,你是正确的。我以前从未写过这样的代码,所以不知道,但你是正确的 :) 谢谢! - Ajeet Shah
对于可能和我一样在第一次看到这个React特定的_updater_引用时感到困惑的人,当你将一个函数传递给useState的"setter"函数时,它有特殊处理 - sherrellbc

11

对于useState,它在第一次使用count创建一个超时。它通过闭包访问count的值。当我们通过setCount设置新值时,组件会重新渲染,但不会更改传递给超时的值。
我们可以使用const count = useRef(0)并将其传递给超时的count.current。这将始终使用最新的count值。
请参阅此链接获取更多信息。


3

超时不与React的声明式编程模型相协调。在函数组件中,每次渲染都是时间轴上的单个帧。它们永远不会改变。当状态更新时,所有状态变量都会被局部创建并且不会覆盖旧的闭合变量。

你也可以将效果视为相同的方式,在每次渲染中,效果将在其本地领域中运行,具有所有本地状态变量,并且新的渲染不会影响它们的输出。

唯一打破这种模式的方法是使用引用(refs)或类组件,其中状态实际上类似于引用(refs),其中实例(this)是引用容器。引用(refs)允许跨渲染通信和闭包破坏。请谨慎使用。

Dan Abramov有一篇精彩的文章解释了这一切,并提供了一个解决方案钩子。正如你正确回答的那样,问题是由于过时的闭包而引起的。解决方案确实涉及使用引用(refs)。


2

解释

使用函数组件时,每次渲染都是一个函数调用,为该特定调用创建一个新的函数闭包。函数组件正在关闭setTimeout回调函数,以便setTimeout回调中的所有内容仅访问它被调用的特定渲染。

可重用解决方案:

使用Ref并仅在setTimeout回调中访问它将为您提供跨渲染持久的值。然而,对于像计数器这样始终更新的值,使用React Ref并不那么方便。您需要同时更新值并自己触发重新渲染。更新Ref并不涉及组件渲染。

我的解决方案是将useState和useRef hooks结合成一个单独的"useStateAndRef" hook,以便轻松使用。这样,您就可以获得一个setter,它既获取值,又获取用于异步情况(如setTimeout和setInterval)的ref:

import { useState, useRef } from "react";

function useStateAndRef(initial) {
  const [value, setValue] = useState(initial);
  const valueRef = useRef(value);
  valueRef.current = value;
  return [value, setValue, valueRef];
}

export default function App() {
  const [count, setCount, countRef] = useStateAndRef(0);
  function logCountAsync() {
    setTimeout(() => {
      const currentCount = countRef.current;
      console.log(`count: ${count}, currentCount: ${currentCount}`);
    }, 2000);
  }
  return (
    <div className="App">
      <h1>useState with updated value</h1>
      <h2>count: {count}</h2>
      <button onClick={() => setCount(prev => prev + 1)}>+</button>
      <button onClick={logCountAsync}>log count async</button>
    </div>
  );
}

工作的CodeSandbox链接:https://codesandbox.io/s/set-timeout-with-hooks-fdngm?file=/src/App.tsx

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接