前端计时器方案探索

场景

最近在项目中遇到一个需求,每个会话需要显示一个计时器。后来发现一个bug,时间一直显示0。排查后发现,在计算时间差时,使用的是当前的客户端时间 - 消息中带的服务器时间,当电脑时间比网络时间晚(小)时,差值为负,这里就会显示0。

now - msgTime,所以 now 需要修改成服务器时间。

Step1 获取服务器时间

直接获取服务器时间,会有网络延迟。这里采用NTP原理来获取比较精确的服务器时间。 NTP(Network Time Protocol) 是用来使计算机时间同步化的一种协议。下面看一下过程:

下图表示一次从请求到响应的过程:

从服务端获取时间,得到的应该是T3,所以客户端收到这个时间,会有T4 - T3(响应过程)的网络延迟。注意不是T4 - T1。

要计算出这个差值,不能直接T4 - T3,因为一个是客户端时间,一个是服务器时间。所以不能直接得到单程的网络传输时间。

可以先计算T4 - T1,结果为客户端从发出请求到接收到响应的时间,去掉服务器处理时间,可以得到双向网络传输时间,再除以2,得到 T4 - T3 的差值delay。

网络延迟 delay :delay = (T4 - T1 - (T3 - T2)) / 2

服务器时间 serverTime :serverTime = T3 + delay

客户端和服务端时间差值 gap :gap = serverTime - new Date().getTime()

之后可以用这个gap来校正客户端时间,不用每次都重新获取服务器时间,隔段时间同步一次即可。

Step2 计时器

一、setInterval

1. 多会话用同一 setInterval 计时器实现
最开始的思路是,每个会话都定义一个计时器:

mounted() {
    this.duration = now - lastMsgTime;
    setInterval(() => {
        this.duration++;
    }, 1000)

这样没必要,可以把所有会话的数据抽离出来,用同一计时器循环会话来进行计算:

var consults = [
        consultId: 1,
        lastMsgTime: 1605679800226,
        duration: 0
        consultId: 2,
        lastMsgTime: 1605679800326,
        duration: 0
setInterval(() => {
    consultTime.forEach((item) => {
        item.duration++;
}, 1000)

在回调中,对时长进行加1,但这样会存在下面的问题。
2. 新会话接收时间位于计时周期中间
接收到一个新会话时,可能距离下一次计时器到时只剩0.1s,那么仅0.1s后就会给该会话增加1s时长。所以不能在回调中直接给时长加1。

需要在计时器回调执行时,用 当前服务器时间 - 消息时间 重新计算时长。 第一种方案 基本可以实现所需功能。

setInterval(() => {
    consults.forEach((item) => {
        // 根据当前客户端时间和gap来校正
        let serverTimeNow = new Date() + gap;
        item.duration = serverTimeNow - item.lastMsgTime;
}, 1000)

但是我们都知道setInterval其实是不准确的。

3. setInterval 循环不准确
为什么不准确

  • 可以把 setInterval 分为两部分来看,一部分是定时,另一部分是回调。

  • 其中定时的部分是由浏览器的定时器触发线程执行的,不像JS主线程需要在执行队列里会受到阻塞,所以计时是比较准确的。

    上面的第一种方案,也可以同时解决setInterval不准确的问题。

    它可以保证,每次回调执行,duration是准确的;但是不能保证回调的执行间隔,导致不能稳定跳秒。数字变化时快时慢。

    针对这个问题,又有了 第二种方案 :递归调用setTimeout,每次校正下次回调的延迟时间。就是动态地去设置计时器的时间间隔。同时回调中也计算duration。

    let count = 0;
      let start = new Date().getTime();
      // 避免递归没有退出条件出现爆栈,实际项目可以是页面退出时清空定时器
      let stop = false;
      function countTime() {
        let now = new Date().getTime();
        let delay = now - (start + count * 1000); // 上次用了1.2s
        count++;
        let intervalGap = 1000 - delay; // 下次0.8s
        let timeout = intervalGap > 0 ? intervalGap : 0;
        setTimeout(() => {
          console.log(`执行时延迟了${new Date().getTime() - start - count * 1000}ms`)
          if (!stop) {
            countTime();
        }, timeout)
      setTimeout(() => {
        stop = true;
      }, 1000 * 60)
      countTime();
      // 如果延迟时间过长,能看到明显的连续变化
      setTimeout(() => {
        let i = 0;
        while (i < 1000000000) { i++ };
      }, 0)
      setTimeout(() => {
        let i = 0;
        while (i < 1000000000) { i++ };
      }, 2000)
    let firstTimeout = 1000 - start % 1000;
    function countTime() {
        let temp = new Date().getTime();
        let delay = temp - (start + count * 1000);
        count++;
        let intervalGap = 1000 - delay;
        let timeout = intervalGap > 0 ? intervalGap : 0;
        setTimeout(() => {
            console.log(`执行时间戳${new Date().getTime()}`)
            if (!stop) {
                countTime();
        }, timeout)
    setTimeout(() => {
        //将开始时间调整为整秒后再开始计时
        start = start + firstTimeout;
        countTime();
    }, firstTimeout)
    setTimeout(() => {
        stop = true;
    }, 1000 * 60)
    setTimeout(() => {
        let i = 0;
        while (i < 1000000000) { i++ };
    }, 0)
    setTimeout(() => {
        let i = 0;
        while (i < 1000000000) { i++ };
    }, 2000)
    PC端,标签页非激活态和浏览器后台运行时,会出现 setInterval 计时变慢的情况。

    let count = 0;
    let time = new Date().getTime();
    setInterval(function(){
        count++;
        let temp = new Date().getTime();
        console.log(count,temp-time)
        time = temp;
    },1000)
    

    使用下面代码在控制台进行试验,切换到其他tab等待一段时间,可以看到时间间隔出现较大偏差
    解决方式是重新打开页面时对时间进行校正。上面的 setInterval 虽然可以实现,但是需要等到下一次回调执行时。通过document的 visibilitychange事件 来监听tab的显示和隐藏,这样就可以在页面显示之后立即进行时间的校正。

    document.addEventListener('visibilitychange', () => {
        console.log('change')
        // 时间校正逻辑
    

    除了 setIntervalsetTimeout ,还有其他计时器方案。

    二、requestAnimationFrame

    window.requestAnimationFrame(callback);
    1.requestAnimationFrame 的回调执行间隔和浏览器刷新频率有关。浏览器一秒刷新60次,那么执行间隔是 1 / 60 = 16.7ms ;如果因为性能原因,浏览器进行降频,那么间隔时间会相应改变。

    2.相对于setInterval的好处在于“踩点”。回调一定在浏览器渲染前执行,页面变化刚好可以体现出来。这是setInterval设置相同时间间隔也无法做到的。

    3.但它存在和setInterval相同的问题:回调函数仍在主线程中执行,也会被阻塞,回调中也需要进行校正。浏览器后台运行时,有可能会被停掉。

    三、web worker

    通过新建一个线程来执行回调,这样回调函数的执行不受主线程执行队列的阻塞,比setInterval更精确一些。

    计算完成后,最终仍要通知主线程执行后续操作。