让setTimeout准时执行
今天翻到一篇文章,里面有这么一个问题:

了解JS的时间循环就知道,setTimeout并不会准时执行,他只负责在计时完成后将回调函数放入任务队列,不会执行回调函数。
例子:
let start = new Date();
setTimeout(()=>{
console.log('执行回调')
let end = new Date();
console.log(end - start)
},1000)
for (let index = 0; index < 10000; index++) {
console.log(index)
}上面的代码理想的结果是在一秒后执行回调,但实际上过了一秒后,setTimeout将回调放入任务队列,但此时主线程上的for循环还未结束,setTimeout中的回调只有等待for循环结束后才执行。
看到这个问题的时候,第一时间是想到用requestAnimationFrame,该方法会在浏览器重绘的时候执行回调,执行频率由显示器的刷新率决定。我的显示器是120刷新率,那么执行的速度就是8ms左右。
下面的代码使用requestAnimationFrame实现计时器功能,每次执行后打印出理想执行时间和实际执行时间的差值。
function timer(speed){
let start = new Date(),count = 1;
function fun(){
let end = new Date()
if(end - start >= speed * count){
console.log(`diff:${end - start - (speed * count)}`);
count++;
}
requestAnimationFrame(fun)
}
requestAnimationFrame(fun)
}
timer(1000)
console.log('执行完毕')结果:大致符合预期,误差较小。
diff:8 timer.js:46:21
执行完毕 timer.js:55:9
diff:7 2 timer.js:46:21
diff:8 timer.js:46:21
diff:7 3 timer.js:46:21
diff:0 2 timer.js:46:21
diff:8 2 timer.js:46:21
diff:0 3 timer.js:46:21
diff:1 2 timer.js:46:21
diff:0 2 timer.js:46:21
diff:1 4 timer.js:46:21
diff:2 6 timer.js:46:21
diff:3 6 timer.js:46:21再看看使用setTimeou实现定时器:
function timer(speed){
const start = new Date();
let count = 0;
function fun(){
count ++;
let end = new Date()
console.log(`diff:${end - start - (speed * count)}`);
setTimeout(fun,speed)
}
setTimeout(fun,speed)
}
timer(1000)结果:每次执行回调后,与理想执行时间的误差都会增大。
diff:7
diff:22
diff:27
diff:43
diff:56
diff:69
diff:75
diff:92
diff:106
diff:116
diff:119
...改进一下上面的代码,让定时器对误差的时间进行补偿:
function timer(speed){
const start = new Date();
let count = 0;
function fun(){
count ++;
let end = new Date(),diff = end - start - (speed * count);
console.log(`diff:${diff}`);
setTimeout(fun,speed - diff)
}
setTimeout(fun,speed)
}
timer(1000)结果:可以看到误差被控制在了一个稳定的范围,并且能够防止增长。
diff:5
diff:2
diff:15
diff:13
diff:12
diff:8
diff:3
diff:2
diff:13
diff:12
diff:7
diff:8
diff:3
diff:15再来试试使用while作为定时器:
function timer(speed) {
function fun(){
const start = new Date();
while(true) {
let end = new Date()
if(end - start >= speed){
console.log(`diff:${end - start - speed}`);
return fun()
}
}
}
fun()
}
timer(1000)
console.log('执行完毕')结果:可以看到误差为零,但是while会对线程造成阻塞,后面的代码将无法执行,使用场景受限较大。
diff:0
diff:0
diff:0
diff:0
diff:0
diff:0
diff:0
...那么把while放入另一个线程,不就能解决上面的问题了吗
// main.js
function timer(speed){
let worker = new Worker('work.js'),count = 0;
worker.postMessage({msg:'start',speed})
worker.onmessage = function(e){
count ++;
worker.postMessage({msg:'reStart',speed})
}
}
timer(1000)
console.log('执行完毕')
// work.js
onmessage = function(e){
let {speed} = e.data;
let start = new Date()
while(true) {
let end = new Date()
if(end - start >= speed){
console.log(`diff:${end - start - speed}`);
this.postMessage('done')
return;
}
}
}执行结果:可以看到,误差与理想非常接近。
diff:0 3 work.js:7:21
执行完毕 timer.js:39:9
diff:0 43 work.js:7:21
diff:32 work.js:7:21
diff:0 43 work.js:7:21上面的操作是在worker线程中执行,woker线程中不能操作主线程中的数据,并且能操作的BOM对象受限,还需要改进一下,将代码放入主线程中执行。
// main.js
function timer(speed){
let worker = new Worker('work.js'),count = 0,start = new Date()
worker.postMessage({msg:'start',speed})
worker.onmessage = function(e){
count ++;
let end = new Date()
console.log(`diff:${end - start - (speed * count)}`);
worker.postMessage({msg:'reStart',speed})
}
}
timer(1000)
console.log('执行完毕')
// work.js
onmessage = function(e){
let start = new Date()
let {speed} = e.data;
while(true) {
let end = new Date()
if(end - start >= speed){
this.postMessage('done')
return;
}
}
}结果:还是存在误差,但误差增加的速度慢了很多。个人的猜想:因为JS消息机制的回调属于微任务,对比setTimeout中间少了渲染过程,比起setTimeout更早执行,所以误差增加的速度慢。
执行完毕 timer.js:44:9
diff:20 23 timer.js:36:17
diff:21 6 timer.js:36:17
diff:22 8 timer.js:36:17
diff:23 timer.js:36:17
diff:24 3 timer.js:36:17
diff:25 timer.js:36:17
diff:27 2 timer.js:36:17
diff:53 3 timer.js:36:17
diff:97总结:上面的方案中,使用while作为定时器的精准度最高,并且能够解决第一段代码中出现的问题,但是使用场景受限,可以配合web worker使用。至于requestAnimationFra,我测试的结果跟看的文章里有点不一样,可能是屏幕刷新率导致的差异,并且上面的例子还存在一些问题,需要改进,这些还需要找大佬讨论一下。
参考文章:
https://jishuin.proginn.com/p/763bfbd57fd7
http://www.ruanyifeng.com/blog/2018/07/web-worker.html