美文网首页
前端计时器方案探索

前端计时器方案探索

作者: WEB前端含光 | 来源:发表于2020-11-20 14:05 被阅读0次

    场景

    最近在项目中遇到一个需求,每个会话需要显示一个计时器。后来发现一个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更精确一些。

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


    相关文章

      网友评论

          本文标题:前端计时器方案探索

          本文链接:https://www.haomeiwen.com/subject/hgpdiktx.html