 useEffect闭包陷阱
useEffect闭包陷阱
  # 前言
经常使用
React的小伙伴都知道,React的useEffect存在一个闭包陷阱。一不小心就会造成一些很离奇的bug,本文将从原理分析问题的产生以及相对应的解决方案。
# 问题的产生
下面先来看一段小例子
const App = ()=>{
    const [count,setCount] = useState(0)
    
    const handleClick = ()=>{
        setCount(1)
    }
    
    useEffect(()=>{
        setInterval(()=>{
            console.log(count)
        },1000)
    },[])
    
    return (
        <>
            <div onClick={()=>handleClick()}></div>
        </>
    )
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
上面这段代码中 我们在useEffect里调用了一个定时器,在定时器中打印了一下count的值。然后在给元素绑定一个点击事件修改count的值为1.
理想状态下,当我们点击元素的时候会修改count的值从而触发重新渲染,但是useEffect第二个参数传的是数组所以他并不会重新执行,所以会继续打印0。这就是useEffect的闭包陷阱。
问题产生的具体原因是什么呢?
首先我们先来回顾一下闭包
const myComponent = ()=>{
    let count = 0
    return ()=>{
        console.log(count)
        count++
    }
}
const fn1 = myComponent()
const fn2 = myComponent()
fn1() // 打印0
fn2() // 打印0
fn2() // 打印1
fn2() // 打印2
fn1() // 打印1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
先来解释一下为什么会这样输出:
首先 myComponent 是一个典型的闭包函数,函数体内创建了一个变量count,并且返回了一个新函数,新函数内对count做自增操作。这就是典型的引用父级作用域的变量,形成了闭包
先执行了一次fn1,这个时候打印出0,然后执行count++。然后我们执行fn2的时候,同样打印的是0因为fn2和fn1是两个完全独立的执行上下文,他们的作用域链并不互通。这也就导致了fn1和fn2两个函数里的count其实并不是同一个count。
把这个例子带入上文中的useEffect中可以理解成,第一次执行useEffect的时候闭包引用了count,而useState的值是动态计算出来的,每一次都会重新创建一个值。所以当我们调用setCount
触发了组件重新渲染后useState计算出了一个新的count,也就是值为1的count并将它返回出来了。但是由于我们的useEffect并没有重新执行,里面还是引用的之前的count所以打印会一直都是0。
根本而言就是出现了两个count,useEffect里的count和组件重新渲染之后的count并不是同一个。
# 解决办法
了解了问题产生的原因,那找解决办法就比较简单了。
既然useState返回了一个全新的状态出来,那我们其实只需要让useEffect去重新执行一遍拿到新的状态就可以了。说成大白话就是让useEffect也在组件重新渲染的时候重新执行。
const App = ()=>{
    const [count,setCount] = useState(0)
    
    const handleClick = ()=>{
        setCount(1)
    }
    // 方案一,第二个参数不传,useEffect就会每次都执行。同时不要忘了清楚副作用,否则又会导致定时器的内存泄漏了
    useEffect(()=>{
      const timer = setInterval(()=>{
            console.log(count)
        },1000)
      return ()=> clearInterval(timer)
    })
    // 方案二,将count作为依赖项传入,这样每当count发生变化useEffect都会重新执行,但这种情况需要考虑引用类型的问题,因为useEffect去比较依赖项用的是浅比较。
    useEffect(()=>{
        const timer = setInterval(()=>{
            console.log(count)
        },1000)
        return ()=> clearInterval(timer)
    },[count])
    
    return (
        <>
            <div onClick={()=>handleClick()}></div>
        </>
    )
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
那如果不想useEffect重复执行,又想规避这个闭包陷阱该怎么办呢?
很简单,回到问题的根本。问题产生的原因就是count被创建了两次,闭包引用的是之前的。那如果我们能维持对同一个变量的引用是不是就不会出现这个问题了
这里就需要用到另一个hooks了 那就是useRef
在useRef初始化创建时其会被react内部方法一路传递引用,最终保存在组件函数的内部作用域之上的上层作用域中(fiber 节点的hook 对象上memoizedState)。
并且每一次function component执行的时候,useRef都会返回同一个内存指向地址的对象,也就是 oldRef === newRef
调整过后的代码如下
const App = ()=>{
    const [count,setCount] = useState(0)
    const currentRef = useRef()
    
    const handleClick = ()=>{
        setCount(1)
    }
    
    useEffect(()=>{
        currentRef.current = count
    },[count])
    
    useEffect(()=>{
        setInterval(()=>{
            console.log(currentRef.current)
        },1000)
    },[])
    
    return (
        <>
            <div onClick={()=>handleClick()}></div>
        </>
    )
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这段代码主要是利用了useRef每次都会返回初始化创建的那个对象的引用,所以这样闭包引用的就是同一个对象。
代码的执行步骤如下
初始化的时候
- count值为0
- 将count赋值给useRef创建的对象的current属性,此时current为0
- 执行定时器,每秒打印currentRef.current,也就是每秒打印0
点击元素之后
- count值变为- 1
- 将count赋值给currentRef.current
- 执行定时器打印currentRef.current,由于currentRef并不是重新创建的而是初始化创建的对象的引用,所以定时器打印的也是1
# 结语
问题的产生主要就是因为闭包引用了之前的变量,而useState又每次都返回的新的变量出来。只要搞明白了闭包的特性,以及react的更新过程其实就很好解决这个问题了