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
的更新过程其实就很好解决这个问题了