灏天阁

基于 requestAnimationFrame 实现高精度毫秒级正向计时器

· Yin灏

需求分析

从图上可以看出来,核心就是一个正向计时器。通过 js 实现一个普通的正向计时器很简单,大多数想到都是使用setInterval来实现。那么还有没有其他的实现方式呢?又怎么去实现一个高精度的毫秒级正向计时器呢?

最近看了 vant4 的倒计时组件的源码,发现其并没有使用setInterval, 而是封装了requestAnimationFrame 和利用 Date.now()来处理毫秒级渲染和倒计时实现。那么能不能通过requestAnimationFrame来实现一个正向计时器呢?

先看看效果图,接下来将会一步步去实现:

计时器

体验地址: suyxh.github.io/timer-demo/

setInterval 版

首先呢,来看看使用setInterval是如何实现的。在网上看了很多文章,大多都是使用的 setInterval 去实现,大致效果如下:

从效果图上我们可以发现,最后一位始终为 0,甚至还是有些小 bug,很明显不是我们想要的。具体代码如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <input type="text" id="timetext" value="00时00分00秒" readonly />
    <br />
    <br />
    <button type="button" onclick="start()">开始</button>
    <button type="button" onclick="stop()">暂停</button>
    <button type="button" onclick="Reset()">重置</button>

    <script>
      //初始化变量
      let hour, minute, second; //时 分 秒
      hour = minute = second = 0; //初始化
      let millisecond = 0; //毫秒
      let int;
      //重置函数
      function Reset() {
        window.clearInterval(int);
        millisecond = hour = minute = second = 0;
        document.getElementById("timetext").value = "00时00分00秒000毫秒";
      }
      //开始函数
      function start() {
        int = setInterval(timer, 50); //每隔50毫秒执行一次timer函数
      }
      //计时函数
      function timer() {
        millisecond = millisecond + 50;
        if (millisecond >= 1000) {
          millisecond = 0;
          second = second + 1;
        }
        if (second >= 60) {
          second = 0;
          minute = minute + 1;
        }

        if (minute >= 60) {
          minute = 0;
          hour = hour + 1;
        }
        document.getElementById("timetext").value =
          hour + "时" + minute + "分" + second + "秒" + millisecond + "毫秒";
      }
      //暂停函数
      function stop() {
        window.clearInterval(int);
      }
    </script>
  </body>
</html>

requestAnimationFrame 版

上文中提到 vant 的 CutDown 组件,主要就是利用 Date.now() 会自己走的原理,结合 requestAnimationFrame 去做时间计算;那么正向计时器则是利用了 requestAnimationFrame 回调函数的参数去做时间计算,从而实现毫秒级的计时器。

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行,当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数 (即你的回调函数)。

注意: 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用 window.requestAnimationFrame()

MDN requestAnimationFrame

参数

  • callback 下一次重绘之前更新动画帧所调用的函数 (即上面所说的回调函数)。该回调函数会被传入DOMHighResTimeStamp参数,该参数与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻。

返回值

一个 long 整数,请求 ID,是回调列表中唯一的标识。是个非零值,没别的意义。你可以传这个值给 window.cancelAnimationFrame() 以取消回调函数。

测试版

通过 requestAnimationFrame API 可以知道,回调函数中的参数就是一个 DOMHighResTimeStamp参数,该参数与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻。

那我们直接使用该值不就可以了吗?试试看:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div id="app">hello world</div>
    <div id="status">这里显示倒计时状态</div>
    <button class="start">开始</button>

    <br />

    <script>
      const render = (time) => {
        document.querySelector("#status").innerHTML = Math.floor(time) / 1000;
      };

      const useCountUp = () => {
        let rafId;
        let endTime;

        const step = (timestamp) => {
          console.log("timestamp", timestamp);
          render(timestamp);
          rafId = window.requestAnimationFrame(step);
        };

        const start = () => {
          rafId = window.requestAnimationFrame(step);
        };

        return {
          start,
        };
      };

      const { start } = useCountUp();

      document.querySelector(".start").addEventListener("click", () => {
        start();
      });
    </script>
  </body>
</html>

效果如下:

测试版

虽然比较简陋,但是并没有出现 setInterval版 的 bug,接下来在一步步优化。

简易版

我们加上格式化时间的函数 parseTime()parseFormat(), 代码如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div id="app">hello world</div>
    <div id="status">这里显示倒计时状态</div>
    <button class="start">开始</button>

    <br />

    <script>
      /**
       * @description: 补0操作
       * @param {*} num
       * @param {*} targetLength
       * @return {*}
       */
      function padZero(num, targetLength = 2) {
        let str = num + "";

        while (str.length < targetLength) {
          str = "0" + str;
        }

        return str;
      }

      /**
       * @description: 解析时间
       * @param {*} time
       * @return {*}
       */
      function parseTime(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 {
          total: time,
          days,
          hours,
          minutes,
          seconds,
          milliseconds,
        };
      }

      /**
       * @description: 格式化时间
       * @param {*} format
       * @param {*} currentTime
       * @return {*}
       */
      function parseFormat(format, currentTime) {
        const { days } = currentTime;
        let { hours, minutes, seconds, milliseconds } = currentTime;

        if (format.includes("DD")) {
          format = format.replace("DD", padZero(days));
        } else {
          hours += days * 24;
        }

        if (format.includes("HH")) {
          format = format.replace("HH", padZero(hours));
        } else {
          minutes += hours * 60;
        }

        if (format.includes("mm")) {
          format = format.replace("mm", padZero(minutes));
        } else {
          seconds += minutes * 60;
        }

        if (format.includes("ss")) {
          format = format.replace("ss", padZero(seconds));
        } else {
          milliseconds += seconds * 1000;
        }

        if (format.includes("S")) {
          const ms = padZero(milliseconds, 3);

          if (format.includes("SSS")) {
            format = format.replace("SSS", ms);
          } else if (format.includes("SS")) {
            format = format.replace("SS", ms.slice(0, 2));
          } else {
            format = format.replace("S", ms.charAt(0));
          }
        }

        return format;
      }

      /**
       * @description: 渲染时间
       * @param {*} time
       * @return {*}
       */
      const render = (time) => {
        time = parseFormat("HH:mm:ss:SSS", parseTime(time));
        document.querySelector("#status").innerHTML = time;
      };

      const useCountUp = () => {
        let rafId;
        let endTime;

        const step = (timestamp) => {
          console.log("timestamp", timestamp);
          // render(timestamp)
          rafId = window.requestAnimationFrame(step);
        };

        const start = () => {
          rafId = window.requestAnimationFrame(step);
        };

        return {
          start,
        };
      };

      const { start } = useCountUp();

      document.querySelector(".start").addEventListener("click", () => {
        start();
      });
    </script>
  </body>
</html>

效果如下:

简易版

又看到了我们熟悉的时间格式啦, 格式化的方法也是来源于 vant 的 CutDown 组件中的格式化代码!

格式化虽然是完成了,但是怎么去停止呢?能不能支持暂停、继续、重置呢?

接下来继续完善。

进阶版

我们直接通过 window.cancelAnimationFrame() 去取消回调函数即可!在 useCountUp函数中添加一下 pause 即可!

const pause = () => {
  if (rafId) {
    window.cancelAnimationFrame(rafId);
  }
};

效果如下:

进阶版-停止Bug版_1

不少的小伙伴已经发现,停止虽然是没问题了,当再次点击开始的时候,时间怎么不对了?有瑕疵!

因为我们少算补时时间,做如下修改,添加startTimestopTimegoOn 方法:

const useCountUp = () => {
  let rafId;
  let startTime;
  let stopTime;

  const step = (timestamp) => {
    console.log("timestamp", timestamp);
    render(timestamp - startTime);
    rafId = window.requestAnimationFrame(step);
  };

  const start = () => {
    startTime = performance.now();
    rafId = window.requestAnimationFrame(step);
  };

  const pause = () => {
    stopTime = performance.now();
    if (rafId) {
      window.cancelAnimationFrame(rafId);
    }
  };

  const goOn = () => {
    startTime += performance.now() - stopTime;
    rafId = window.requestAnimationFrame(step);
  };

  return {
    start,
    pause,
    goOn,
  };
};

这里基本上已经完成了暂停和继续的功能了,但是仍是有些 bug 的,可以多次点击继续试试 🙈 。

完整版

接下来,我们来修复上述的 bug,方法:添加一个变量来表示当前计时器的状态。

在增加几个新功能:

  • 添加 重置 方法: 其实我们只需要调用一下暂停,在清理一下时间即可
  • 支持 配置:比如 正香计时的时间, 计时结束的函数

核心代码如下,其他部分代码不变:

const useCountUp = (options) => {
  let rafId,
    startTime,
    stopTime,
    curentTime,
    counting = false;

  const step = (timestamp) => {
    curentTime = timestamp - startTime;
    render(curentTime);
    options.onChange?.(curentTime);

    if (options.time) {
      if (Math.floor(curentTime) < options.time) {
        rafId = window.requestAnimationFrame(step);
      } else {
        pause();
        options.onFinish?.();
      }
    } else {
      rafId = window.requestAnimationFrame(step);
    }
  };

  const start = () => {
    // 计时中 或者 已经开始过计时想要重新开始计时,应该先点击一下 重置 再开始计时
    if (counting || curentTime) {
      return;
    }
    counting = true;
    startTime = performance.now();
    rafId = window.requestAnimationFrame(step);
  };

  const pause = () => {
    // 已经暂停后,屏蔽掉点击
    if (!counting) {
      return;
    }
    counting = false;
    stopTime = performance.now();
    if (rafId) {
      window.cancelAnimationFrame(rafId);
    }
  };

  const goOn = () => {
    // 已经在计时中,屏蔽掉点击
    if (counting) {
      return;
    }
    counting = true;
    startTime += performance.now() - stopTime;
    rafId = window.requestAnimationFrame(step);
  };

  const reset = () => {
    pause();
    curentTime = 0;
    startTime = 0;
    stopTime = 0;
    render(0);
  };

  return {
    start,
    pause,
    goOn,
    reset,
  };
};

const { start, pause, goOn, reset } = useCountUp({
  time: 3 * 1000,
  onChange: (current) => console.log("change", current),
  onFinish: () => console.log("finish"),
});

document.querySelector(".start").addEventListener("click", () => {
  start();
});

document.querySelector(".pause").addEventListener("click", () => {
  pause();
});

document.querySelector(".goOn").addEventListener("click", () => {
  goOn();
});

document.querySelector(".reset").addEventListener("click", () => {
  reset();
});

到此基本上就是实现了一个毫秒级的正向计时器!

vue 版

只是对 js 的逻辑进行了一些封装

代码:github.com/SuYxh/timer…

预览:suyxh.github.io/timer-demo/

总结

正向毫秒级计时器主要就是利用了window.requestAnimationFrame的回调函数的参数为DOMHighResTimeStamp,且与performance.now()的返回值相同;在实现暂停、继续时,需要计算一下补时时间。

- Book Lists -