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