手把手带你撸一个精准倒计时(已封装,即开即用~)

我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

前言

事情是这样的,在我们的开发项目中之中,很可能出现需要支付或者秒杀的场景。在这个场景之下,我们难免会遇到精准计时功能。比如我们生成订单但是还没有付款的时候,会出现一个倒计时 “请在03分23秒完成付款,超时自动取消”。亦或者是 “12:00准时开售”等内容。这个功能是如何实现的,中间又有什么样的坑在等着你呢?现在,我们就来一起手撸一个准确的计时功能吧~

需求分析

虽然上述有两种情况,一种秒杀、一种倒计时。但归根结底其实是同一件事情,就是精准计时。我们的难点主要在于:

  1. 如何保证计时器的准确行。
  2. 需要可以自定义显示的格式
  3. 使用场景较多,需要独立封装

常规定时器的筛选

我们都知道js提供了两个计时器的功能,分别是 setTimeoutsetInterval
那不是正正好嘛。我需要一个计时器,哎呀,js直接提供给我两个。怎么也够用了不是。 NO,大漏特漏

JS 的单线程特性使得同步任务执行过程中出现阻塞时,任务队列中的异步任务并不能及时执行,因此浏览器并不能保证在定时器设置的时间结束后代码总是被准时执行,从而造成了倒计时的偏差。 我们来盘点一下他们可能造成的误差(假设定时器为1000ms):

setTimeoutsetInterval
现象触发事件的时间点和规定时间点偏差较大可能出现跳过其中一次事件的执行
原因事件循环机制的原因,setTimeout的事件插入其实并不是精准的时间,因为js是单线程的。规定的1000ms的延迟,其实并不是精准的1000ms,而是在当前任务结束轮到setTimeout执行的时候才进行1000ms的事件插入,而等待之前任务运行的那段时间,便是我们主要的误差值。大体原因和settimeOut一致。,setInterval会更精准,因为在执行的时候他打点的时间是标准的1000ms,不会因为其他事务的运行而延迟或者提前打点。当打点的时间段有其他事务正在运行的话,那将占据打点执行的时间,从而导致本次打点事件被略过
不适用的场景较为精准的计时(秒杀)过一秒变换一次布尔值(flag = !flag),如果略过会导致逻辑出错。

正常使用的倒计时逻辑为:

  1. 使用setTimeout递归调用的方式形成类似setinterval的语法糖。来保证不会出现略过一次执行的问题。
  2. 经过一段时间便会和当前时间进行比较,抹平差值

更精准的定时器(requestAnimationFrame)

如果只是单纯的实现这个功能, 自带的api确实是足够了。
但是!!!!

  1. 产品的要求是什么!
    精准计时: 我们要有匠心,要追求完美!!!(我绝对是自愿的,产品的🔪刀也没有架在我脖子上)
  2. 产品用在什么地方!
    支付倒计时、抢购页面: 支付倒计时还好说,晚一秒什么的没关系,但是对于某东、某淘的定时秒杀来说。这一秒的误差可能导致你别说薅羊毛了,羊屁股都看不到。

这个时候就会用到我们的 requestAnimationFrame属性了。 这个属性的详细内容可以参考 requestAnimationFrame用法 - 掘金 (juejin.cn)
我在这里简单汇总一下:

  1. 根据浏览器的帧数进行回调,正常一秒有60帧,每一帧的执行时间大概在16ms左右,在每一帧的渲染的时候进行时间的比对。可以保证回调时间的精准性。 setTimeout的缺陷被弥补。
  2. 当浏览器性能差的时候,会自动调节帧数,不会出现略过某一次事件的情况。并且保证1秒的时间内肯定会有多次的帧渲染。不会出现略过某个事件的情况。 setInterval的缺陷被弥补。

具体差异可以去看 requestAnimationFrame 执行机制探索 - 掘金 (juejin.cn)

说了半天,我们上代码吧:

  /**
   *
   * @param {number} interval  执行间隔,默认1s
   * @param {function} cb 间隔结束后执行的方法
   * @param {function} cancelCb 判断什么时候结束,返回值是布尔值,返回true则终止。
   */
  mySetInterval(cb, cancelCb, afterCancelCb, interval = 1000) {
    let timer = null;
    let pre = new Date();
    let fn = function () {
      timer = requestAnimationFrame(() => {
        let cur = new Date();
        if (cur - pre >= interval) {
          cb();
          pre = cur;
        }
        timer = requestAnimationFrame(fn);
        if (cancelCb && cancelCb()) {
          afterCancelCb();
          timer && cancelAnimationFrame(timer);
        }
      });
    };
    fn();
  }

使用方法是这样的

// 模拟倒计时的功能
let flag = false;
setTimeout(() => {
        flag = true;
}, 5000);

let nowTime = new Date().getTime()
let prevTime = 0;
console.log('开始执行');
mySetInterval(
        () => {
                prevTime = nowTime
                nowTime = new Date().getTime()
                console.log('过去了1s,具体时差:',nowTime - prevTime);
        },
        () => {
                return flag;
        },
        ()=>{
                console.log('结束的时候执行的回调');
        }
);

大体思路就是:

  1. 每一帧都会进行一次判断,看确定时间是否经过1000ms。经过的话,则执行对应回调,否则无事发生。
  2. 执行的最后进行终止帧事件的判断,当我们传入的第二个参数运行返回true的话,则代表到时间了,不需要继续执行下去了,取消对帧的监听。
  3. 为了验证我们的精准性,我们使用上述代码,在每一次经过1s的时候进行经过时间的展示。

看一下我们的效果吧:

interval.gif

我们看到这里的定时器只有第一次出现了8ms的偏差,然后就一直都是正正好。

至此,我们的定时器精准度就可以达标了。看起来是不是很完美。是不是到这里就应该结束这个文章了。
NO,大漏特漏

倒计时功能的实现

当我们拥有很精准的定时器之后,我们需要做的是什么。
我们需要进行时间的转换。需要把时间转换成 d h:m:s 这样的格式。 或者03:24这样的格式。 这块没什么可说的,直接上代码。

  // 对事件进行修改
  /**
   *
   * @param {number} second 需要转换的秒数
   * @param {*} format  转换的格式
   * @returns
   */
  formatDate(second, format = 'd h:m:s') {
    format = this.format ? this.format : format;
    second = Number(second);
    let sec = 1,
      min = 60 * sec,
      hour = 60 * min,
      day = 24 * hour;
    let formatObj = {
      d: parseInt(second / day),
      h: parseInt((second % day) / hour),
      m: parseInt((second % hour) / min),
      s: parseInt(second % min)
    };
    const time_str = format.replace(/(d|h|m|s)/g, (match) => {
      let value = formatObj[match];
      if (match && value < 10) {
        value = '0' + value;
      }
      return value || 0;
    });
    return time_str;
  }

大体思路就是:

  1. 传入需要转换的秒数,按照时间的计量单位算出对应的时分秒。
  2. 创建一个 formatObj 对象,存储对应时分秒的value值。
  3. 根据格式替换,将value值代入数据并返回。

封装成class---解耦使用

老规矩,先上完全体的代码

class countdownClass {
  /**
   *
   * @param {number} second // 需要倒计时的秒数
   * @param {*} afterCancelCb // 倒计时结束时触发的事件
   * @param {*} format // 时间格式
   */
  constructor(second, afterCancelCb, format = '') {
    this.countdownStr = this.formatDate(second,format);
    this.afterCancelCb = () => {};
    this.format = format;
    this.countDownNum = second;
    this.afterCancelCb = afterCancelCb;
  }

  // 开始倒计时
  start() {
    this.mySetInterval(
      () => {
        this.countdownStr = this.formatDate(this.countDownNum--);
      },
      () => {
        return this.countDownNum < 0;
      },
      this.afterCancelCb
    );
  }

  // 对事件进行修改
  /**
   *
   * @param {number} second 需要转换的秒数
   * @param {*} format  转换的格式
   * @returns
   */
  formatDate(second, format = 'd h:m:s') {
    format = this.format ? this.format : format;
    second = Number(second);
    let sec = 1,
      min = 60 * sec,
      hour = 60 * min,
      day = 24 * hour;
    let formatObj = {
      d: parseInt(second / day),
      h: parseInt((second % day) / hour),
      m: parseInt((second % hour) / min),
      s: parseInt(second % min)
    };
    const time_str = format.replace(/(d|h|m|s)/g, (match) => {
      let value = formatObj[match];
      if (match && value < 10) {
        value = '0' + value;
      }
      return value || 0;
    });
    return time_str;
  }

  /**
   *
   * @param {number} interval  执行间隔,默认1s
   * @param {function} cb 间隔结束后执行的方法
   * @param {function} cancelCb 判断什么时候结束,返回值是布尔值,返回true则终止。
   */
  mySetInterval(cb, cancelCb, afterCancelCb, interval = 1000) {
    let timer = null;
    let pre = new Date();
    let fn = function () {
      timer = requestAnimationFrame(() => {
        let cur = new Date();
        if (cur - pre >= interval) {
          cb();
          pre = cur;
        }
        timer = requestAnimationFrame(fn);
        if (cancelCb && cancelCb()) {
          afterCancelCb();
          timer && cancelAnimationFrame(timer);
        }
      });
    };
    fn();
  }
}

function countdown(second, afterCancelCb, format) {
  return new countdownClass(second, afterCancelCb, format);
}
export { countdown };

// 使用方法
const countdownObj = countdown(10,()=>{
  console.log('经过了10s');
},'m:s')

countdownObj.start()

大体思路就是:

  1. 构造函数的时候初始化 countdownStr 是为了保存格式化后的样式。 其他看注释即可。
  2. 初始化完成之后,并不是即时倒计时的,需要通过start方法启动。
  3. 当满足结束条件时,触发 afterCancelCb 方法,结束回调,清除定时器。

我们看一下这个的效果:

formatCountdown.gif

另一种思路

先说说现在这个思路哈。

  1. 一开始打算传参并不是倒计时的秒数,而是最终结束时间。希望通过new Date().getTime()来判断是否结束。
  2. 这种情况有一个很大的弊端,那就是获取的是本地时间,如果用户篡改了时间的话,会导致提前触发或者不触发。
  3. 在这里有两种思路可供选择
    1. 像我这样,让后端直接返回倒计时的时间。不进行本地时间的读取。
    2. 获取网络上的当地时间,不适用当地时间---获取北京当地时间的api

结尾

初来乍到,如果写的不合逻辑或者哪里乱糟糟的话,欢迎指出问题。我会慢慢改进的~~~

参考文章