在 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 中闭包的值。

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

相关推荐
浪裡遊12 小时前
Nivo图表库全面指南:配置与用法详解
前端·javascript·react.js·node.js·php
漂流瓶jz14 小时前
快速定位源码问题:SourceMap的生成/使用/文件格式与历史
前端·javascript·前端工程化
samroom14 小时前
iframe实战:跨域通信与安全隔离
前端·安全
fury_12314 小时前
vue3:数组的.includes方法怎么使用
前端·javascript·vue.js
weixin_4050233714 小时前
包资源管理器NPM 使用
前端·npm·node.js
宁&沉沦14 小时前
Cursor 科技感的登录页面提示词
前端·javascript·vue.js
Dragonir15 小时前
React+Three.js 实现 Apple 2025 热成像 logo
前端·javascript·html·three.js·页面特效
peachSoda715 小时前
封装一个不同跳转方式的通用方法(跳转外部链接,跳转其他小程序,跳转半屏小程序)
前端·javascript·微信小程序·小程序
@PHARAOH16 小时前
HOW - 浏览器兼容(含 Safari)
前端·safari