React Hooks闭包陷阱

“ 近期由于工作忙的原因,导致许久没有更新公众号了,现在抽空我们来了解一下React Hooks闭包相关的问题。”

1. 浅谈一下什么是闭包

学过js的同学,肯定对闭包一点都不陌生吧。
举个例子:

1
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
28
29
30
31
32
33
// 前提:函数外部是无法读取函数内部变量
function f1 () {
const count = 1
}
console.log('count: ', count) // Error: count is not defined

// 如何从外部读取局部变量?
// 答案:在函数内部再定义一个函数
// 例一
function f1 () {
const count = 1
function f2 () {
console.log('count: ', count) // count: 1
}
}
// 在例一中函数f2会被包括在函数f1内部,此时函数f1内部所有局部变量对
// 函数f2都是可见的;相反函数f2内部的局部变量对函数f1是不可见的。
// 例一中应证了:js语言特有的"链式作用域"结构,子对象会一级一级向上,因此,
// 父对象的所有变量,对子对象都是可见的,反之则不成立。寻找所有父对象的变量,

// 例二
// 在例一中,我们分析到函数f2是可以读取f1中的变量,那么我们可以把
// f2作为返回值,这样就可以在f1外部读取它内部的变量了。
function f1 () {
const count = 1;
function f2 () {
console.log('count: ', count)
}
return f2; // 对于例一中代码新增部分
}
const res = f1();
res(); // count: 1
// 在这里其实函数f2就是闭包

闭包概念:
简单理解为:能够读取其他函数内部变量的函数。由于在js语言中,只有函数内部的子函数才能去读取局部变量,我们还可以把闭包理解为:定义在一个函数内部的函数本质:闭包就是将函数内部和函数外部连接起来的一座桥梁(你可以把它当成私有变量的get和set方法)。

闭包的作用:
可以读取函数内部的变量(上面例子中提到的);
让这些变量的值始终保持在内存中。
为什么始终保持在内存中?由于函数f1是f2的父函数,函数f2被赋值给res一个全局变量,就会导致函数f2始终在内存中,由于函数f2存在依赖函数f1,所有函数f1也始终在内容中,导致不会在调用结束后被垃圾回收机制回收。

闭包的应用场景:
数据私有化;
函数的封装与改造;
手动延长某些局部变量的寿命。

闭包的注意事项:

由于闭包会使得函数中的变量被保存在内存中,内存消耗很大,因此不能滥用闭包,否则会造成内存泄漏,出现性能问题(解决办法:在退出函数之前,将不使用的局部变量全部删除);

闭包会在父函数外部改变父函数内部变量的值,因此在使用闭包时不要随便的改变父函数内部变量的值。

2. React Hooks中的闭包陷阱

首先先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
import React, { useState, useEffect } from 'react'

function Test () {
const [count, setCount] = useState<number>(1)
useEffect(() => {
// 这里定时器去打印 count值,不管其他地方使用到setCount方法去改变count值,
// 最终都会发现count值打印依旧都是1
setInterval(() => console.log('count: ', count), 1000) // 始终为1
}, []) // 如果想解决这个问题,就把[]改为[count]
const handleClick = () => setCount(2)
return <button onClick={handleClick}>点击</button>}

在useEffect回调函数中我们会发现count打印的值始终为1,为什么count值没有跟着变呢?这就是React Hooks的闭包陷阱!

3. 简单介绍一下React Hooks的原理

React Hooks原理:Hooks就是在fiber节点上存放了memoizedState链表,不同的hook在memoizedState链表不同的元素上存取值,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Fiber与Hook的部分结构定义
export type Fiber = {
updateQueue: mixed, // 存储Fiber节点相关的副作用链表,比如useEffect
memoizedState: any, // 存储Fiber节点相关的状态值
flags: Flags, // 标识当前Fiber节点是否有副作用
}
export type Hook = {
memoizedState: any, // 最新的状态值
baseState: any, // 初始状态值
baseUpdate: Update<any, any> | null, // 最近一次调用更新state方法的action
queue: UpdateQueue<any, any> | null, // 环形链表,存储的是该hook多次调用产生的更新对象
next: Hook | null, // 指向下一个hook对象
}

我们还需要了解的就是:Hooks与Fiber是如何共同工作的,先看看下面两张在网络上找到的图片:
Hooks组成的链表结果图
Hooks组成的链表结果图

在每个状态Hook节点中(如useState),会通过queue属性上的循环链表记住所有更新操作,并在update阶段依次执行循环链表中的所有更新操作,最终拿到最新的state返回。
副作用Hooks组成的链表结构
副作用Hooks组成的链表结构

在每个副作用Hook节点中(如useEffect),创建effect挂载到Hook的memoizedState中,并添加到环形链表的末尾,该链表会保存到Fiber节点的updateQueue中,会在commit阶段执行。

4. 如何解决React Hook闭包陷阱

大致介绍了一下React Hooks的原理之后,我们再回顾上面的代码,useEffect中的deps传递的是空数组,所以只会执行一次,然后我们又点击了1次button按钮试图去改变count的值变为2;那么这时问题就出来了,因为定时器中是要去拿到最新的count值,也就是说useEffect需要去依赖count的;deps是一个空数组,count的值是最开始初始化时的值为1,并且在定时器的回调函数中count值被引用了,所以就形成了闭包一直被保存(相当于它始终捕获的是第一个函数调用时产生的词法环境,因此捕获的count值始终为1)。看下面这幅图:
useEffect中deps为空数组解析图
useEffect中deps为空数组解析图

虽然state变化了,但是执行的回调函数却一直引用着最开始的state。如何解决这个问题呢?答案其实很简单,就是给deps添加上count引用就可以了,然后就得到了下面这幅图:
useEffect中deps添加count引用解析图
useEffect中deps添加count引用解析图

闭包陷阱产生本质原因:useEffect、useCallback等hook里用到了某个state,但是没有添加到deps数组中,这样就导致了state变了却没有执行新传入的函数,依然引用的是之前的state。其实要解决闭包陷阱也很简单,那么就是正确的设置deps数组,这样每次用到的state变了就会执行新的函数,引入新的state。

5. 不依赖deps数组解决闭包陷阱之useRef

如果不依赖deps数组,那我们应该如何解决这个棘手的问题呢?其实可以考虑使用useRef。因为useRef在memoizedState链表中放一个对象,current是用来保存值的,如果后面修改了ref中的值,那么取出来的就是最新的值。

特别感谢下面几篇文章的作者:

从根上理解 React Hooks 的闭包陷阱

React Hooks 实现原理

从react hooks“闭包陷阱”切入,浅谈react hooks

React Hooks 的实现必须依赖 Fiber 么?