foreword - 前言
最近业务需求中涉及到一个倒计时的场景,因为该场景对时间的误差要求不是很高,所以用 setTimeout 做了简单的实现。
但是如果之后遇到的场景需要极高的精确度呢?
- 答题倒计时?
- 到点秒杀抢购?
正当我准备网上查阅相关资料时,突然想起来我曾经在知乎上提了一个类似的问题:(之前一时兴起想要好好研究一下,后面因为懒就没有再管了,以至于现在又在重复造轮子。。。)
链接地址:www.zhihu.com/question/29…
基于网上查阅的各种资料,并结合上述帖子中大佬们的回答,我有了一些新的思考,以下我将通过这几个方面来 “分析/解答” 可能涉及到的问题、思路、个人觉得比较合适的解法:
- setTimeout 和 setInterval 存在什么样的问题?
- requestAnimationFrame 可以解决问题吗?
- “当前时间” 真的是 “当前时间” 吗?
- 前端视角 & 后端视角,如何取舍?
- 还能继续优化吗?
- 实现一个简单的 chrome extension:用于在当前 active tab 下展示还剩多少时间(假设你设置了一个 60 分钟的倒计时)
- 归纳 & 总结
setTimeout Or setInterval
对于 “倒计时” 的场景,可能我们一开始想到的会是 setInterval ,因为直接通过这样的几条语句不就实现了吗?
let restTime = 60 * 60 * 1000 // 假设倒计时 1 小时
/**
* 倒计时操作
*/
function countdownOperate() {
restTime -= 1000
}
setInterval(countdownOperate, 1000)
上面的代码看起来是那么简洁又清晰,但是如果遇到这种情况呢?
let count = 0
let startTime = new Date()
let preTime = new Date()
let nowTime
/**
* 间隔回调函数
*/
function intervalHandler() {
if (count === 20) {
return
}
if (count === 0) {
for (let i = 0; i < 1000000000; i++) {}
}
nowTime = new Date()
console.log(
`How many milliseconds have passed since the previous time: ${nowTime - preTime}`,
`How many milliseconds have passed since the start time: ${nowTime - startTime}`,
`value of variable 'count' is: ${count}`,
`real value of variable 'count' is: ${Math.floor((new Date() - startTime) / 100) - 1}`)
preTime = nowTime
count++
}
setInterval(intervalHandler, 100)
我们在控制台上执行,结果如下:
上面的数据不够直观,我们通过 table 来罗列一下:
| nowTime - previousTime | nowTime - startTime | nowCount | realCount | |
|---|---|---|---|---|
| 01 | 860 | 860 | 0 | 7 |
| 02 | 0 | 860 | 1 | 7 |
| 03 | 42 | 902 | 2 | 8 |
| 04 | 102 | 1004 | 3 | 9 |
| 05 | 96 | 1100 | 4 | 10 |
| 06 | 101 | 1201 | 5 | 11 |
| 07 | 101 | 1302 | 6 | 12 |
| 08 | 102 | 1404 | 7 | 13 |
| 09 | 100 | 1504 | 8 | 14 |
| 10 | 96 | 1600 | 9 | 15 |
| 11 | 102 | 1702 | 10 | 16 |
| 12 | 102 | 1804 | 11 | 17 |
| 13 | 97 | 1901 | 12 | 18 |
| 14 | 100 | 2001 | 13 | 19 |
| 15 | 101 | 2102 | 14 | 20 |
| 16 | 99 | 2201 | 15 | 21 |
| 17 | 100 | 2301 | 16 | 22 |
| 18 | 99 | 2400 | 17 | 23 |
| 19 | 101 | 2501 | 18 | 24 |
| 20 | 100 | 2601 | 19 | 25 |
看上面的数据,从第 4 次回调开始,时间间隔都比较稳定,在 100 ms 左右,但是真实的数据一直比预期小 6 (也就是慢了 6 次回调),为什么会这样呢?我们结合以下的示意图来分析一下:
第一次回调在 100 ms 被推入进 queue 当中,因为执行调用队列当前是空闲的,所以立马被取出执行,第一次执行运算比较繁琐,导致消耗 860ms 。Js 是单线程语言,虽然第二次回调在 200 ms 被推入 queue 中,但 callback 01 仍然在执行中,所以需要等待其执行完。
这也是为什么后面一直是相差 6 的原因。
除此之外,我们发现 callback 03 - 08 均被跳过,这是为什么呢?
当使用 setInterval 时,仅当队列中没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。
引用自:segmentfault.com/a/119000001… —— “从setTimeout-setInterval看JS线程”
综上,setInterval 存在以下几个问题:
- 单线程影响,setInterval 的回调不确定什么时候能够执行,其中的时间间隔仅仅是将 task 推入任务队列的时间,而不是真正执行的时间。(对于 setTimeout 来说实际也是一样的)
- 假如当前任务队列中已存在等待中的 task (该 task 同样是被当前 setInterval 创建),则这时触发的其他 task 将会被跳过。
冬沐:抛开上面的第一点来看,我们使用 setTimeout 就可以了。
吃瓜群众:
冬沐:不信?我们直接看看文档好了:developer.mozilla.org/en-US/docs/…
这是个啥?。。。
从文档中我们看到了以下几个会影响执行的因素:
- 如果嵌套调用 5 次,会强制最低 4 ms 的延迟。
- 对于浏览器的 inactive tabs(非激活下的 tab)会有一个最小的时间延迟,这个延迟取决于浏览器厂商的实现。
- Firefox 对其识别为跟踪脚本的脚本实施额外的限制,在前台运行时,最小延迟仍然是 4ms,但是,在后台选项卡中,限制最小延迟为 10,000 毫秒或 10 秒,在文档首次加载 30 秒后生效。
- Firefox 中,对于浏览器插件,setTimeout 也不能按照预期执行,为此,开发者应该使用 alarms API(developer.mozilla.org/en-US/docs/…)
requestAnimationFrame
requestAnimationFrame(callback)
requestAnimationFrame 会在浏览器下次重绘之前调用你设置的 callback ,也就是按照屏幕刷新频率(一般为 60fps)来走,即 1/60 s(16.6 ms)。
利用这个特性,我们可以在回调中添加一个 counter 用于计数(默认为 0 ),每次执行加1,直到 60 时重置为 0 并将秒数 -1 。
大致的代码可能是这样的:
let restTime = 60 * 60 * 1000
let counter = 0
function step () {
counter++
if (counter === 60) {
counter = 0
restTime -= 1000
}
if (restTime !== 0) {
window.requestAnimationFrame(step)
}
}
window.requestAnimationFrame(step)
冬沐:代码就这么简单嘛。
吃瓜群众:
冬沐:有啥问题吗?
吃瓜群众:
- 屏幕刷新频率都是 60 fps 吗?
- callback 准备执行时,发现当前线程中还有任务在执行咋办?
冬沐:
我们重新捋一捋,只要在每次执行回调的时候计算一下误差不就行了吗?
let restTime = 60 * 60 * 1000
const totalTime = restTime
const startTime = new Date()
function step () {
const currentRestTime = totalTime - (new Date() - startTime)
restTime = currentRestTime <= 0 ? 0 : currentRestTime
if (restTime > 0) {
window.requestAnimationFrame(step)
}
}
window.requestAnimationFrame(step)
吃瓜群众:这样一来,一秒钟你不是得计算几十次。。。我还不如用 setTimeout 来做:比如我将时间间隔设置成 500 ms ,并在每次调用的时候都计算一次误差。
冬沐:抛开一些异常耗时的调用场景,setTimeout 确实看起来比 requestAnimationFrame “更节省性能”。
但,现在的设备,多调几次怕个锤子。另外,别忘了 setTimeout 的缺点:inactive tab 下仍然会执行。而 requestAnimationFrame 则完全不同,当页面处于未激活状态,该页面的屏幕刷新任务将会被系统暂停,并且requestAnimationFrame callback 其 “即时性、流畅度” 会更好(为了更直观地描述,我们再给出一些配图)。
吃瓜群众:
“当前时间” 真的是 “当前时间” 吗?
倒计时这个场景,我们第一反应都是基于 new Date() 作为我们的起始点,这个 “起始点” 准确吗?
new Date() 是取自当前设备的系统时间,所以如果我们手动更改了系统时间,那这个 “起始点” 就不准确了。
既然当前设备的系统时间不可靠,那我直接用后端服务器的时间不就好了?
“确实,为了防止用户手动更改系统时间,统一用后端的时间不就行了。” 理想很美好,但,现实很残酷,因为后端数据返回到前端是有耗时成本在的,所以前端拿到的时间比实际时间慢了一拍。
针对这个问题,有办法解决吗?比如是否有方法获取接口请求的耗时?
目前暂时没有找到相关的方法获取请求耗时,虽然我们在浏览器的开发者工具中是能看到耗时的。。。
前端视角 & 后端视角,如何取舍?
前面我们分析了 “当前时间” 的准确性,无论从前端,还是后端,都会存在一定的误差:
- 后端时间的误差取决于请求的耗时,网络信号慢则误差大,信号强则误差小。
- 前端时间的误差取决于用户自定义的系统时间。
所以,两端的误差波动都是无法确定的,但前端的波动明显会大很多(系统时间是可以随便设定的)。
吃瓜群众:听你巴拉巴拉半天了,你就说到底怎么办吧。
冬沐:别急别急,这不是得了解下前因后果嘛。
方法肯定是有的,不过我们还是先确定用哪个端的时间吧。
个人觉得可以采取一定的策略:
- 绝大部份用户都不会闲着没事干去改自己设备的系统时间,所以我们可以优先采用前端时间。
- 为了防止用户更改系统时间,我们也要求后端将后端时间返回,这时我们可以设置一个时间差,即如果 “前端时间 - 后端时间 <= 1000”,则我们认定前端时间是 ok 的,这时我们用前端时间即可,反之我们使用后端时间。
- 大多数场景,上面两步已经可以满足我们的需求了,但是如果是类似 “线上答题倒计时”、“秒杀抢优惠券” 等这些场景呢?咋办呢?
-
- 基于上面的两个策略,像上面 “线上答题倒计时” 这种场景,误差基本可以控制在几秒内(这类倒计时多几秒少几秒其实没有多大毛病)。
- 而 “秒杀抢优惠券的场景” ,我们在领券的时候会先向后端发送一个请求,后端逻辑如果判断还未到时间,可以一直 pending 状态,直到时间到了再返回,如果到了,则直接返回即可。(也就是加了一个后端校验的逻辑)
还能继续优化吗?
经过上面的分析,实际上我们已经基本解决了所有的问题,先捋一捋还剩下什么问题:
- 不得不接受的一个事实:requestAnimationFrame 调用频次确实还是高了点。
为了解决上面的问题,其实还是有思路的:用 web worker。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>倒计时</title>
</head>
<body>
<h2>倒计时:</h2>
<p id="countdownBox"></p>
<script src="./index2.js"></script>
</body>
</html>
window.onload = function () {
const countdownBox = document.getElementById('countdownBox')
const workerInstance = new Worker('./worker2.js')
workerInstance.addEventListener('message', function (workerMessage) {
const currentTime = new Date()
const timeMsg = `${currentTime.getHours()}时${currentTime.getMinutes()}分${currentTime.getSeconds()}秒 ${currentTime.getMilliseconds()}毫秒`
countdownBox.innerText = `${workerMessage.data}\n${timeMsg}`
})
workerInstance.postMessage('start')
}
self.requestAnimationFrameInstance = null
self.count = 0
self.timeAction = function () {
self.count++
if (self.count === 60) {
const currentTime = new Date()
const timeMsg = `${currentTime.getHours()}时${currentTime.getMinutes()}分${currentTime.getSeconds()}秒 ${currentTime.getMilliseconds()}毫秒`
self.postMessage(timeMsg)
self.count = 0
}
cancelAnimationFrame(self.requestAnimationFrameInstance)
self.requestAnimationFrameInstance = requestAnimationFrame(self.timeAction)
}
self.addEventListener('message', function (webMessage) {
if (webMessage.data === 'start') {
self.requestAnimationFrameInstance = requestAnimationFrame(self.timeAction)
}
})
效果如下:
(上面的gif图片比实际上要慢一些)
上图可知:
- web worker 误差基本处于 0-1 ms ,偶尔会出现 2 ms 的误差。
吃瓜群众:
这不一样的吗,requestAnimationFrame 还不是执行了很多次。。。
冬沐:别急嘛,我们简单捋一捋。之所以觉得执行太多次不好,不就是因为怕影响性能吗?我们开了一个线程隔离一下,这部分逻辑的执行对其他 js 任务的影响面也就大大降低了。
这就好比,之前一个人 A “要剥花生,然后将花生仁装入篓子里”,现在有两个人一起 A 和 B ,“B 负责剥花生,A 只要将花生仁放到篓子里”。
吃瓜群众:
哦哦哦
冬沐: 当然,上面我们做了进一步优化,只有过了一秒,才会重新渲染页面的时间。(这里只是为了简单演示,所以以 “60fps” 的标准执行)
我们一定要使用 requestAnimationFrame 吗?答案肯定是否定的。对于日常的开发场景中,我们在 worker 中使用 setTimeout、setInterval 其实也是一个不错的选择 —— 因为 worker 中只有我们轮询的逻辑,像 setInterval 不会再出现被其他 js 任务阻塞导致 callback 没有被推入到 taskqueue 中的问题。 除此之外,为了保证精度,其实也很简单:
- 正常情况下(页面 tab 处于 active 状态下,无高耗时的 js 任务存在),setTimeout、setInterval 基本不存在误差的,有误差也非常小。
- 页面 tab 处于 inactive 状态下,多多少少会有 delay 存在,setTimeout、setInterval 的回调我们不去关心也没什么问题,毕竟我们现在又看不到页面。
- 考虑到 “ inactive => active ”、“有高耗时 js 任务阻塞” 这两种情况,为了保证精确度,我们需要在 callback 中做一下时间的精度调整。
其实,文章到现在,还是有一个问题一直没有得到解决 —— 肯定会有一些高耗时任务会阻塞我们的 “计时任务” ,导致 “卡死” 现象。
对于这些高耗时的任务,也可以单独塞到一个 worker 线程里面,这样对我们的 “计时任务” 阻塞影响就会大大降低了。但这种方式主要靠开发者手动去控制,比较难维护。
实现一个简单的倒计时插件
chrome extension 开发指南:developer.chrome.com/docs/extens…
代码比较简单,git 仓库地址:github.com/TodoHacker/…
效果图如下:(点击插件图标会出现一个弹框,点击弹框中的按钮后,会在当前 active tab 页下添加倒计时展示)
last - 最后
“计时” 这类场景虽然平时不经常碰到,逻辑上也不是很复杂,但仔细研究下来,还是收获了很多,不是吗?
所以,再小的事物,其实都能延伸出很多知识、技巧,只要你仔细去发现。另外,在业务比较繁忙的状态下,通过本篇文章中采取的模式 —— “业务中思考、学习、成长”,未尝不是一种快速提升自己的方式。
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)