JavaScript运行机制
本文最后更新于 2025年9月15日 下午
参考:
https://juejin.cn/post/6844903512845860872
https://juejin.cn/post/6844904100195205133
https://juejin.cn/post/6991849728493256741
https://juejin.cn/post/7126795602276384804
JavaScript 异步实现
JavaScript 是一门单线程语言,虽然 HTML5 中提出 Web-Worker,但 JavaScript 是单线程这一核心仍未改变。所以一切 JavaScript 的”多线程”都是用单线程模拟出来的。
JavaScript 的主运行线程只有一个,但不是整个运行环境都是单线程。JavaScript 的运行环境主要是浏览器,它不仅是多线程的,而且是多进程的。
浏览器的多进程架构
浏览器的渲染进程
同:浏览器的渲染进程
Event Loop
Event Loop 是 JavaScript 管理事件执行的一个流程,具体的管理办法由它具体的运行环境确定。JavaScript 的主要运行环境有两个:浏览器和 Node.js,这两个环境的 Event Loop 有所区别。
任务分类
Event Queue 里面的事件分两类:宏任务和微任务。
微任务拥有更高的优先级,Event Loop 遍历队列时会先检查微任务队列,如果里面有任务就全部拿来执行,执行完所有微任务后再执行下一个宏任务。执行每个宏任务前都检查微任务队列是否有任务,如果有,则会优先执行微任务队列。
| 常见宏任务 | 浏览器环境 | Node.js 环境 |
|---|---|---|
| script | ✅ | ✅ |
| setTimeout | ✅ | ✅ |
| setInterval | ✅ | ✅ |
| I/O 操作 | ✅ | ✅ |
| UI 渲染 | ✅ | ❌ |
| setImmediate | ❌ | ✅ |
| 常见微任务 | 浏览器环境 | Node.js 环境 |
|---|---|---|
| Promise | ✅ | ✅ |
| async/await | ✅ | ✅ |
| MutaionObserver | ✅ | ❌ |
| process.nextTick | ❌ | ✅ |
不同类型的任务会进入对应的 Event Queue,比如 setTimeout 和 setInterval 会进入相同的 Event Queue。
事件循环的顺序,决定 JS 代码的执行顺序。进入整体代码(宏任务)后开始第一次循环,接着执行所有的微任务。然后再次从宏任务开始,找到其中一个事件队列执行完毕,再执行所有的微任务。
浏览器
Event Loop 是各个异步线程用来通讯和协同执行的机制。各线程为了交换消息有一个公用的数据区,这就是 Event Queue。各个异步线程执行完后,通过事件触发线程将回调函数放到 Event Queue。

执行流程:
- 主线程执行时,先看要执行的是同步任务还是异步任务
- 同步任务进入主线程,异步任务进入相对应的线程,主线程继续执行同步任务
- 相应的线程执行异步任务,满足条件后对应的线程将完成异步任务后的回调放入 Event Queue
- 主线程内的任务执行完毕后就去 Event Queue 取出里面的回调函数放入主线程执行
主线程不断循环上述执行流程,就是浏览器的 Event Loop。
DOM 操作
浏览器的事件循环(Event Loop)大致分成几个关键步骤:
- 执行同步代码(主线程调用栈)
- JS 是单线程的,先执行同步任务
- 处理微任务(microtask)队列
- 比如:Promise.then、queueMicrotask、MutationObserver
- 渲染(Render/Update the rendering)
- 浏览器会把 DOM 的变化更新到页面上,也就是布局(Layout)+ 绘制(Paint)
- 继续处理后续宏任务(macrotask)队列
- 比如:setTimeout、setInterval、setImmediate、requestAnimationFrame
针对 DOM 操作:
- 在 JS 里修改 DOM(比如
element.style.color = 'red'),这个修改会立即反映到 DOM 树/渲染树的内存数据结构中 - 浏览器不会立刻把页面渲染出来,而是等到本轮的宏任务和所有微任务都执行完,进入渲染阶段时统一更新页面
计时不准
Event Loop 的这个流程里面其实还是隐藏了一些问题的,最典型的就是总是先执行同步任务,然后再执行 Event Queue 里面的回调函数。这个特性就直接影响定时器的执行。如果主线程长时间被阻塞,定时器的回调函数就没机会执行,即使执行时间也不准。所以写代码时一定不要长时间占用主线程。
setTimeout
setTimeout 用于指定在一定时间后执行某些代码。实际上,是指经过指定的时间后,把要执行的任务加入 Event Queue 中,又因为是单线程任务要一个一个执行,所以如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间大于指定的时间。
setTimeout(fn, 0) 含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少时间,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行这儿任务。但是即便主线程为空,0 毫秒实际上也达不到。根据 HTML 的标准最低是 4 毫秒。而且 setTimeout(fn, 0) 会被强制改为 setTimeout(fn, 1)。
1 | |
1 | |
setInterval
setInterval() 用于指定每隔一段时间执行某些代码,直到取消循环或者页面卸载。setInterval() 会在每过指定的时间,将注册的函数放入 Event Queue。同理,如果 Event Queue 前面的任务耗时太久,一样需要等待。
需要注意的一点是,对 setInterval(fn, ms) 来说,不是每过 ms 秒会执行一次 fn,而是每过 ms 秒,会有 fn 进入 Event Queue。一旦 setInterval 的回调函数 fn 执行时间超过了延迟时间 ms,那就完全看不出来有指定的时间间隔。
Node.js
Node.js 是运行在服务端的 JavaScrip,虽然它也用到了 V8 引擎,但是它的服务目的和环境不同,导致了它的 API 与原生 JavaScript 有些区别,它的 Event Loop 还要处理一些 I/O,比如新的网络连接等,所以与浏览器 Event Loop 也是不一样的。Node.js 的 Event Loop 是分阶段的,而且 Node.js 是先将外层的同步代码一次性全部执行完,遇到异步任务就塞到对应的阶段,然后进入 Event Queue 进行阶段循环。

不同阶段:
- timers:执行 setTimeout 和 setInterval 的回调函数;
- pending callbacks:执行延迟到下一个循环迭代的 I/O 回调函数;
- idle:仅系统内部使用;
- prepare:仅系统内部使用;
- poll:检索新的 I/O 事件、执行与 I/O 相关的回调函数;
- check:setImmediate 在这里执行;
- close callbacks:一些关闭的回调函数。
不断循环上述执行阶段,就是 Node.js 的 Event Loop。
每个阶段都有一个自己先进先出的队列,只有当这个队列的事件执行完或者达到该阶段的上限时,才会进入下一个阶段。在每次事件循环之间,Node.js 都会检查它是否在等待任何一个 I/O 或者定时器,如果没有程序就关闭退出。我们的直观感受就是,如果一个 Node.js 程序只有同步代码,在控制台运行完后它就退出了。
需要注意的是 poll 阶段,它后面并不一定每次都是 check 阶段,poll 队列执行完后,如果没有 setImmediate 但是有定时器到期,它会绕回去执行定时器阶段。
setImmediate
在一个异步流程里,setImmediate 会比定时器先执行。
1 | |
上面 console.log('setTimeout') 和 console.log('setImmediate') 都包在了一个 setTimeout 里面,如果直接写在最外层会怎么样呢?上面没有问题,但是,下面的代码输出结果不一定。
1 | |
1 | |
分析执行流程:
- 外层同步代码一次性全部执行完,遇到异步 API 就塞到对应的阶段
- 遇到 setTimeout,虽然设置是 0 毫秒触发,但是被 node.js 强制改为 1 毫秒,塞入 times 阶段
- 遇到 setImmediate 塞入 check 阶段
- 同步代码执行完毕,进入 Event Loop
- 先进入 times 阶段,检查是否过去 1 毫秒:若已过,满足 setTimeout 条件,执行回调;若未过,跳过
- 跳过空的阶段,进入 check 阶段,执行 setImmediate 回调
关键就在这个 1 毫秒,如果同步代码执行时间较长,进入 Event Loop 的时候 1 毫秒已经过了,setTimeout 执行,如果 1 毫秒还没到,就先执行了 setImmediate。每次我们运行脚本时,机器的状态可能不一样,导致运行时有 1 毫秒的差距,一会儿 setTimeout 先执行,一会儿 setImmediate 先执行。但是这种情况只会发生在还没进入 timers 阶段的时候。像第一个例子那样,因为已经在 timers 阶段,所以里面的 setTimeout 只能等下个循环,所以 setImmediate 肯定先执行。同理的还有其他 poll 阶段也是这样。
process.nextTick
process.nextTick() 是一个特殊的异步任务,它不属于任何的 Event Loop 阶段。实际 Node 在遇到这个 API 时 Event Loop 根本就不会继续进行,而是马上停下来执行 process.nextTick(),执行完后继续 Event Loop。可以理解为,该任务执行在同步任务执行之后、异步任务执行之前;或者理解为,该任务在微任务队列最前列。
1 | |
分析执行流程:
- 代码基本都在 readFile 回调里面,它自己执行时,已经在 poll 阶段
- 遇到 setTimeout(fn, 0),其实是 setTimeout(fn, 1),塞入之后的 timers 阶段
- 遇到 setImmediate 塞入 check 阶段
- 遇到 nextTick 立马执行,输出
nextTick 1 - 到 check 阶段,输出
setImmediate,又遇到 nextTick 立马执行,输出nextTick 2 - 到 timers 阶段,输出
setTimeout
这种机制其实类似于微任务,但是并不完全一样,同时有 nextTick 和 Promise 时是 nextTick 先执行,原因是 nextTick 的队列比 Promise 队列优先级更高。
1 | |
实践分析
1 | |
1 | |
1 | |
1 | |
1 | |
1 | |
1 | |
1 | |