在 React 中实现倒计时功能会有什么坑

倒计时

倒计时是一个非常常见的业务场景,但是在 React 中实现起来,却不算简单。

首先我们来看这段倒计时代码,它能否正常执行?

jsx 复制代码
import { useEffect, useState } from 'react';

export function App() {
  const [sec, setSec] = useState(10);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('倒计时剩余秒数', sec);
      setSec(sec - 1);
      if (sec <= 0) {
        clearInterval(timer);
      }
    }, 1000);

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

  return <div className='main'>{sec}</div>;
}

来看看实际表现效果

可以看到计时器在不断执行,但是 sec 的值却没有变。

这是一个很经典的问题:React 闭包陷阱

我们来详细分析下:useEffect 的依赖数组 [] 是空的,这意味着 effect 只会在组件挂载时执行一次,而不会在 sec 状态更新时重新执行。所以setInterval 回调函数中捕获的 sec 值始终是初始值 10

这个问题解决起来也很简单,有两种方案,第一种就是在依赖数组中添加 sec,这样每次 sec 更新后都会重新触发 useEffect 执行,更新 sec 的值。

jsx 复制代码
export function App() {
  const [sec, setSec] = useState(10);

  useEffect(() => {
   // ...
  }, [sec]); // 添加 sec 作为依赖

  return <div className='main'>{sec}</div>;
}

第二种方案。使用函数式更新, 这样每次更新都会基于最新的状态值,而不是被闭包捕获的初始值。

jsx 复制代码
export function App() {
  const [sec, setSec] = useState(10);

  useEffect(() => {
    const timer = setInterval(() => {
      setSec(pre => { // 使用函数式更新
        if (pre <= 0) {
          clearInterval(timer);
          return 0;
        }
        return pre - 1;
      });
    }, 1000);

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

  return <div className='main'>{sec}</div>;
}

什么是闭包

闭包是指函数及其引用的外部词法环境的组合。简单来说,当一个函数能够记住并访问它所在的词法作用域时,即使该函数在其原始作用域之外执行,这就形成了闭包。

这个概念估计大家都看到过很多很多次,但是真正遇到的时候还是一脸懵逼:"还能这样?" 要我说都是 JS 的错,才不是咱们的问题。

再来看一遍这个经典的题目:

js 复制代码
function createFunctions() {
  let funcs = [];
  
  for (var i = 0; i < 3; i++) {
    funcs.push(function() {
      console.log(i);
    });
  }
  
  return funcs;
}

const functions = createFunctions();
functions[0](); // 3
functions[1](); // 3
functions[2](); // 3

我们在函数中打印了 i 本意是打印 1 2 3 结果却打印了三次 3

这个问题的重点是:闭包捕获的是变量本身,而不是变量在某个时刻的值 。在打印的时刻,i 已经变成了 3 我们无法再获取创建函数那一刻 i 所对应的值。

而这道题的解决方式也特别简单,我们只需要把 var 改成 let 就好了,因为前者是函数作用域,而后者是块级作用域,当我们使用 let i 时,每一次循环都会创建一个新的 i 自然不再会有问题。

深入理解

重温了闭包,再回归我们的问题,为什么在前面的代码,我们没有获取到最新的 sec

首先从 useEffect 的实现来看

在 React 的 Fiber 架构中,每个组件对应一个 Fiber 节点,而 useEffect 会创建一个 effect 对象,被添加到 Fiber 节点的 updateQueue 中:

javascript 复制代码
// React 内部简化实现示意
function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = pushEffect(
    HookHasEffect | HookPassive,
    create,           // 你传入的函数
    undefined,        // 清理函数占位
    nextDeps          // 依赖数组
  );
}

当组件重新渲染时,React 会比较新旧依赖数组,如果依赖改变就重新创建 effect 对象,否则不更新。

javascript 复制代码
// React 内部简化实现示意
function updateEffect(create, deps) {
  const hook = updateWorkInProgressHook();
  if (areHookInputsEqual(nextDeps, prevDeps)) {
    // 依赖没变,跳过执行
    pushEffect(HookPassive, create, destroy, nextDeps);
    return;
  }
  
  // 依赖变了,标记这个 effect 需要在提交阶段执行
  currentlyRenderingFiber.flags |= PassiveEffect;
  hook.memoizedState = pushEffect(
    HookHasEffect | HookPassive,
    create,
    destroy,
    nextDeps
  );
}

function areHookInputsEqual(nextDeps, prevDeps) {
  // 使用 Object.is 算法比较每一项
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (!Object.is(nextDeps[i], prevDeps[i])) {
      return false;
    }
  }
  return true;
}

我们的依赖为空数组,则 effect 回调只在组件挂载时执行一次,之后即使 sec 状态变化,也不会重新创建定时器和闭包,它所引用的 sec 永远是开始的那个。

接下来我们再看 setState

让我们看看 useState 在 React 内部是如何工作的,下面是一个简化的实现:

js 复制代码
// 简化的 useState 实现
function useState(initialState) {
  // 获取当前正在渲染的 Fiber 节点
  const currentFiber = getCurrentFiber();
  
  // 获取或创建当前的 Hook
  const hook = updateWorkInProgressHook();
  
  if (hook.memoizedState === null) {
    // 首次渲染,初始化状态
    hook.memoizedState = initialState;
    hook.baseState = initialState;
  }
  
  // 处理更新队列
  const newState = processUpdateQueue(hook);
  hook.memoizedState = newState;
  
  // 创建 setState 函数
  const setState = createSetStateFunction(currentFiber, hook);
  
  return [hook.memoizedState, setState];
}

// 创建 setState 函数
function createSetStateFunction(fiber, hook) {
  // 返回一个新的 setState 函数
  return function setState(action) {
    // 创建更新对象
    const update = {
      action: action,           // 新状态值或更新函数
      next: null,              // 指向下一个更新
      priority: getCurrentPriority(),
    };
    
    // 将更新添加到队列
    enqueueUpdate(fiber, hook, update);
    
    // 调度重新渲染
    scheduleUpdateOnFiber(fiber);
  };
}


// 在更新队列处理时
function processUpdateQueue(hook) {
  let newState = hook.baseState;
  let update = hook.queue;
  
  while (update !== null) {
    const action = update.action;
    
    if (typeof action === 'function') {
      // 函数式更新:将当前最新状态传给函数
      newState = action(newState);
    } else {
      // 直接更新:使用 action 作为新状态
      newState = action;
    }
    
    update = update.next;
  }
  
  return newState;
}

可以观察到,每一次组件的更新渲染,都会重新执行 setState 此时 state 会被更新引用为 setState() 传的那个值(不传函数的情况下)。

也就是说,我们组件此时的 sec 已经不是初始的 sec 了,useEffect 仍然引用闭包中最初的 sec 所以它的值没有被更新。

所以我们很好理解为什么在修改了依赖数组,就可以拿到最新的值。

那为什么修改为函数式更新也会生效呢?

可以看到 processUpdateQueue 的实现中,如果我们使用函数式更新,传入的值来自 fiber 节点中的 hook,而不依赖 useEffect 中闭包的值。

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

相关推荐
小小小小宇2 小时前
前端PerformanceObserver
前端
王者鳜錸2 小时前
PYTHON从入门到实践-18Django从零开始构建Web应用
前端·python·sqlite
拾光拾趣录2 小时前
ES6到HTTPS全链路连环拷问,99%人第3题就翻车?
前端·面试
haaaaaaarry3 小时前
Element Plus常见基础组件(二)
开发语言·前端·javascript
xyphf_和派孔明4 小时前
关于echarts的性能优化考虑
前端·性能优化·echarts
PyHaVolask4 小时前
HTML 表单进阶:用户体验优化与实战应用
前端·javascript·html·用户体验
A了LONE4 小时前
cv弹窗,退款确认弹窗
java·服务器·前端
AntBlack4 小时前
闲谈 :AI 生成视频哪家强 ,掘友们有没有推荐的工具?
前端·后端·aigc
花菜会噎住5 小时前
Vue3核心语法进阶(computed与监听)
前端·javascript·vue.js