Hooks闭包陷阱
闭包陷阱
以下两段代码,来自 https://github.com/hacker0limbo/my-blog/issues/6
1 | function createIncrement(i) { |
1 | function createIncrementFixed(i) { |
截取文章中的一段话,如下:
对于这句话,我再做下解释:
在 value 变量修改值之后,message 变量保存的值仍旧是原始的值。这是因为基础数据类型的值是不可变的。
基础数据类型的值是不可变的
让我们举例说明,看下这段代码,思考 a 的值为多少?
1 | let a = 1; |
b++
,b 的值被改变了,但是 a 没被改变,依然为 1。这意味着 a b 的等价,并不表示他们是同一个值。也就是说,a 赋予 b 的时候,重新给 b 分配了一块内存空间。因此我们说,基础数据类型,是按值访问的。 用图表示
基础数据类型的比较,是值在比较
1 | const a = 1; |
当 a 和 b 在比较的时候,本质上是值在比较。所以我们说基础数据类型的值是不可变的。
出现闭包陷阱的原因
为第一段代码第 6 行打上断点
- 开始执行
createIncrement
函数,先定义基础数据类型 value并赋值为 0,然后返回increment
函数并赋值给inc
。 - inc 执行,进入
increment
函数,执行到第 8 行,value+=i
,变量value
的值变成了 1,同时产生了闭包对象Closure(createIncrement)
,该闭包对象存在increment
作用域当中。
- 接着执行到第 10 行,声明了
message
变量,并将基础数据类型 value的值赋给message
,此时value
为 1,所以message = "Current value is 1"
,然后返回logValue
函数,increment
函数执行完毕,并赋值给log
变量。因为闭包对象Closure(createIncrement)
的产生,log
变量将会引用着increment
函数第一次执行时创建的作用域。如图所示,作用域里的 Local 对象「活动对象」中的message = "Current value is 1"
。
- 解释第 25 行,执行
logValue
函数,寻找message
变量。 先查找 Local 活动对象,没找到,发现作用域链中的闭包对象Closure(increment)
中存在message
,且值为Current value is 1
,打印message
,logValue
函数执行完毕。
1 | Local 活动对象:仅仅只有处于栈顶的执行上下文,才会生成 Local 对象。 |
虽然Closure(createIncrement)
中的value
已经变成了 3,但是并不影响基础数据类型 message 变量中的 value 值。
那是不是只要将引用类型赋值给 message 变量,就可以解决闭包陷阱问题?
解决办法
让我们稍微改下代码,将value
改为引用类型,再赋值给 message
1 | // 闭包陷阱 |
第一次执行increment
函数时,value.a += 1
,所以value = { a: 1 }
,message = { a: 1 }
,increment
函数执行完毕,并赋值给log
变量。
后面执行logValue
时,虽然createIncrement
函数第一次执行时创建的作用域中的message = { a: 1 }
,但是message
的引用指向着value
,所以当value = { a: 3 }
时,message
也等于{ a: 3 }
。所以将引用类型赋值给message
确实能解决闭包陷阱问题。
小结:
log
变量引用着increment
函数第一次执行时创建的作用域,此时message = "Current value is 1"
。- 执行三次
increment
函数后,value
变成了 3,然后执行logValue
函数而message
中的value
依旧为 1 的原因是value
变量是个基础数据类型。 - 想要拿到
value
变量的最新值,我们只需要把value
变量从基础数据类型改为引用数据类,并让message
指向value
变量即可。
思考以下三种不同赋值方式message
的值:
1 | function createIncrement(i) { |
Hooks 中的闭包陷阱
1 | useEffect(fn, [deps]); |
在 react hook 中有个经典的闭包陷阱
1 | import { useEffect, useState } from 'react'; |
在这个例子中,我们给useEffect
第二个参数deps
传了个空数组,并使用setInterval
希望隔秒count+1
,然而实际效果是首次渲染时页面中count
为0
,然后过了 1 秒页面中的count
更新为1
,之后一直保持为1
不变,这就是useEffect
的引起的闭包问题。
因为当依赖项为空数组时,fn
(useEffect 的第一个参数)只会执行一次,而 useEffect 永远引用着 HookClosureTrap 第一次执行时创建的作用域。第一次创建时 count 为 0。
解决办法
添加依赖项
添加 count 依赖项,依赖项改变时 fn 重新执行,因为 setCount 会触发重新渲染,那么我们就能获取到 HookClosureTrap 最新的作用域,也就能获取到最新的 count
1 | // 解决办法1:添加依赖项 |
虽然获取到了最新值,但是 count 显示结果却很混乱。
这是因为每次执行 useEffect 时就会创建一个定时器timer
,下次执行时,也会执行上次创建的定时器。
所以我们需要在 useEffect 中返回一个回调函数,清除本次创建的定时器。
1 | useEffect(() => { |
setState 函数式更新
setState 可以接收一个函数,函数的参数是上一次计算后该状态的值,可以理解为最新值
1 | // 解决办法2:使用setState函数式更新 |
useRef
1 | const refContainer = useRef(initialValue); |
useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。
也就是说,使用 useRef, 可以保持变量的引用不变。
这简直跟解决闭包陷阱的办法一模一样,就是把value
变量改为了引用类型。
我们修改 ref 的值后,setState 触发更新,重新渲染时,拿到的虽然是 HookClosureTrap 第一次执行时创建的作用域,ref 引用地址不变,但是它的值已经被改变了。
1 | // 解决办法3:使用useRef |
useReducer
useReducer 同 useRef 类似,使用引用数据类型解决这个问题
1 | import { useEffect, useRef, useReducer } from 'react'; |
除了useEffect
,像useMemo
、useCallback
都有 deps 参数,在这些 hook 里用到某个 state 时,如果 deps 为空数组,虽然 state 变了,但是却没有执行新传入的函数,依旧引用的之前的 state。
总结
createIncrement
这段代码中,出现闭包陷阱的原因是,第一次执行increment
后,该作用域被log
变量保存(因为产生了闭包(Closure(createIncrement))所以能被保存),作用域中的Local
对象中message = "Current value is 1"
,message
是基础数据类型,所以后续打印message
的值仍为第一次执行increment
的值。如果希望message
中的value
为最新值,将value
变量改为引用数据类型,并让message
指向value
变量即可。
react hooks 出现闭包陷阱的原因也是如此,采用添加依赖项、使用 setState 函数式更新、useRef、useReducer 都可以解决这个问题。
参考:
http://www.ferecord.com/react-hooks-closure-traps-problem.html
https://www.jianshu.com/p/6a512f78536a
https://cloud.tencent.com/developer/article/2016207
https://juejin.cn/post/6844904193044512782