前端计时器方案探索

场景

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

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

方案

Step1 获取服务器时间

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

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


  • T1:客户端,发送请求时间
  • T2:服务端,接受到请求时间
  • T3:服务端,返回响应时间
  • T4:客户端,接受响应时间
  • d/2:单程的网络传输时间

从服务端获取时间,得到的应该是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)
复制代码

只有当次计时被同步代码影响,下次循环就可以准确校正回来,不受之前循环阻塞的影响。

4. 优化点:和系统时间秒数对齐同步跳秒,整秒跳(抢购倒计时)
上述方案可以增加一点优化,第一次设置计时器间隔时间时,先进行秒数对齐。

let count = 0;
let start = new Date().getTime();
//避免递归没有退出条件出现爆栈,实际项目可以是页面退出时清空定时器
let stop = false;
//计算需对齐的秒数
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)
复制代码

除因为被阻塞时间戳出现较大偏差,剩下的执行与整秒的偏差均在1ms以内。(当次回调被阻塞仍会出现偏差,js单线程机制导致无法解决该问题。)

5. 特殊情况:浏览器后台运行
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更精确一些。

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


你可能感兴趣的:(前端计时器方案探索)