倒计时
倒计时是一个非常常见的业务场景,但是在 React 中实现起来,却不算简单。
首先我们来看这段倒计时代码,它能否正常执行?
1import { useEffect, useState } from 'react';
2
3export function App() {
4 const [sec, setSec] = useState(10);
5
6 useEffect(() => {
7 const timer = setInterval(() => {
8 console.log('倒计时剩余秒数', sec);
9 setSec(sec - 1);
10 if (sec <= 0) {
11 clearInterval(timer);
12 }
13 }, 1000);
14
15 return () => {
16 clearInterval(timer);
17 };
18 }, []);
19
20 return <div className='main'>{sec}</div>;
21}
来看看实际表现效果
可以看到计时器在不断执行,但是 sec
的值却没有变。
这是一个很经典的问题:React 闭包陷阱 。
我们来详细分析下:useEffect
的依赖数组 []
是空的,这意味着 effect 只会在组件挂载时执行一次,而不会在 sec
状态更新时重新执行。所以setInterval
回调函数中捕获的 sec
值始终是初始值 10
。
这个问题解决起来也很简单,有两种方案,第一种就是在依赖数组中添加 sec
,这样每次 sec
更新后都会重新触发 useEffect
执行,更新 sec
的值。
1export function App() {
2 const [sec, setSec] = useState(10);
3
4 useEffect(() => {
5
6 }, [sec]);
7
8 return <div className='main'>{sec}</div>;
9}
第二种方案。使用函数式更新, 这样每次更新都会基于最新的状态值,而不是被闭包捕获的初始值。
1export function App() {
2 const [sec, setSec] = useState(10);
3
4 useEffect(() => {
5 const timer = setInterval(() => {
6 setSec(pre => {
7 if (pre <= 0) {
8 clearInterval(timer);
9 return 0;
10 }
11 return pre - 1;
12 });
13 }, 1000);
14
15 return () => {
16 clearInterval(timer);
17 };
18 }, []);
19
20 return <div className='main'>{sec}</div>;
21}
22
什么是闭包
闭包是指函数及其引用的外部词法环境的组合。简单来说,当一个函数能够记住并访问它所在的词法作用域时,即使该函数在其原始作用域之外执行,这就形成了闭包。
这个概念估计大家都看到过很多很多次,但是真正遇到的时候还是一脸懵逼:“还能这样?” 要我说都是 JS 的错,才不是咱们的问题。
再来看一遍这个经典的题目:
1function createFunctions() {
2 let funcs = [];
3
4 for (var i = 0; i < 3; i++) {
5 funcs.push(function() {
6 console.log(i);
7 });
8 }
9
10 return funcs;
11}
12
13const functions = createFunctions();
14functions[0]();
15functions[1]();
16functions[2]();
我们在函数中打印了 i
本意是打印 1 2 3
结果却打印了三次 3
。
这个问题的重点是:闭包捕获的是变量本身,而不是变量在某个时刻的值。在打印的时刻,i
已经变成了 3
我们无法再获取创建函数那一刻 i
所对应的值。
而这道题的解决方式也特别简单,我们只需要把 var
改成 let
就好了,因为前者是函数作用域,而后者是块级作用域,当我们使用 let i
时,每一次循环都会创建一个新的 i
自然不再会有问题。
深入理解
重温了闭包,再回归我们的问题,为什么在前面的代码,我们没有获取到最新的 sec
?
首先从 useEffect
的实现来看
在 React 的 Fiber 架构中,每个组件对应一个 Fiber 节点,而 useEffect
会创建一个 effect 对象,被添加到 Fiber 节点的 updateQueue
中:
1
2function mountEffect(create, deps) {
3 const hook = mountWorkInProgressHook();
4 const nextDeps = deps === undefined ? null : deps;
5 hook.memoizedState = pushEffect(
6 HookHasEffect | HookPassive,
7 create,
8 undefined,
9 nextDeps
10 );
11}
当组件重新渲染时,React 会比较新旧依赖数组,如果依赖改变就重新创建 effect 对象,否则不更新。
1
2function updateEffect(create, deps) {
3 const hook = updateWorkInProgressHook();
4 if (areHookInputsEqual(nextDeps, prevDeps)) {
5
6 pushEffect(HookPassive, create, destroy, nextDeps);
7 return;
8 }
9
10
11 currentlyRenderingFiber.flags |= PassiveEffect;
12 hook.memoizedState = pushEffect(
13 HookHasEffect | HookPassive,
14 create,
15 destroy,
16 nextDeps
17 );
18}
19
20function areHookInputsEqual(nextDeps, prevDeps) {
21
22 for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
23 if (!Object.is(nextDeps[i], prevDeps[i])) {
24 return false;
25 }
26 }
27 return true;
28}
我们的依赖为空数组,则 effect
回调只在组件挂载时执行一次,之后即使 sec
状态变化,也不会重新创建定时器和闭包,它所引用的 sec
永远是开始的那个。
接下来我们再看 setState
让我们看看 useState
在 React 内部是如何工作的,下面是一个简化的实现:
1
2function useState(initialState) {
3
4 const currentFiber = getCurrentFiber();
5
6
7 const hook = updateWorkInProgressHook();
8
9 if (hook.memoizedState === null) {
10
11 hook.memoizedState = initialState;
12 hook.baseState = initialState;
13 }
14
15
16 const newState = processUpdateQueue(hook);
17 hook.memoizedState = newState;
18
19
20 const setState = createSetStateFunction(currentFiber, hook);
21
22 return [hook.memoizedState, setState];
23}
24
25
26function createSetStateFunction(fiber, hook) {
27
28 return function setState(action) {
29
30 const update = {
31 action: action,
32 next: null,
33 priority: getCurrentPriority(),
34 };
35
36
37 enqueueUpdate(fiber, hook, update);
38
39
40 scheduleUpdateOnFiber(fiber);
41 };
42}
43
44
45
46function processUpdateQueue(hook) {
47 let newState = hook.baseState;
48 let update = hook.queue;
49
50 while (update !== null) {
51 const action = update.action;
52
53 if (typeof action === 'function') {
54
55 newState = action(newState);
56 } else {
57
58 newState = action;
59 }
60
61 update = update.next;
62 }
63
64 return newState;
65}
可以观察到,每一次组件的更新渲染,都会重新执行 setState
此时 state
会被更新引用为 setState()
传的那个值(不传函数的情况下)。
也就是说,我们组件此时的 sec
已经不是初始的 sec
了,useEffect
仍然引用闭包中最初的 sec
所以它的值没有被更新。
所以我们很好理解为什么在修改了依赖数组,就可以拿到最新的值。
那为什么修改为函数式更新也会生效呢?
可以看到 processUpdateQueue
的实现中,如果我们使用函数式更新,传入的值来自 fiber 节点中的 hook
,而不依赖 useEffect
中闭包的值。
所以我们可以通过函数式更新解决这个问题。