如何优雅的实现一个倒计时功能

前言

记录分享每一个日常开发项目中的实用小知识,不整那些虚头巴脑的框架理论与原理,之前分享过抽奖功能、签字功能、多行标签展开折叠功能等,有兴趣的可以看看本人以前的分享。

这次要分享的实用小功能十分的常见,通常在未支付的订单、获取手机验证码登录界面、特别是抢购秒杀活动中可以经常看到,那就是倒计时功能。下面是我随手截的一些APP中的倒计时功能图:

之前做了一个考试系统也有倒计时这个功能,然而就是这个随处可见的一个小小的功能在完成产品需求过程中还是或多或少的遇到了一些小问题,最常见的就是时间不准确的问题。刚好借着处理这个需求的过程来总结一下该如何实现一个倒计时,且如何优雅的实现一个倒计时功能。

实现方法

通过多年的经验,JavaScript实现倒计时方法主要有下面几种:

  • setInterval() 方法重复调用一个函数或执行一个代码片段,在每次调用之间具有固定的时间间隔。
  • setTimeout() 方法设置一个定时器,一旦定时器到期,就会执行一个函数或指定的代码片段。
  • requestAnimationFrame() 方法执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

下面是三种方法实现的倒计时实现GIF图:

接下来我们就看看分别如何实现,且分析分析各种方式的优缺点。

第一种:setInterval()方法

定义:

setInterval() 方法重复调用函数,每经过指定毫秒后执行一次。

语法:
js 复制代码
let intervalID = setInterval(func, [delay, arg1, arg2, ...])

这个语法不用多说,基本刚入们的同学都会使用,注意后面还可以有第三个参数,这个可能一些工作多年的小伙伴都不知道啥意思,如果不知道的话就去好好面壁思过,罚你今天不许吃晚饭。

简单案列:
html 复制代码
<h3>>>>>>>>>> 【 setInterval 】 <<<<<<<<</h3>
<h4>{{ setIntervalRemain }}</h4>
js 复制代码
const setIntervalRemain = ref(0)
let setIntervalTime = 3 * 60 * 1000
const setIntervalId = setInterval(() => {
  if (setIntervalTime <= 0) {
    clearInterval(setIntervalId)
    return
  }
  setIntervalTime -= 1000
  const { hours, minutes, seconds } = parseTimeData(setIntervalTime)
  setIntervalRemain.value = parseFormat(props.format, parseTimeData(setIntervalTime))
}, 1000)

上面是使用Vue3来实现倒计时的关键方法,parseTimeDataparseFormat方法是解析时间和格式的相关方法,后面代码会说明。

使用容易踩的坑

如果没有深入研究 JavaSrcipt 的同学可能会问,简单使用一个 setInterval 方法会什么坑啊,首先就是无法实现毫秒计算,我只想说如果不注意的话轻则时间不准确,重则导致内存泄漏,而且无法实现毫秒展示,那么此话怎讲?

首先说说时间可能不准确的问题,众所周知 JavaSrcipt 是单线程的,某个时间点只能执行一个任务,而 setInterval 方法实则是在规定的时间点把执行任务的事件推到事件队列中,如果只是简单的项目且页面足够简单,没有其它的监听事件,不会发生频繁的交互操作,这么写不会出现大问题,而且误差也仅在毫秒内。如果页面有大量同步事件或存在很多监听事件或者交互操作,就可能会发生跳秒的现象,而且现在的前端三大框架运行态基本都是主线程中进的,下面看执行一个大循环同步任务导致的跳秒现象:

然后说说可能存在的内存泄露问题,**setInterval()**是由 Window 和或 Worker 接口提供的,它返回一个 interval ID,该 ID 唯一地标识时间间隔,因此你可以稍后通过调用 clearInterval() 来移除定时器。如果不小心没有移出定时器的话,就会一直存在内存中,不会被浏览器清除,从而导致内存泄露。

最后说说毫秒的展示问题,我们根据前言截的一些APP中的倒计时功能图中可以看到,有些场景是需要展示毫秒数据的,然而定时器是很难甚至无法实现的。

第二种:setTimeout()方法

定义:

setTimeout() 方法设置一个定时器,一旦定时器到期,就会执行一个函数或指定的代码片段。

语法:
js 复制代码
let timeoutID = setTimeout(functionRef, delay, param1, param2, /* ... ,*/ paramN)

这个语法不用多说,跟 setInterval 一样,如果不清楚的话自裁吧。但是我们都清楚,setTimeout方法只会执行一次,那么倒计时是要不断间隔执行的我们如何实现呢,当然是循环调用啦,使用setTimeout来模拟setInterval的功能,如果你的代码逻辑执行时间可能比定时器时间间隔要长,例如,使用 setInterval() 以 5 秒的间隔轮询服务器,可能因网络延迟、服务器无响应以及许多其他的问题而导致请求无法在分配的时间内完成。因此,你可能会发现排队的 XHR 请求没有按顺序返回,建议你使用递归调用 setTimeout() 的具名函数,这里我们不展开讲,下面看setTimeout 使用案例。

简单案列:
html 复制代码
<h3>>>>>>>>>> 【 setTimeout 】 <<<<<<<<</h3>
<h4>{{ setTimeoutRemain }}</h4>
js 复制代码
const setTimeoutRemain = ref(0)
let setTimeoutTime = 3 * 60 * 1000
let setTimeoutId = null
const setTimeoutFun = () => {
  setTimeoutId = setTimeout(() => {
    if (setTimeoutTime <= 0) {
      clearTimeout(setTimeoutId)
      return
    }
    setTimeoutTime -= 1000
    const { hours, minutes, seconds } = parseTimeData(setTimeoutTime)
    setTimeoutRemain.value = `${hours}:${minutes}:${seconds}`
    setTimeoutFun()
  }, 1000)
}
setTimeoutFun()
容易踩的坑

既然setInterval都有那么多坑,那么setTimeout坑肯定也是不少的,首先就是 延时比指定值更长,有很多因素会导致 setTimeout 的回调函数执行比设定的预期值更久,比如:

  • 为了优化后台标签的加载损耗(以及降低耗电量),浏览器会在非活动标签中强制执行一个最小的超时延迟。

  • 如果页面(或操作系统/浏览器)正忙于其他任务,超时也可能比预期的晚。

  • 当前标签页正在加载时,Firefox 将推迟触发 setTimeout() 计时器,直到主线程被认为是空闲的等等情况。

解决方案

既然 setIntervalsetTimeout都会产生时间计算偏差或延迟的问题,那么有没有解决方法呢,答案时肯定的,通用一般有两种

  • 第一种是调整时间偏差

  • 第二种是使用 Web Workers

关于这两种方案的具体实现这里不展开讨论,可自行查阅相关资料,因为我接下来主要是要讲解的第三种实现方法 requestAnimationFrame()

第三种:requestAnimationFrame()

这种方案是我最推荐的,也是这篇文章要着重阐述的,因为对比前两种方案优势很明显:第一个是性能要更优,第二个是方便实现毫秒展示,第三个就是该方法执行延迟偏差几乎可以忽略不计。

而且通过观察我发现大部分比较流行的前端UI组件库源码几乎都是使用这种方法实现的,比如 VantNUTUITDesign Mobile 等,但是机制如我发现好像PC端的UI库都没有倒计时组件,咋回事,懂的老哥评论大声告诉我。

定义

requestAnimationFrame() 告诉浏览器------你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

回调函数执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配,这样可以大概得出每 1000 / 60 ≈ 16.7ms 执行一次,人眼几乎是无法分辨。

案例

因为我们这篇文章目的就是主要是讲解如何使用 requestAnimationFrame()方法来优雅的实现一个倒计时组件,所以这部分案列代码我们尽可能要深入分析,接下来我们使用vue3来实现一个可以直接应用的Demo出来,不多逼逼直接上代码。

html主体结构如下,包括各种模拟开始、暂停、重置事件:

html 复制代码
<h3>>>>>>>>>> 【 requestAnimationFrame 】 <<<<<<<<</h3>
<h4>{{ formatTime }}</h4>
<button @click="start">start</button>
<button @click="pause">pause</button>
<button @click="reset">reset</button>

模拟组件要传的参数和主要方法:

js 复制代码
const { createApp, ref, computed, watch } = Vue
createApp({
  props: {
    // 倒计时时长(毫秒)
    time: {
      type: Number,
      default: 3 * 60 * 1000
    },
    // 自动开始
    autoStart: {
      type: Boolean,
      default: true
    },
    // 倒计时格式(DD-天、HH-时、mm-分、ss-秒、ms-毫秒)
    format: {
      type: String,
      default: 'HH:mm:ss:ms',
    },
    millisecond: {
      type: Boolean,
      default: false,
    },
  },
  emits: ['finish', 'change'],
  setup (props, ctx) {
    const endTime = ref(0) // 结束时间
    const remainTime = ref(0) // 剩余时间
    const counting = ref(false) // 是否正在计时
    let requestAnimationFrameId = null

    const formatTime = computed(() => {
      return parseFormat(props.format, parseTimeData(remainTime.value))
    })

    const countDown = () => {
      remainTime.value = Math.max(0, endTime.value - Date.now())
      ctx.emit('change', parseTimeData(remainTime.value))
      if (remainTime.value <= 0) {
        cancelAnimationFrame(requestAnimationFrameId)
        ctx.emit('finish')
        return
      }
      requestAnimationFrameId = requestAnimationFrame(countDown)
    }
    // countDown()

    const start = () => {
      if (counting.value) {
        return
      }
      console.log('start>>>>>>')
      counting.value = true
      endTime.value = Date.now() + remainTime.value
      countDown()
      
    }
    const pause = () => {
      console.log('pause>>>>>>')
      counting.value = false
      cancelAnimationFrame(requestAnimationFrameId)
    }
    const reset = () => {
      console.log('reset>>>>>>')
      pause()
      remainTime.value = props.time
      if (props.autoStart) {
        start()
      }
    }
    watch(props.time, () => {
      reset()
    }, {
      immediate: true
    })

    return {
      formatTime,
      start,
      pause,
      reset
    }
  }
}).mount('#app')

浅析一番:这里我们假装是封装的一个倒计时组件,props 是我们可以传的参数,time 表示必须要传的倒计时时间,这里应该默认0,我为了方便给了个三分钟,整个倒计时组件实现流程是,进来我们先监听 time 值得变化,然后开始倒计时,reset() start() pause()这三个方法不用多说应该一眼就明白。

重点是 countDown() 方法,这个就是能够实现倒计时的关键方法,那么 requestAnimationFrame()在递归的过程中我们是如何实现倒计时的呢?简单来说就是我们每次递归都计算一次当前时间和我们倒计时开始就计算好的结束时间的差值 来表示倒计时的剩余时间。可能这么说不直白,那么直接看代码,可以看到我们先定义了两个变量结束时间endTime 和 剩余时间remainTime,在 start 方法中我们先根据当前时间戳计算出结束时间 endTime.value = Date.now() + remainTime.value,remainTime 在初始化的时候表示我们要倒计时的时长即传入的 time,然后倒计时开始后会不断执行 countDown 方法,倒计时的剩余时间就是通过 endTime.value - Date.now() 结束事假减去当前时间计算得出,其实最关键就是在于不断获取利用当前时间为一个衡量标杆来计算。

通过上面简单的分析应该可以理解是怎么计算了吧,如果不能理解,那你得好好研究研究源码了,毕竟我感觉已经分析的比较透彻了,然后还有三个重要的工具方法 parseTimeData() parseFormat()padZero(),它们作用分别是解析时间戳返回时间对象、格式化时间和补零,不多逼逼直接上代码:

js 复制代码
// 解析毫秒为时分秒
function parseTimeData (time) {
  const SECOND = 1000;
  const MINUTE = 60 * SECOND;
  const HOUR = 60 * MINUTE;
  const DAY = 24 * HOUR;

  const days = Math.floor(time / DAY);
  const hours = Math.floor((time % DAY) / HOUR);
  const minutes = Math.floor((time % HOUR) / MINUTE);
  const seconds = Math.floor((time % MINUTE) / SECOND);
  const milliseconds = Math.floor(time % SECOND);

  return {
    days,
    hours,
    minutes,
    seconds,
    milliseconds,
  }
}
// 格式化 先看有没有天数,有则替换没有把天数化为小时,然后计算时分秒
function parseFormat (format, timeDate) {
  let { days, hours, minutes, seconds, milliseconds } = timeDate

  if (format.includes('DD')) {
    format = format.replace('DD', padZero(days))
  } else {
    hours = hours + days * 24
  }
  if (format.includes('HH')) {
    format = format.replace('HH', padZero(hours))
  } else {
    minutes = minutes + hours * 60
  }
  if (format.includes('mm')) {
    format = format.replace('mm', padZero(minutes))
  } else {
    seconds = seconds + minutes * 60
  }
  if (format.includes('ss')) {
    format = format.replace('ss', padZero(seconds))
  } else {
    ms = ms + seconds * 1000
  }
  if (format.includes('ms')) {
    format = format.replace('ms', padZero(milliseconds).slice(0, 2))
  }
  return format
}
// 补零 
function padZero(num, targetLength = 2) {
  let str = num + '';
  while (str.length < targetLength) {
    str = '0' + str;
  }
  return str;
}

这几个时间工具函数应该不用我多解释吧,是比较通用的,一般的UI库的工具方法应该也是跟这差不多少。

总结

上面就是实现一个倒计时的几种方法,其中第三种利用 requestAnimationFrame() 来实现代码还是比较完整,只是没有封装成一个完善的组件,但也是按照封装成组件的思路来写案例的,如果总结的不到位或者有错误,希望大佬们指出,完整的代码在我的GitHub仓库~


本文是笔者总结编撰,如有偏颇,欢迎留言指正,若您觉得本文对你有用,不妨点个赞~

关于作者:

GitHub

简书

掘金

相关推荐
迷雾漫步者1 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-2 小时前
验证码机制
前端·后端
燃先生._.3 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235244 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240255 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar5 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人5 小时前
前端知识补充—CSS
前端·css
GISer_Jing6 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试