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

2025-08-03
11734 分钟
...

倒计时

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

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

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 的值。

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

  useEffect(() => {
   
  }, [sec]); 

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

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

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 的错,才不是咱们的问题。

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

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

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

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

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

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

深入理解

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

首先从 useEffect 的实现来看

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


function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = pushEffect(
    HookHasEffect | HookPassive,
    create,           
    undefined,        
    nextDeps          
  );
}

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


function updateEffect(create, deps) {
  const hook = updateWorkInProgressHook();
  if (areHookInputsEqual(nextDeps, prevDeps)) {
    
    pushEffect(HookPassive, create, destroy, nextDeps);
    return;
  }
  
  
  currentlyRenderingFiber.flags |= PassiveEffect;
  hook.memoizedState = pushEffect(
    HookHasEffect | HookPassive,
    create,
    destroy,
    nextDeps
  );
}

function areHookInputsEqual(nextDeps, prevDeps) {
  
  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 内部是如何工作的,下面是一个简化的实现:


function useState(initialState) {
  
  const currentFiber = getCurrentFiber();
  
  
  const hook = updateWorkInProgressHook();
  
  if (hook.memoizedState === null) {
    
    hook.memoizedState = initialState;
    hook.baseState = initialState;
  }
  
  
  const newState = processUpdateQueue(hook);
  hook.memoizedState = newState;
  
  
  const setState = createSetStateFunction(currentFiber, hook);
  
  return [hook.memoizedState, setState];
}


function createSetStateFunction(fiber, hook) {
  
  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 {
      
      newState = action;
    }
    
    update = update.next;
  }
  
  return newState;
}

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

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

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

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

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

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

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
react2025-07-30
React性能优化三剑客:memo、useMemo和useCallback详解
前言 在React开发中,性能优化是一个永恒的话题。今天我们就来深入探讨React提供的三个重要性能优化工具: 、 和 ,它们如何帮助我们构建更高效的React应用。 1\. 为什么需要性能优化? 🤔 React的核心机制是当组件的state或props发生变化时,组件会重新渲染。但有时这种重新渲染是不必要的: 父组件更新导致所有子组件重新渲染 ,即使子组件的props没有变化 复杂计算在每次渲染时重复执行 ,消耗大量资源 函数引用在...
学习面试
react2024-12-06
React 19 终于发布,一大波新功能正式升级
这篇文章主要介绍了 React 19 的新功能,包括 Actions 自动处理数据突变相关状态,新增的 useActionState、useFormStatus、useOptimistic 等 hook,新的 use API 读取渲染资源,新的 React DOM 静态 API 生成静态站点,以及 React 服务器组件中的 RSC 和 RSA 等内容。同时提到后续会分享针对旧版功能的优化和改良。 关联问题: React 19 性能如何...
学习面试
react2024-09-18
React | 浏览器刷新,会执行 useEffect 的 卸载函数吗?
React | 浏览器刷新,会执行 useEffect 的 卸载函数吗? 需求描述 后端在跑一个长期的异步上传任务,在任务执行过程中,用户刷新页面(f5),此时产品希望能够发送一个 kill 任务,杀掉这个任务。 拆解 需求合理性 其他情况是否存在类似:比如关闭 tab,关闭浏览器,突然断网等操作,等浏览器层级或者异常的用户行为 前端是否应该响应这种行为:对于更深层次的需求处理 根本问题点,或者说为什么会存在这个需求:上一个任务会加锁,...
学习面试
react2024-07-29
Effect:渲染之外的副作用
React 借鉴了许多函数式编程的理念,使得它在构建复杂用户界面时能够保持代码的简洁、可维护和可测试性。函数式编程在很大程度上依赖于纯函数 纯函数(Pure Function)是指在计算机科学和函数式编程中使用的一类函数。一个函数如果具备以下两个主要特性,我们就可以称之为纯函数: 1. 引用透明性:纯函数在相同的输入下总是产生相同的输出。也就是说,函数的输出仅依赖于其输入参数,不依赖于任何外部状态或变量。 2. 无副作用:纯函数不会改变...
学习面试
nuxt2025-10-22
Nuxt SSR 与 Next.js SSR
🧭 引言 在现代前端生态中, Nuxt(Vue) 与 Next.js(React) 是最成熟的两大全栈框架。 它们都具备 SSR(Server Side Rendering) 能力,但实现原理与运行机制差异巨大。 如果你在使用 Nuxt 构建 SSR 项目,却发现“路由跳转后不再 SSR”, 而在 Next.js 中同样操作却会重新发起服务端渲染——这不是 bug,而是 理念差异 。 本文将详细对比这两种框架的 SSR 行为,帮助你彻...
学习面试
vue2025-10-22
shallowRef 与 ref 的区别、场景与坑位
结论先行: 会 深度 地把你放进去的对象转成响应式(递归代理),因此"对象内部的改动"也会触发视图更新。 只在 顶层 做依赖追踪: 更换 才触发更新 ;若你只是"在原对象上改属性",不会触发,除非手动 。 1\. 快速对比 维度 追踪粒度 深度(递归) 仅顶层 对象内部属性变更 会触发更新 不会 触发(除非 或重新赋值) 性能 有递归 & 依赖跟踪开销 更轻量,适合大对象/第三方实例 适用对象 普通标量、普通对象、数组 第三方实例(图表...
学习面试

评论

请登录后发表评论

去登录
加载评论中...

目录