彻底弄懂 JavaScript 执行机制,Event Loop 宏任务与微任务
面试里被问「下面这段代码输出什么」,一堆 setTimeout 套 Promise 套 process.nextTick,能背出答案的人不少,能讲清楚为什么的人少很多。而真到了排查线上问题的时候,靠的恰恰是后者:为什么这个 loading 明明 setState 了却晚了一帧才消失,为什么这个 setTimeout(fn, 0) 在某台机器上要等半秒。
这篇把 JavaScript 的执行机制从头捋一遍,从单线程为什么必须有事件循环讲到宏任务微任务的实际排队规则,再到浏览器和 Node 之间那些容易踩的差异。文中的关键结论我都在 Node 22.23.1 上跑过。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 单线程为什么需要事件循环,同步任务和异步任务分别去了哪
setTimeout的延迟为什么经常对不上,4 毫秒下限到底怎么回事- 宏任务和微任务的划分,以及
process.nextTick其实不是微任务这件事 - 一道经典面试题的完整推演,以及在 Node 22 上的实测结果
- 浏览器和 Node 的事件循环差异,
setImmediate该怎么理解 - 微任务和页面渲染的关系,为什么
requestAnimationFrame不能用来做延时
javascript 是一门单线程语言。在 HTML5 中提出了 Web Worker,但 javascript 是单线程这一核心仍未改变。所以一切 javascript 版的「多线程」都是用单线程模拟出来的。
# 一、javascript 事件循环
既然 js 是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理 js 任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。
那么问题来了。假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?
聪明的程序员把任务分为了两类:
- 同步任务
- 异步任务
当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。关于这部分有严格的文字定义,但本文的目的是用最小的学习成本彻底弄懂执行机制,所以我们用导图来说明:

- 同步和异步任务分别进入不同的执行「场所」,同步的进入主线程,异步的进入
Event Table并注册函数 - 当指定的事情完成时,
Event Table会将这个函数移入Event Queue - 主线程内的任务执行完毕为空,会去
Event Queue读取对应的函数,进入主线程执行 - 上述过程会不断重复,也就是常说的
Event Loop(事件循环)
我们不禁要问了,那怎么知道主线程执行栈为空啊?
原文这里的说法是 js 引擎存在 monitoring process 进程持续检查执行栈。这个描述是一种便于理解的比喻,规范里并没有这么一个进程。真实情况是事件循环本身就是一个循环体,跑完一个任务就回到循环开头看队列里还有没有下一个,没有就阻塞等待。同样地,Event Table 和 Event Queue 也是这篇文章为了讲清楚而起的名字,HTML 规范里对应的术语是任务队列(task queue)和微任务队列(microtask queue)。
这套模型讲执行顺序是够用的,只是别把它当成规范原文去背,面试官追问的时候容易露怯。
let data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!');
}
})
console.log('代码执行结束');
上面是一段简易的 ajax 请求代码:
ajax进入Event Table,注册回调函数 success- 执行
console.log('代码执行结束') ajax事件完成,回调函数success进入Event Queue- 主线程从
Event Queue读取回调函数success并执行
还有一点值得点破。发起网络请求、计时、读文件这些事,并不是 JavaScript 线程自己在做,是宿主环境(浏览器或 Node)用另外的线程在做。JavaScript 只有一个执行线程,但浏览器不是单线程的。所以「单线程」限制的是你写的代码同一时刻只有一行在跑,不限制底下的 I/O 并发。
# 二、setTimeout 和 setInterval
# 2.1 setTimeout
大名鼎鼎的 setTimeout 无需再多言,大家对它的第一印象就是异步可以延时执行,我们经常这么实现延时 3 秒执行:
setTimeout(() => {
console.log('延时3秒');
},3000)
渐渐地 setTimeout 用的地方多了,问题也出现了。有时候明明写的延时 3 秒,实际却 5、6 秒才执行函数,这又咋回事啊?
setTimeout(() => {
task();
},3000)
console.log('执行console');
根据前面我们的结论,setTimeout 是异步的,应该先执行 console.log 这个同步任务,所以我们的结论是:
//执行console
//task()
去验证一下,结果正确。然后我们修改一下前面的代码:
setTimeout(() => {
task()
},3000)
sleep(10000000)
乍一看其实差不多,但我们把这段代码在 chrome 执行一下,却发现控制台执行 task() 需要的时间远远超过 3 秒。说好的延时三秒,为啥现在需要这么长时间啊?
这时候我们需要重新理解 setTimeout 的定义。先说上述代码是怎么执行的:
task()进入Event Table并注册,计时开始- 执行
sleep函数,很慢,非常慢,计时仍在继续 - 3 秒到了,计时事件
timeout完成,task()进入Event Queue,但是sleep也太慢了吧,还没执行完,只好等着 sleep终于执行完了,task()终于从Event Queue进入了主线程执行
上述的流程走完,我们知道 setTimeout 这个函数,是经过指定时间后,把要执行的任务(本例中为 task())加入到 Event Queue 中。又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于 3 秒。
所以 setTimeout 的第二个参数是「最少等这么久」,不是「正好等这么久」。
我们还经常遇到 setTimeout(fn, 0) 这样的代码,0 秒后执行又是什么意思呢?是不是可以立即执行呢?
答案是不会的。setTimeout(fn, 0) 的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成、栈为空就马上执行。举例说明:
//代码1
console.log('先执行这里');
setTimeout(() => {
console.log('执行啦')
},0);
//代码2
console.log('先执行这里');
setTimeout(() => {
console.log('执行啦')
},3000);
代码 1 的输出结果是:
//先执行这里
//执行啦
代码 2 的输出结果是:
//先执行这里
// ... 3s later
// 执行啦
关于 setTimeout 要补充的是,即便主线程为空,0 毫秒实际上也是达不到的,根据 HTML 的标准最低是 4 毫秒。
这条得说得更准确一点。HTML 规范里的规则是:定时器有一个嵌套层级计数,当嵌套层级超过 5 层、并且传入的时间小于 4 毫秒时,才会被钳制到 4 毫秒。也就是说第一次调用 setTimeout(fn, 0) 并不会被钳到 4ms,是在定时器回调里再套定时器、连续套超过五层之后才会触发。所以那些用 setTimeout 递归做轮询的代码,跑着跑着间隔会自己变长。
还有一条实际影响更大的:页面切到后台标签页时,浏览器会把 setTimeout 的最小间隔限制到 1 秒甚至更长,用来省电。所以靠 setInterval 累加计时器做倒计时,用户切走再切回来一定会对不上,正确做法是每次都用 Date.now() 重新算差值。这个我踩过,本地测一点问题没有,用户反馈说倒计时慢了好几分钟。
# 2.2 setInterval
上面说完了 setTimeout,当然不能错过它的孪生兄弟 setInterval。他俩差不多,只不过后者是循环执行。对于执行顺序来说,setInterval 会每隔指定的时间将注册的函数置入 Event Queue,如果前面的任务耗时太久,那么同样需要等待。
唯一需要注意的一点是,对于 setInterval(fn, ms) 来说,我们已经知道不是每过 ms 毫秒会执行一次 fn,而是每过 ms 毫秒会有 fn 进入 Event Queue。一旦 setInterval 的回调函数 fn 执行时间超过了延迟时间 ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。
补一句实践上的结论:需要周期执行又不希望任务堆积的场景,用 setTimeout 递归代替 setInterval。
function poll() {
doSomething()
timer = setTimeout(poll, 1000) // 上一次干完了才排下一次
}
这样能保证两次执行之间至少隔 1 秒,而不是「每 1 秒往队列里塞一个」。另外定时器忘了清是内存泄漏的常见来源,组件销毁的时候记得 clearTimeout / clearInterval,相关排查手段我写在 JavaScript 内存泄漏排查 那篇里。
# 三、Promise 与 process.nextTick(callback)
传统的定时器我们已经研究过了,接着我们探究 Promise 与 process.nextTick(callback) 的表现。
process.nextTick(callback) 类似 node.js 版的「setTimeout」,在事件循环的下一次循环中调用 callback 回调函数。
除了广义的同步任务和异步任务,我们对任务有更精细的定义:
macro-task(宏任务):包括整体代码script,setTimeout,setIntervalmicro-task(微任务):Promise,process.nextTick
不同类型的任务会进入对应的 Event Queue,比如 setTimeout 和 setInterval 会进入相同的 Event Queue。
事件循环的顺序决定 js 代码的执行顺序。进入整体代码(宏任务)后开始第一次循环,接着执行所有的微任务,然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。听起来有点绕,我们用一段代码说明:
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');