likes
comments
collection
share

React:useEffect中的setInterval

作者站长头像
站长
· 阅读数 16

React官网的FAQ有这么一个问题“如果我的 effect 的依赖频繁变化,我该怎么办?

1、BUG描述:你的 effect 可能会使用一些频繁变化的值。你可能会忽略依赖列表中 state,但这通常会引起 Bug

会产生BUG的代码

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 这个 effect 依赖于 `count` state    }, 1000);
    return () => clearInterval(id);
  }, []); // 🔴 Bug: `count` 没有被指定为依赖
  return <h1>{count}</h1>;
}

运行结果: count 永远不会超过 1

count的值为:0111......

2、疑惑:一开始完全不理解count的值为什么最大为1,认为setCount每次都会使count加1且重新渲染新值,但事实与之相反,后来在官网的修复代码中看到了一个关键词“作用域”,猜测不理解的原因基本上是对“作用域”了解不深。

React:useEffect中的setInterval

紧接着马上去啃完了《你不知道的JavaScript》中第一部分“作用域和闭包”的知识,然后画出BUG代码对应的作用域链气泡图后瞬间清晰了

3、分析解决

(1)、分析

BUG代码的作用域链气泡图

React:useEffect中的setInterval

由上图可以看出,Counter组件在挂载时,会产生由三个作用域嵌套而成的作用域链,当定时器setInterval每一次执行时,都是从此作用域链(挂载时形成)寻找count值,因为挂载期间count的初始值始终为0,所以定时器每一次执行开始前count都为0,执行结束后count都为1。

尽管由于定时器的存在,组件始终会一直重新渲染,但定时器的回调函数是挂载期间定义的,所以它的闭包永远是对挂载时Counter作用域的引用,故count 永远不会超过 1。

(2)解决:方案一为最佳

方案一:使用setCount回调形式更新count
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); // ✅ 在这不依赖于外部的 `count` 变量    }, 1000);
    return () => clearInterval(id);
  }, []); // ✅ 我们的 effect 不使用组件作用域中的任何变量
  return <h1>{count}</h1>;
}

setCount 函数的身份是被确保稳定的,所以可以放心的省略掉

此时,setInterval 的回调依旧每秒调用一次,但每次 setCount 内部的回调取到的 count 是最新值(在回调中变量命名为 c)。

方案二:给useEffect添加count依赖
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 这个 effect 依赖于 `count` state    }, 1000);
    return () => clearInterval(id);
  }, [count]); // 🔴 Bug: `count` 没有被指定为依赖
  return <h1>{count}</h1>;
}

作用域链气泡图

React:useEffect中的setInterval

上图可以看出,由于给useEffect添加了第二个依赖参数count,每一次定时器的执行改变了count值后,组件都会重新渲染,且effect都会重新执行,所以新的作用域链会生成(见上图右侧),此后的每一次重新渲染,访问的都是新Counter作用域中的新count值,即:1、2、3...

方案三:使用useReducer把state更新逻辑移到effect 之外
const initialState = { count: 0 }

function reducer(state, action) {
  switch (action.type) {
    case 'add':
      return { count: state.count + 1 } // *
    default:
      throw new Error()
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'add' });
    }, 1000);
    return () => {
      clearInterval(id)
    };
  }, []);

  return <h1>{state.count}</h1>;
}

将整个状态管理转移到reducer(见*行)将消除useEffect回调中对本地状态的任何引用。

我们的useEffect主体更加简单易读,并且我们不再需要担心陈旧状态被困在闭包中:我们所有的更新都是通过对单个 reducer 的调度发生的。使用 reducer 有助于将读取与写入分开。我们的useEffect身体现在只关心调度动作,这会产生新的状态,在它关心读取现有状态和写入新状态之前。

方案四:useRef

万不得已的情况下,如果你想要类似 class 中的 this 的功能,你可以 使用一个 ref 来保存一个可变的变量。然后你就可以对它进行读写了

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。

function Counter() {
  const [count, setCount] = useState(0)
  const countRef = useRef(count)
  useEffect(() => {
    countRef.current += 1; // *
  })

  useEffect(() => {
    const id = setInterval(() => {
      console.log(countRef.current)
      setCount(countRef.current)
    }, 1000)
    return () => clearInterval(id)
  }, [])
  return (
    <div>
      <div>count: {count}</div>
    </div>
  )
}

React:useEffect中的setInterval

每一次重新渲染后,countRef.current就会递增一次(见*行)。利用countRef.current来保存并递增count的值。每过一秒,定时器就会取出countRef.current的值并重新渲染组件。

方案五:局部变量法

该方案由读者smalllong提供,非常感谢,原文在此

function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    let counterMutable = count;
    const id = setInterval(() => {
      counterMutable++
      setCount(counterMutable)
    }, 1000)
    return () => clearInterval(id)
  }, [])
  return (
    <div>
      <div>count: {count}</div>
    </div>
  )
}

作用域链气泡图

React:useEffect中的setInterval 组件挂载时,在effect中保存外部作用域的count值,setInterval回调中的setCount在每一次执行前都会把counterMutable加1,并用counterMutable去更新count,这保证了count的值会一直正常递增。

setCount的作用之一是重新渲染组件,即重新执行函数组件,重新执行函数后的新count值都会在新counter作用域中(见上图右侧),不会改变原来counter作用域中count的值,但定时器却只会使用挂载时的作用域链中的值。