前端如何实现一个倒计时组件?

8,983 阅读4分钟

需求

倒计时这种需求非常常见。在我接触的项目中,已经做过N个倒计时的需求。常见的场景有电商项目中的秒杀抢购活动倒计时,短信验证码等。

现在再一次碰到了倒计时的需求,是一个答题倒计时的场景。具体效果如图。

思路

要实现倒计时,就需要来回顾一下JavaScript中的定时器相关知识。

  1. setInterval: 每间隔N秒执行一次回调函数。
  2. setTimeout: N秒后执行回调函数。
  3. setImmediate: 非标准的API,目前尚未被正式采纳,用于执行耗时的运算。执行完其它代码,就会立即执行。
  4. requestAnimationFrame: 类似于setInterval,用于动画。采用系统时间,保证时间的准确性,但无法指定间隔时间。

从上面可以看出,能指定某个时间触发的定时器,仅有setIntervalsetTimeout,这种场景下,我选择setInterval。当然也可以对setTimeout进行递归,不断重新创建和销毁的方式,达到同样的效果。

首先我们明白,因为JavaScript是单线程的,在事件循环过程中,当前宏观任务队列中的微观任务会阻塞下一个宏观任务队列中任务的执行。所以会造成一种现象,定时器中的真实执行时间并不会精准的按照第2个参数所设定的数值执行。比如设置1000毫秒,如果到了1000毫秒,主线程被其他任务所占用了,那么就会等待其它任务的执行,等其它任务执行完毕后,才会执行定时器的回调函数。

也就是说,如下代码代表的意思不是1秒后执行,而是最快1秒后执行。

setTimeout(() => {console.log('我是定时器!')},1000);

你可以尝试执行如下代码,会发现定时器的执行时间应该超过了1秒钟,如果正常执行,你可以从循环条件后面加个0。电脑配置很差的就不要试了。

setTimeout(() => {console.log('我是定时器!')}, 1000);
for (let i = 0; i<1000000000; i++) {}

碰到这种循环或者递归代码时,回调函数的执行时间会根据不同的电脑运算速度决定。如果你的电脑配置够强,比如小型机,高性能服务器等,能够在1秒以内执行完逻辑,那么就不会影响定时器的正常执行。

要想做到时间相对准确,就必须解决这个问题,办法有很多种,最常见也最有效的办法,是在当前定时器的回调函数中校验误差并调整下一次定时器的发生时间,达到平均1秒的效果。

掘金上面有一篇介绍这种做法的文章,可供参考: juejin.cn/post/684490…

但是,如果在浏览器中单独打开一个空白页面,在控制台中运行如下代码,观察每次的输出,发现还是足够准确的,误差都在1毫秒以内。

setInterval(() => { console.log(new Date().getTime()); }, 1000);

这是不是就意味着我们可以直接这么写代码呢?如果页面足够简单,没有其它的监听事件,不会发生频繁的交互操作,这么写仍然会出问题,当页面休眠时,定时器就会停止。如果页面存在很多监听事件或者交互操作,就可能会发生跳秒的现象。特别是在单页面应用中更应该注意,像reactvue框架中,diff算法和DOM渲染都在一个主线程中执行。

为了最大程度的避免这个问题,可以采用web worker来开启一个后台线程单独运行定时器,但是这样也只是能够保证计时器的运行间隔是精准的,并不能保证UI渲染是精准的。

目前web worker支持度已经非常好了,基本上不需要担心兼容性问题。

使用web worker的唯一方式就是通过new Worker('../xx.js')的方式使用。构造参数是独立线程js文件的路径。在react框架中,只能引用public目录下的文件,才能保证打包后路径是正确的。或者修改webpack配置,但这样做并不是很优雅。

虽然使用web worker的方式只有一种,但是我们可以在遵循正常使用规则下,用一种更优雅的方式来实现。通过Blob对象和URL.createObjectURL方法来创建一个虚拟的js文件。

具体实现代码如下:

// worker.js
export default class WebWorker {
  constructor(worker) {
    const code = worker.toString();
    const blob = new Blob([`(${code})()`]);
    return new Worker(URL.createObjectURL(blob));
  }
}

这个类接受一个构造参数,这个构造参数是一个函数,通过Blob创建这个虚拟的js文件。再通过URL.createObjectURL方法为Blob对象创建一个链接。最终作为Worker的构造参数,来创建一个worker实例。

使用它也比较简单。

// CountDownTimer.jsx
import WebWorker from "../../utils/worker";

let work = function() {
  let timer = null;
  this.onmessage = e => {
    const { endTime, state } = e.data;
    if (state === "stop") {
      if (timer) {
        clearInterval(timer);
        timer = null;
      }
      return;
    } else if (state === "start") {
      let interval = 1000;
      if (!timer) {
        timer = setInterval(() => {
          this.postMessage(endTime - new Date().getTime());
        }, interval);
      }
    }
  };
};

let worker = new WebWorker(work); 

这样就可以发送给worker一个开始指令。

worker.postMessage({
      state: "start",
      endTime: Number.parseInt(endTime, 10)
    });

然后监听worker的响应。

  worker.onmessage = e => {
    if (e.data <= 0) {
      worker.postMessage({ state: "stop" });
      return;
    }
    setTime(relativeTime(e.data));
  };

这里仍然是一个无法解决的问题。由于DOM的绘制是在主线程内完成的,web worker不能处理DOM,虽然可以保证定时器的间隔精准度,但无法保证主线程更新UI的精准度。如果主线程在处理其它事情,onmessage不能及时响应,UI仍然会发生卡顿。

所以,文章写到这,关于定时器相关的知识差不多就讲完了。最后你肯定还有个问题没明白,既然onmessage也会被阻塞,也会导致UI更新不及时,那和直接在主线程中写setInterval又有什么区别呢?为什么要这么麻烦的写到web worker中?

在看答案之前,你不妨先思考一下。

最大的区别在于:setInterval在被阻塞一次后,后面的所有执行时间间隔都会被打乱,如果被阻塞N次,时间间隔就会越来越乱。web worker的作用就是即使被阻塞N次,也能保证定时器中的函数执行次数是按照预期执行的。

为了避免这种情况可以按照上面提到的那种不断进行时间纠偏、重新创建setTimeout的方式来实现。web worker的方式是一种新的实现思路,其优势在于无论主线程如何阻塞,定时器的回调函数执行次数和频率是不会受到影响的。

具体实现

组件的用法:

<CountDownTimer endTime={1569834068266} onEnd={this.onEndHandler.bind(this)} />

组件接口设计如下:

  1. endTime 结束的时间戳,13位字符串
  2. onEnd 到达结束时间所要执行的回调函数

组件代码

CountDownTimer.jsx

import React from "react";
import styled from "styled-components";
import WebWorker from "../../utils/worker";

let work = function() {
  let timer = null;
  this.onmessage = e => {
    const { endTime, state } = e.data;
    if (state === "stop") {
      if (timer) {
        clearInterval(timer);
        timer = null;
      }
      return;
    } else if (state === "start") {
      let interval = 1000;
      if (!timer) {
        timer = setInterval(() => {
          this.postMessage(endTime - new Date().getTime());
        }, interval);
      }
    }
  };
};

let worker = new WebWorker(work);

/**
 * 计算相对时间字符串
 * @param {number} time 13位时间戳
 */
function relativeTime(time) {
  if (time <= 0) {
    return "00:00";
  }
  const minute = Number.parseInt(time / 1000 / 60, 10);
  const second = Number.parseInt((time / 1000) % 60, 10);
  return `${minute > 9 ? minute : "0" + minute}:${
    second > 9 ? second : "0" + second
  }`;
}

export default function CountDownTimer({
  endTime,
  onEnd = Function.prototype
}) {
  const initTime = relativeTime(endTime - new Date().getTime());
  let [time, setTime] = React.useState(initTime);

  worker.onmessage = e => {
    if (e.data <= 0) {
      worker.postMessage({ state: "stop" });
      return;
    }
    setTime(relativeTime(e.data));
  };

  React.useEffect(() => {
    worker.postMessage({
      state: "start",
      endTime: Number.parseInt(endTime, 10)
    });
    return function() {
      worker.postMessage({ state: "stop" });
    };
  }, [endTime]);

  React.useEffect(() => {
    if (time === "00:00") {
      return function() {
        onEnd();
        worker.postMessage({ state: "stop" });
      };
    }
  }, [time, endTime, onEnd]);

  const Time = styled.span`
    font-size: 1.6rem;
    font-weight: 700;
    vertical-align: middle;
  `;
  return (
    <div>
      倒计时:<Time>{time}</Time>
    </div>
  );
}

worker.js

export default class WebWorker {
  constructor(worker) {
    const code = worker.toString();
    const blob = new Blob([`(${code})()`]);
    return new Worker(URL.createObjectURL(blob));
  }
}