前端计时器方案探索

场景

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

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


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容