JavaScript引擎采用单线程模型,通过事件循环机制处理异步任务。本文深入解析setTimeout与setInterval的执行差异,剖析GUI渲染、事件触发与定时线程如何协同工作,揭示定时器回调并非立即执行的原因及队列处理逻辑,帮助开发者避免常见误区。
JavaScript定时器机制概述
JavaScript中的setTimeout与setInterval是两个容易让人产生误解的API。许多开发者在初次使用时,往往默认调用后会严格按照设定时间执行,这种直觉在很多场景下确实成立,比如:
示例代码如下:
setTimeout( function(){ alert('你好!'); } , 0);
setInterval( callbackFunction , 100); 开发者通常认为,setTimeout中的回调函数会立即执行,因为根据JavaScript API文档,第二个参数指定的是延迟毫秒数。当设置为0毫秒时,直觉上认为应该立刻执行。
同理,对于setInterval,开发者也坚信callbackFunction会每隔100毫秒准时执行。
然而,随着JavaScript开发经验的积累,你可能会遇到一段令人困惑的代码,无论怎么调试都无法理解其行为:
示例代码如下:
div.onclick = function(){
setTimeout( function(){document.getElementById('inputField').focus();}, 0);
}; 既然设定为0毫秒后执行,为何还要包裹在setTimeout中?此时,原本坚定的认知开始动摇。
直到某天,你不小心编写了一段有问题的代码:
示例代码如下:
setTimeout( function(){ while(true){} } , 100);
setTimeout( function(){ alert('你好!'); } , 200);
setInterval( callbackFunction , 200); 第一行代码进入了死循环。但随后你会发现,第二、第三行的行为与预期截然不同:alert对话框没有出现,callbackFunction也未执行。
这种结果令人困惑,因为推翻长期形成的认知并接受新事实的过程往往伴随痛苦。但为了探究JavaScript的本质,我们必须继续探索其线程模型和定时器机制。
JavaScript单线程模型解析
拨开迷雾见月明
上述误区产生的根本原因在于:潜意识里认为JavaScript引擎拥有多个线程,且定时器的回调是异步执行的。
实际上,JavaScript使用了一种“障眼法”迷惑了开发者。这里需要澄清一个关键事实:
JavaScript引擎是单线程运行的,浏览器在任何时刻都只运行一个JavaScript线程。
采用单线程模型是有其道理的,因为单线程避免了复杂的线程同步问题,从而简化了开发。
那么,单线程的JavaScript引擎是如何配合浏览器内核处理定时器和响应浏览器事件的?
下面结合浏览器内核的工作机制进行简要说明。
浏览器内核线程与事件循环
浏览器内核允许多个线程异步执行,这些线程在控制下相互配合以保持同步。通常,浏览器内核至少包含三个常驻线程:JavaScript引擎线程、界面渲染线程和浏览器事件触发线程。此外,还有一些执行完毕后即销毁的线程,如HTTP请求线程。这些异步线程会产生不同的异步事件,下图展示了单线程JavaScript引擎与其他线程的交互通信机制。尽管不同浏览器的实现细节各异,但调用原理大同小异。

从图中可以看出,浏览器中的JavaScript引擎基于事件驱动。这里的事件可视为浏览器分配给引擎的各种任务,这些任务可能源自JavaScript引擎当前执行的代码块(如调用setTimeout添加任务),也可能来自浏览器内核的其他线程(如界面元素的鼠标点击事件、定时器到达通知、异步请求状态变更通知等)。从代码角度看,任务实体即为各种回调函数。JavaScript引擎一直等待任务队列中任务的到来。由于单线程的限制,这些任务必须排队,一个接一个地被引擎处理。
上图中的t1-t2..tn表示不同的时间点,tn下方对应的小方块代表该时间点的任务。假设当前处于t1时刻,引擎正在执行t1对应的任务代码。在此时间点,我们来观察浏览器内核其他线程的状态。
t1时刻:
GUI渲染线程:
该线程负责渲染浏览器界面中的HTML元素。当界面需要重绘(Repaint)或因某种操作引发回流(reflow)时,该线程会执行。虽然本文重点解释JavaScript定时机制,但有必要提及渲染线程,因为它与JavaScript引擎线程是互斥的。这是显而易见的,因为JavaScript脚本可以操作DOM元素,如果在修改元素属性的同时渲染界面,渲染线程前后获取的元素数据可能不一致。
在JavaScript引擎运行脚本期间,浏览器渲染线程处于挂起状态,即被“冻结”。
因此,在脚本中执行界面更新操作(如添加节点、删除节点或改变节点外观)不会立即体现,这些操作将保存在队列中,待JavaScript引擎空闲时才会渲染。或许你会疑惑:为什么执行JS代码中的alert时界面有更新?JS不是正在运行吗?其实,当alert发生时,浏览器内核会挂起JavaScript引擎线程,并促使界面执行更新。
GUI事件触发线程:
JavaScript脚本的执行不影响HTML元素事件的触发。在t1时间段内,用户首先点击了鼠标键,点击被浏览器事件触发线程捕捉后形成鼠标点击事件。由图可知,对于JavaScript引擎线程来说,该事件是由其他线程异步传递到任务队列尾部的。由于引擎正在处理t1时的任务,这个鼠标点击事件正在等待处理。
定时触发线程:
注意,这里的浏览器模型定时计数器并非由JavaScript引擎计数。因为JavaScript引擎是单线程的,如果处于阻塞状态就无法计时,它必须依赖外部来计时并触发定时,因此队列中的定时事件也是异步事件。
由图可知,在t1时间段内,继鼠标点击事件触发后,先前设置的setTimeout定时也到达了。此刻,对JavaScript引擎来说,定时触发线程产生了一个异步定时事件并放入任务队列。该事件被排在点击事件回调之后,等待处理。
同理,在t1时间段内,接下来某个setInterval定时器也被添加。由于是间隔定时,在t1段内连续触发了两次,这两个事件被排在队尾等待处理。
可见,如果时间段t1非常长,远大于setInterval的定时间隔,那么定时触发线程就会源源不断地产生异步定时事件并放到任务队列尾部,不管它们是否已被处理。但一旦t1和最先的定时事件前面的任务已处理完,这些排列中的定时事件就依次不间断地执行。这是因为,对于JavaScript引擎来说,处理队列中各任务的方式是一样的,只是处理的次序不同而已。
t1过后,即当前处理的任务已返回,JavaScript引擎会检查任务队列。发现当前队列非空,就取出t2下面对应的任务执行,其他时间依此类推。由此看来:
如果队列非空,引擎就从队列头取出一个任务,直到该任务处理完(即返回)后,引擎接着运行下一个任务。在任务没返回前,队列中的其他任务是没法被执行的。
可以尝试运行以下例子进行测试:第一个定时器是一个死循环,第二个定时器是向界面输出一串字符串,第一个定时器比第二个定时器设置的时间更早。
示例代码如下:
setTimeout(function(){
for(;;);
}, 50);
setTimeout(function(){alert('Hello');}, 51); 运行后可看出,在浏览器未提示脚本繁忙前,第二个定时器的alert不会弹出。因为单线程关系,第一个定时器回调脚本未返回前,第二个脚本没机会运行。
相信您现在已经清楚JavaScript是否支持多线程,也了解了JavaScript定时器运行机制。下面我们来对一些案例进行分析:
setTimeout与setInterval案例对比
案例1:setTimeout与setInterval
示例代码如下:
setTimeout(function(){
/* 代码块... */
setTimeout(arguments.callee, 10);
}, 10);
setInterval(function(){
/*代码块... */
}, 10); 这两段代码看起来效果一样,其实并非如此。第一段中,回调函数内的setTimeout是在JavaScript引擎执行后再设置新的setTimeout定时。假定上一个回调处理完到下一个回调开始处理为一个时间间隔,理论上两个setTimeout回调执行时间间隔>=10ms。第二段自setInterval设置定时后,定时触发线程就会源源不断地每隔十秒(注:原文为十秒,结合上下文应为10毫秒)产生异步定时事件并放到任务队列尾,理论上两个setInterval回调执行时间间隔<=10。
案例2:ajax异步请求是否真的异步?
Ajax异步请求机制分析
很多同学朋友搞不清楚,既然说JavaScript是单线程运行的,那么XMLHttpRequest在连接后是否真的异步?
其实请求确实是异步的,不过这请求是由浏览器新开一个线程请求(参见上图)。当请求的状态变更时,如果先前已设置回调,这异步线程就产生状态变更事件放到JavaScript引擎的处理队列中等待处理。当任务被处理时,JavaScript引擎始终是单线程运行回调函数,具体点即还是单线程运行onreadystatechange所设置的函数。