Hooks闭包陷阱

闭包陷阱

以下两段代码,来自 https://github.com/hacker0limbo/my-blog/issues/6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function createIncrement(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
const message = `Current value is ${value}`;
return function logValue() {
console.log('value: ', value);
console.log(message);
};
}

return increment;
}

const inc = createIncrement(1);
const log = inc(); // 1
inc(); // 2
inc(); // 3

log(); // "Current value is 1"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createIncrementFixed(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
return function logValue() {
const message = `Current value is ${value}`;
console.log(message);
};
}

return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 1
inc(); // 2
inc(); // 3

log(); // "Current value is 3"

截取文章中的一段话,如下:



对于这句话,我再做下解释:
在 value 变量修改值之后,message 变量保存的值仍旧是原始的值。这是因为基础数据类型的值是不可变的。

基础数据类型的值是不可变的

让我们举例说明,看下这段代码,思考 a 的值为多少?

1
2
3
4
5
6
let a = 1;
let b = a;
b++;

console.log(a);
console.log(b);

b++,b 的值被改变了,但是 a 没被改变,依然为 1。这意味着 a b 的等价,并不表示他们是同一个值。也就是说,a 赋予 b 的时候,重新给 b 分配了一块内存空间。因此我们说,基础数据类型,是按值访问的。 用图表示

基础数据类型的比较,是值在比较

1
2
3
const a = 1;
const b = 1;
a === b; // true

当 a 和 b 在比较的时候,本质上是值在比较。所以我们说基础数据类型的值是不可变的。

出现闭包陷阱的原因

为第一段代码第 6 行打上断点

  1. 开始执行createIncrement函数,先定义基础数据类型 value并赋值为 0,然后返回increment函数并赋值给inc
  2. inc 执行,进入increment函数,执行到第 8 行,value+=i,变量value的值变成了 1,同时产生了闭包对象Closure(createIncrement),该闭包对象存在increment作用域当中。

  1. 接着执行到第 10 行,声明了message变量,并将基础数据类型 value的值赋给message,此时value为 1,所以message = "Current value is 1",然后返回logValue函数,increment函数执行完毕,并赋值给log变量。因为闭包对象Closure(createIncrement)的产生,log变量将会引用着increment函数第一次执行时创建的作用域。如图所示,作用域里的 Local 对象「活动对象」中的message = "Current value is 1"

  1. 解释第 25 行,执行logValue函数,寻找message变量。 先查找 Local 活动对象,没找到,发现作用域链中的闭包对象Closure(increment)中存在message,且值为Current value is 1,打印messagelogValue函数执行完毕。
1
2
Local 活动对象:仅仅只有处于栈顶的执行上下文,才会生成 Local 对象。
除了 Local 活动对象,作用域链中的其他几种变量对象(Closure/Module/Global)都能够在函数解析时确定

虽然Closure(createIncrement)中的value已经变成了 3,但是并不影响基础数据类型 message 变量中的 value 值。
那是不是只要将引用类型赋值给 message 变量,就可以解决闭包陷阱问题?

解决办法

让我们稍微改下代码,将value改为引用类型,再赋值给 message

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
34
35
36
// 闭包陷阱
// 参考: https://github.com/hacker0limbo/my-blog/issues/6
const Trap = (props) => {
// debugger
function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = value;

return function logValue() {
console.log('value: ', value);
console.log('message: ', message);
};
}

return increment;
}

const inc = createIncrement(1);
const log = inc(); // 1
inc(); // 2
inc(); // 3

log(); // "Current value is 3"

return (
<>
<div>闭包陷阱</div>
</>
);
};

export default Trap;

第一次执行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确实能解决闭包陷阱问题。

小结:

  1. log变量引用着increment函数第一次执行时创建的作用域,此时message = "Current value is 1"
  2. 执行三次increment函数后,value变成了 3,然后执行logValue函数而message中的value依旧为 1 的原因是value变量是个基础数据类型。
  3. 想要拿到value变量的最新值,我们只需要把value变量从基础数据类型改为引用数据类,并让message指向value变量即可。

思考以下三种不同赋值方式message的值:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = `Current value is ${value.a}`;

return function logValue() {
console.log('value: ', value); // { a: 3 }
console.log('message: ', message); // Current value is 1
};
}

return increment;
}

function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = {
a: value.a,
};

return function logValue() {
console.log('value: ', value); // { a: 3 }
console.log('message: ', message); // { a: 1 }
};
}

return increment;
}

function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = {
a: value,
};

return function logValue() {
console.log('value: ', value); // { a: 3 }
console.log('message: ', message); // { a: { a: 3 } }
};
}

return increment;
}

hooks 出现的闭包陷阱,其实跟上面的问题是一样的。

Hooks 中的闭包陷阱

1
useEffect(fn, [deps]);

在 react hook 中有个经典的闭包陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useEffect, useState } from 'react';

// hooks 中的闭包陷阱
const HookClosureTrap = () => {
const [count, setCount] = useState(0);

useEffect(() => {
// 获取到的count永远是0,这是因为闭包陷阱导致的问题
// 当依赖项为空数组时,fn只会执行一次,useEffect永远引用着HookClosureTrap第一次执行时创建的作用域。第一次创建是count为0
setInterval(() => {
setCount(count + 1);
}, 1000);
}, []);

return (
<>
<h1>hooks 中的闭包陷阱</h1>
<div>count: {count}</div>
</>
);
};

export default HookClosureTrap;

在这个例子中,我们给useEffect第二个参数deps传了个空数组,并使用setInterval希望隔秒count+1,然而实际效果是首次渲染时页面中count0,然后过了 1 秒页面中的count更新为1,之后一直保持为1不变,这就是useEffect的引起的闭包问题。
因为当依赖项为空数组时,fn(useEffect 的第一个参数)只会执行一次,而 useEffect 永远引用着 HookClosureTrap 第一次执行时创建的作用域。第一次创建时 count 为 0。

解决办法

添加依赖项

添加 count 依赖项,依赖项改变时 fn 重新执行,因为 setCount 会触发重新渲染,那么我们就能获取到 HookClosureTrap 最新的作用域,也就能获取到最新的 count

1
2
3
4
5
6
// 解决办法1:添加依赖项
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 1000);
}, [count]);

虽然获取到了最新值,但是 count 显示结果却很混乱。
这是因为每次执行 useEffect 时就会创建一个定时器timer,下次执行时,也会执行上次创建的定时器。
所以我们需要在 useEffect 中返回一个回调函数,清除本次创建的定时器。

1
2
3
4
5
6
7
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);

return () => timer && clearInterval(timer);
}, [count]);

setState 函数式更新

setState 可以接收一个函数,函数的参数是上一次计算后该状态的值,可以理解为最新值

1
2
3
4
5
6
7
8
9
10
11
12
// 解决办法2:使用setState函数式更新

useEffect(() => {
const timer = setInterval(() => {
// 这里打印的count,依旧永远是0
console.log('count: ', count);

setCount((prev) => prev + 1);
}, 1000);

return () => timer && clearInterval(timer);
}, []);

useRef

1
const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。

也就是说,使用 useRef, 可以保持变量的引用不变。
这简直跟解决闭包陷阱的办法一模一样,就是把value变量改为了引用类型。

我们修改 ref 的值后,setState 触发更新,重新渲染时,拿到的虽然是 HookClosureTrap 第一次执行时创建的作用域,ref 引用地址不变,但是它的值已经被改变了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 解决办法3:使用useRef
const countRef = useRef(0);

useEffect(() => {
const timer = setInterval(() => {
setCount(++countRef.current);

// 还有一种不常见的写法
// 对count使用前置自增运算符,也能得到正确的效果
// setCount(++count)
}, 1000);

return () => timer && clearInterval(timer);
}, []);

useReducer

useReducer 同 useRef 类似,使用引用数据类型解决这个问题

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
34
35
import { useEffect, useRef, useReducer } from 'react';

function countReducer(state, { type, payload }) {
switch (type) {
case 'add':
return {
count: state.count + payload,
};
default:
throw new Error();
}
}

// hooks 中的闭包陷阱
const HookClosureTrap = () => {
const [state, dispatch] = useReducer(countReducer, { count: 0 });

// 解决办法4: 使用useReducer
// useReducer 同 useRef 类似,使用引用数据类型解决这个问题
useEffect(() => {
setInterval(() => {
dispatch({ type: 'add', payload: 1 });
}, 1000);
}, []);

return (
<>
<h1>hooks 中的闭包陷阱</h1>

<div>state.count: {state.count}</div>
</>
);
};

export default HookClosureTrap;

除了useEffect,像useMemouseCallback都有 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

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×