一个 “倒计时” 引发的思考

520 阅读10分钟

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 - previousTimenowTime - startTimenowCountrealCount
0186086007
02086017
034290228
04102100439
05961100410
061011201511
071011302612
081021404713
091001504814
10961600915
1110217021016
1210218041117
139719011218
1410020011319
1510121021420
169922011521
1710023011622
189924001723
1910125011824
2010026011925

看上面的数据,从第 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/…

这是个啥?。。。

我们继续,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)

冬沐:代码就这么简单嘛。

吃瓜群众

冬沐:有啥问题吗?

吃瓜群众

  1. 屏幕刷新频率都是 60 fps 吗?
  2. 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 许可证)