倒计时

倒计时是一个非常常见的业务场景,但是在 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 中闭包的值。

所以我们可以通过函数式更新解决这个问题。

个人笔记记录 2021 ~ 2025