JavaScript 事件循环竟还能这样玩!

JavaScript 是一种单线程的编程语言,这意味着它一次只能执行一个任务。为了能够处理异步操作,JavaScript 使用了一种称为事件循环(Event Loop)的机制。本文将深入探讨事件循环的工作原理,并展示如何基于这一原理实现一个更为准确的 setTimeout、setInterval
什么是事件循环?
事件循环是 JavaScript 运行时环境中处理异步操作的核心机制。它允许 JavaScript 在执行任务时不会阻塞主线程,从而实现非阻塞 I/O 操作。
为了理解事件循环,首先需要了解以下几个关键概念:
调用栈(Call Stack):
调用栈是一个 LIFO(后进先出)结构,用于存储当前执行的函数调用。当一个函数被调用时,它会被推入调用栈,当函数执行完毕后,它会从调用栈中弹出。
任务队列(Task Queue):
任务队列存储了所有等待执行的任务,这些任务通常是异步操作的回调函数,例如
setTimeout、setInterval、I/O 操作等。当调用栈为空时,事件循环会从任务队列中取出一个任务并将其推入调用栈执行。微任务队列(Microtask Queue):
微任务队列存储了所有等待执行的微任务,这些微任务通常是
Promise的回调函数、MutationObserver等。微任务队列的优先级高于任务队列,当调用栈为空时,事件循环会优先处理微任务队列中的所有任务,然后再处理任务队列中的任务。
事件循环的工作原理
事件循环的工作原理可以简化为以下几个步骤:
执行调用栈中的任务:
JavaScript 引擎会从调用栈中取出并执行最顶层的任务,直到调用栈为空。
处理微任务队列:
当调用栈为空时,事件循环会检查微任务队列。如果微任务队列中有任务,会依次取出并执行,直到微任务队列为空。
处理任务队列:
当调用栈和微任务队列都为空时,事件循环会检查任务队列。如果任务队列中有任务,会取出一个任务并将其推入调用栈执行。
重复上述步骤:
事件循环会不断重复上述步骤,确保所有任务都能被及时处理。
示例
以下是一个简单的示例,展示事件循环的工作原理:
输出结果:
解释如下:
同步任务:首先执行同步任务,
console.log('Start')和console.log('End')被推入调用栈并立即执行。微任务:
Promise.resolve().then创建了一个微任务,该微任务被推入微任务队列。任务:
setTimeout创建了一个任务,该任务被推入任务队列。处理微任务:同步任务执行完毕后,调用栈为空,事件循环检查微任务队列并执行所有微任务,因此输出
Promise callback。处理任务:微任务队列为空后,事件循环检查任务队列并执行所有任务,因此输出
Timeout callback。
为什么 setTimeout 不准确?
JavaScript 中的 setTimeout 和 setInterval 是基于事件循环和任务队列的,因此它们的执行时间可能会受到以下几个因素的影响,从而导致不准确:
事件循环机制:
JavaScript 是单线程的,所有代码的执行都是在一个事件循环中进行的。事件循环会依次处理任务队列中的任务。
如果前面的任务执行时间较长,或者任务队列中有很多任务,定时器的回调函数就会被延迟执行。
任务队列的优先级:
浏览器的任务队列有不同的优先级,例如用户交互事件、渲染更新等任务的优先级通常高于
setTimeout和setInterval。这意味着即使定时器到期,如果有其他高优先级任务在执行,定时器的回调函数也会被延迟执行。
JavaScript 引擎的限制:
JavaScript 引擎通常会对最小时间间隔进行限制。例如,在浏览器环境中,嵌套的
setTimeout调用的最小时间间隔通常是 4 毫秒。这意味着即使你设置了一个非常短的时间间隔,实际执行的时间间隔也可能会比你设置的时间更长。
系统性能和负载:
系统的性能和当前负载也会影响定时器的准确性。如果系统负载较高,任务的执行时间可能会被进一步延迟。
为了更直观地理解这一点,可以考虑以下示例:
在这个示例中,setTimeout 的回调函数设置为 1 秒后执行,但由于在主线程上有一个耗时 2 秒的任务,导致定时器的回调函数被延迟到这个任务执行完毕后才执行。
因此,实际执行时间会远远超过 1 秒。
实现一个更准确的 setTimeout
为了实现更精确的定时器,可以结合 Date 对象和递归的 setTimeout 来实现更高精度的定时器。
以下是一个实现准时 setTimeout 的例子:
在这个实现中:
获取当前时间
start。在
loop函数中不断计算已经过去的时间elapsed和剩余时间remaining。如果剩余时间
remaining小于等于 0,就调用回调函数callback。如果剩余时间
remaining大于 0,就使用setTimeout递归调用loop函数。
这种方法能比直接使用 setTimeout 更精确地执行定时任务。
进一步优化
上面的代码还可以进一步优化,可以考虑使用 requestAnimationFrame 来实现更高精度的定时器。
requestAnimationFrame 是专门为动画设计的,它会在浏览器下一次重绘之前调用指定的回调函数。由于浏览器的重绘通常是每秒 60 次(即每 16.67 毫秒一次),所以使用 requestAnimationFrame 可以实现更高精度的定时器。
以下是使用 requestAnimationFrame 实现的高精度定时器:
在这个实现中,requestAnimationFrame 会在每次浏览器重绘之前调用 loop 函数,从而实现更高精度的定时器。
实现一个更准确的 setInterval
同样地,我们可以通过结合 Date 对象和递归的 setTimeout 来实现更高精度的 setInterval。以下是一个实现准时 setInterval 的例子:
在这个实现中:
设置预期的下一次执行时间
expected。在
step函数中不断计算当前时间now和预期时间expected之间的偏差drift。如果偏差
drift大于等于 0,就调用回调函数callback,并更新预期时间expected。使用
setTimeout递归调用step函数,并根据偏差drift调整下一次调用的时间间隔。
进一步优化
为了进一步优化,可以考虑使用 requestAnimationFrame 来实现更高精度的定时器。requestAnimationFrame 是专门为动画设计的,它会在浏览器下一次重绘之前调用指定的回调函数。由于浏览器的重绘通常是每秒 60 次(即每 16.67 毫秒一次),所以使用 requestAnimationFrame 可以实现更高精度的定时器。
那我们使用 requestAnimationFrame 来实现的高精度 setInterval
总结
事件循环是 JavaScript 处理异步操作的核心机制,通过调用栈、任务队列和微任务队列的协调工作,实现了非阻塞 I/O 操作。
虽然 setTimeout 的定时精度受到事件循环的影响,但通过结合 Date 对象和递归的 setTimeout,或者使用 requestAnimationFrame,可以实现更为准确的定时器。
版权声明: 本文为 InfoQ 作者【控心つcrazy】的原创文章。
原文链接:【http://xie.infoq.cn/article/ccf57d86a23fbf450ff6f31e9】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。







评论