这一次,彻底弄懂 JavaScript 执行机制
Category(分类): JavaScript Status: 未知
本文的目的,是帮助我们分析给定代码的输出顺序。不论是面试题还是日常开发,都应该先区分两件事:当前 JavaScript job 中的同步代码按顺序执行,而异步回调要由宿主环境安排到后续任务中。不能把“JavaScript 通常在一个执行代理中按顺序执行”简单推导成“所有异步代码也按照源代码顺序立刻执行”。
1. 关于 JavaScript
JavaScript 语言本身规定的是执行语义;浏览器、Node.js、Worker 等宿主负责提供事件循环、定时器、网络、文件系统和渲染等能力。浏览器主线程通常一次执行一个 job,但 Web Worker 可以在独立的执行代理中运行 JavaScript,并通过消息或共享内存通信。因此“JavaScript 是单线程”只能作为主线程入门模型,不能当作所有宿主、所有 agent 的绝对结论。
普通同步代码确实按语句顺序执行:
const a = '1'
console.log(a)
const b = '2'
console.log(b)

然而,定时器回调和 Promise 反应并不会在注册处立即执行:
setTimeout(() => {
console.log('定时器开始啦')
}, 0)
new Promise(resolve => {
console.log('马上执行 for 循环啦')
for (let i = 0; i < 10000; i++) {
if (i === 99) resolve()
}
}).then(() => {
console.log('执行 then 函数啦')
})
console.log('代码执行结束')
在浏览器和 Node.js 的常见实现中,同步输出通常是:
马上执行 for 循环啦
代码执行结束
执行 then 函数啦
定时器开始啦
Promise 的 then 回调属于 Promise jobs/microtasks;定时器回调属于宿主安排的后续任务。具体任务队列名称和渲染时机由宿主规范决定。

2. 事件循环的入门模型
浏览器或 Node.js 会把同步代码、定时器、网络、文件系统等工作交给不同的宿主机制处理。异步操作完成后,宿主把相应的回调安排到可执行的任务队列;当当前 job 结束,事件循环才会在合适的时机取出任务执行。
过去常见的教学图会把这些部分称为“主线程、Event Table、Event Queue 和 monitoring process”。这套图有助于入门,但不是 HTML 或 ECMAScript 规范中的完整对象模型:现代浏览器会区分 task、microtask、渲染机会和多个任务源,Node.js 则由 libuv 的事件循环阶段协调 I/O 和定时器。
历史上的 AJAX 示例可以这样表示($.ajax 需要先加载 jQuery,URL 也必须是真实且满足 CORS 的地址):
$.ajax({
url: 'https://example.com/api',
data: [],
success: () => {
console.log('发送成功!')
},
error: error => {
console.error('请求失败', error)
}
})
console.log('代码执行结束')
现代 Web API 通常使用 Promise 化的 fetch:
fetch('/api/data')
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
})
.then(data => console.log('发送成功!', data))
.catch(error => console.error('请求失败', error))
console.log('代码执行结束')
一般可以这样理解:请求发出后当前代码继续执行;响应完成后,fetch 的 Promise 反应会在后续 microtask 中运行。但网络响应到达时间、任务安排和渲染时机不能由 JavaScript 源码单方面决定。

3. 又爱又恨的 setTimeout
setTimeout(fn, delay) 的意思是:在指定延迟之后,允许宿主把 fn 安排为后续任务;它不是“精确在 delay 毫秒时执行”。当前执行栈、其他任务、页面后台节流、系统调度和 Node.js 事件循环都可能让实际执行更晚:
setTimeout(() => {
console.log('延时约 3 秒')
}, 3000)
先看一个简单例子:
setTimeout(() => {
console.log('task()')
}, 0)
console.log('执行 console')
通常先输出 执行 console,再输出 task()。setTimeout(fn, 0) 也不会同步执行,它只表示没有额外的目标延迟,仍要等当前同步 job 结束并等待宿主调度。
如果主线程被长任务占用,定时器即使已经到期也只能等待:
function blockCpu(milliseconds) {
const end = Date.now() + milliseconds
while (Date.now() < end) {
// 模拟占用主线程的同步长任务
}
}
setTimeout(() => {
console.log('定时器回调')
}, 0)
blockCpu(100)
console.log('同步长任务结束')
这里定时器的计时和主线程的执行是两件事:计时可能已经到期,但回调要等当前长任务结束后才有机会执行。不要用这种忙等方式实现真正的延迟,它会阻塞页面;示例只是为了说明调度关系。
在浏览器中,HTML 标准对嵌套定时器有最小延迟和节流规则,但“4 毫秒”不是所有环境、所有定时器和所有调用层级下的绝对保证。Node.js 的定时器也有自己的实现和调度规则。
4. 又恨又爱的 setInterval
setInterval(fn, ms) 会请求宿主按间隔安排回调,但不保证每次回调之间恰好相隔 ms。如果主线程繁忙、页面被后台节流,或回调本身耗时较长,回调可能延迟、合并或出现明显的时间漂移;不要用它实现精确计时。
需要避免异步任务重叠时,常见做法是递归 setTimeout,在本次工作完成后再安排下一次:
let timerId
function tick() {
console.log('执行一次')
timerId = setTimeout(tick, 1000)
}
timerId = setTimeout(tick, 1000)
// 需要停止时:clearTimeout(timerId)
5. Promise 与 process.nextTick()
Promise 的处理函数和 Node.js 的 process.nextTick() 不能混成同一个跨平台队列:
- Promise 的反应处理属于 ECMAScript Promise jobs,浏览器通常把它作为 microtask 执行。
queueMicrotask()是浏览器和现代 Node.js 都可用的显式 microtask API。process.nextTick()是 Node.js 特有的高优先级队列,不是 ECMAScript 标准的 Promise microtask;Node.js 通常会在继续事件循环前先清空它。setTimeout、setInterval是宿主定时器任务;Node.js 还提供setImmediate,浏览器通常没有同名标准 API。
因此,下面这段代码在常见浏览器/Node.js 环境中通常会先输出同步代码,再输出 then,最后输出定时器:
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('promise')
resolve()
}).then(() => {
console.log('then')
})
console.log('console')
常见输出:
promise
console
then
setTimeout
事件循环的简化顺序可以描述为:执行一个 task 的同步代码;当前 job 结束后清空 microtask;宿主在合适时机进行渲染或进入下一个 task。Node.js 还要结合 libuv 的 timers、poll、check 等阶段理解,不能用浏览器的一张队列图覆盖所有环境。

6. Node.js 中的复杂示例
下面的示例明确标注为 Node.js 代码,因为浏览器没有 process.nextTick():
console.log('1')
setTimeout(() => {
console.log('2')
process.nextTick(() => {
console.log('3')
})
new Promise(resolve => {
console.log('4')
resolve()
}).then(() => {
console.log('5')
})
})
process.nextTick(() => {
console.log('6')
})
new Promise(resolve => {
console.log('7')
resolve()
}).then(() => {
console.log('8')
})
setTimeout(() => {
console.log('9')
process.nextTick(() => {
console.log('10')
})
new Promise(resolve => {
console.log('11')
resolve()
}).then(() => {
console.log('12')
})
})
在 Node.js 24 的常见运行方式下,输出为:
1
7
6
8
2
4
3
5
9
11
10
12
第一轮主脚本同步执行时输出 1、7;随后 Node.js 先处理 process.nextTick,再处理 Promise reaction,得到 6、8。第一个定时器任务中同步输出 2、4,然后先输出 Node.js 的 nextTick(3),再输出 Promise reaction(5)。第二个定时器同理输出 9、11、10、12。
这个顺序依赖 Node.js 的调度模型和定时器安排;不要把它当成浏览器的通用答案。不同 Node.js 版本、启动方式或其他 I/O 任务可能造成定时器先后差异,因此面试题应先确认运行环境。

7. 浏览器中的 microtask 与渲染
浏览器在一个 task 结束后通常会清空 microtask 队列,然后才可能进入渲染机会或下一个 task。Promise reaction 和 queueMicrotask 都能加入 microtask,但大量递归 microtask 也会让渲染和用户输入长时间得不到机会:
let count = 0
function schedule() {
queueMicrotask(() => {
count++
if (count < 3) schedule()
console.log('microtask', count)
})
}
schedule()
setTimeout(() => console.log('task'), 0)
实际页面动画应优先考虑 requestAnimationFrame,后台轮询要考虑可见性、取消和节流;网络请求应使用 AbortController 管理取消,而不是只依赖定时器。
8. 写在最后
(1)JavaScript 的异步
JavaScript 的异步不是把同一段代码同时执行,而是由宿主在当前 job 结束后安排后续工作。网络、定时器、文件 I/O 和 Worker 都有自己的宿主机制;“单线程”不能解释所有调度细节。
(2)事件循环 Event Loop
事件循环是宿主安排任务、microtask、I/O 和渲染机会的一组机制。ECMAScript 规定 Promise jobs 等语言级语义,HTML 和 Node.js 则补充各自的事件循环。
(3)JavaScript 的执行和运行
JavaScript 在浏览器、Node.js、Worker、Deno 等宿主中的运行方式不同。引擎负责执行 ECMAScript 语义,宿主负责提供 API 和调度环境;因此分析输出时一定要注明运行环境。
(4)setImmediate
setImmediate 主要是 Node.js 的宿主 API(也曾在部分旧浏览器环境出现过非标准实现),不能当作浏览器通用 API。Node.js 中它与 setTimeout(..., 0) 的先后还可能取决于代码所在阶段和 I/O 情况。
(5)最后的最后
- 当前同步 job 内的代码按顺序执行。
- Promise reaction 和
queueMicrotask通常在当前 job 后执行。 - 定时器只是最早可调度时间,不是精确执行时间。
- 浏览器、Node.js 和 Worker 的事件循环不能用一套绝对顺序替代。

现代补充
现代异步代码还应关注以下实践:
- 用
async/await组织顺序流程,但并行任务应显式使用Promise.all、Promise.allSettled等组合器,避免无意中串行化。 - 用
AbortController或AbortSignal.timeout()处理请求取消和超时。 - 不要在主线程中使用忙等或同步 XHR;长计算可以拆分、移到 Worker,或使用合适的任务调度方式。
- 未处理的 Promise 拒绝应在边界处捕获;浏览器可监听
unhandledrejection,Node.js 的默认行为和进程退出策略也应按目标版本确认。 - 需要分析复杂顺序时,先画出“当前 task → microtask → 下一 task”的时间线,再标记 Node.js 的
nextTick和 libuv 阶段,避免把所有东西都称为“宏任务/微任务”。
参考资料
作者:ssssyoki
链接:https://juejin.cn/post/6844903512845860872
来源:稀土掘金。著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。