场景
最近在项目中遇到一个需求,每个会话需要显示一个计时器。后来发现一个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')
// 时间校正逻辑
});
复制代码
除了 setInterval
和 setTimeout
,还有其他计时器方案。
二、requestAnimationFrame
window.requestAnimationFrame(callback);
1.requestAnimationFrame 的回调执行间隔和浏览器刷新频率有关。浏览器一秒刷新60次,那么执行间隔是 1 / 60 = 16.7ms
;如果因为性能原因,浏览器进行降频,那么间隔时间会相应改变。
2.相对于setInterval的好处在于“踩点”。回调一定在浏览器渲染前执行,页面变化刚好可以体现出来。这是setInterval设置相同时间间隔也无法做到的。
3.但它存在和setInterval相同的问题:回调函数仍在主线程中执行,也会被阻塞,回调中也需要进行校正。浏览器后台运行时,有可能会被停掉。
三、web worker
通过新建一个线程来执行回调,这样回调函数的执行不受主线程执行队列的阻塞,比setInterval更精确一些。
计算完成后,最终仍要通知主线程执行后续操作。