前言
众所周知JavaScript是一门单线程的高级语言,可以在浏览器端和Node.js服务端高效运行,从而实现全栈开发。
那么就有人要问了:为什么单线程语言还能高效运行???高效往往要求有较高的并发能力。单线程的JavaScript通过事件循环,实现异步编程,提高并发度。
在讲事件循环之前,我们需要先知道什么是同步代码,什么是异步代码。
🧐第一关:基础知识
1.何为同步代码❓
定义如下
可以直接被JavaScript引擎执行的,不会引起堵塞的代码
例如:
// 声明语句
var person={
name:'张三',
age:18
}
// 赋值语句
var a=1;
// 打印语句
console.log(a);
// 函数
function add (x,y){
return x+y;
}
特点
- 同步代码在JavaScript引擎的调用栈中被调用
- JavaScript会等待同步代码的执行结果,然后按顺序同步执行后面的代码
2.何为异步代码❓
定义如下
执行需要一定的时间会阻塞程序运行的代码。JavaScript引擎对于这种类型的代码并不关心他们的执行过程(即不等待执行结果)。而是等异步代码主动通知主进程说:”我执行完了!“,主进程空闲的时候通过事件循环机制调用对应的回调函数处理异步代码的返回结果。
分类
异步代码又可分为以下两类(不同类型的任务拥有自己的任务队列):
- 宏任务(setTimeout,dom操作,I/O操作等)
- 微任务(promise回调等)
微任务的优先级高于宏任务(详情见事件循环部分)
宿主环境
前面说到了,JavaScript引擎并不关心异步事件的执行过程,它只处理执行结果,那谁去处理过程呢🤔?
有些聪明的小伙伴可能就会想到了:是不是有一个什么东西帮助JavaScript引擎去做这件事情呢???
事实也的确是这样,JavaScript引擎会让宿主环境(通常为浏览器环境和Node环境) 去代替自己处理异步事件,同时宿主环境在异步事件完成后会将事件对应的回调函数放到回调队列里面在事件循环的过程中被JavaScript引擎调用
浏览器环境
常见异步代码:
// 定时器事件
setTimeout(()=>{
console.log('定时器事件');
},0)
// Dom操作
document.addEventListener('click',()=>{
console.log('点击事件触发');
})
// fetch请求
fetch("网址")
.then(response => response.json())
.then(data => console.log("fetch成功", data))
.catch(err => console.log("fetch失败", err));
Node环境
常见异步代码:
// 导入读取文件模块
const fs = require("fs");
// 定时函数
setTimeout(() => {
console.log("setTimeout");
}, 10);
// I/O事件
fs.readFile("./test.txt", "utf-8", (err, data) => {
if (err) {
console.log("读取第一个文件失败", err);
return;
}
console.log("读取第一个文件成功", data);
});
fs.readFile("./text.txt", "utf-8", (err, data) => {
if (err) {
console.log("读取第二个文件失败", err);
return;
}
console.log("读取第二个文件成功", data);
});
// 立即执行函数(Node专属)
setImmediate(()=>{
console.log("setImmediate");
})
// process.nextTick()微任务(Node专属,异步优先级最高)
process.nextTick(() => {
console.log("process.nextTick");
});
执行结果(看不懂没关系,后续Node事件循环部分会详解):
🍅第二关:事件运行总览图
在讲事件循环之前先对整个事件流程有一个大致的概念,从整体到局部,再聚焦到事件循环上面,着重理解
🥳Boss战:事件循环
浏览器环境 VS Node环境
在了解完基础知识和大致事件运行流程后,来具体看看,不同宿主环境下事件循环有什么异同吧!!!
不同宿主环境(如浏览器和 Node.js)的事件循环机制核心逻辑相似(都是通过循环处理任务队列实现异步),但具体细节和阶段划分存在明显差异,这是因为它们的宿主环境设计目标和任务类型不同。
一、核心相似点
无论是浏览器还是 Node.js,事件循环的核心目的一致:
- 解决 JS 引擎单线程的阻塞问题,协调同步代码和异步任务(如定时器、I/O 回调等)的执行顺序。
- 都区分宏任务(Macrotask) 和微任务(Microtask),且微任务的优先级高于宏任务(每个宏任务执行后,会先清空微任务队列)。
二、关键差异点(以浏览器 vs Node.js 为例)
1. 任务队列的阶段划分不同
-
浏览器环境:
事件循环的阶段相对简单,主要围绕“渲染相关任务”和“普通异步任务”设计,没有明确的多阶段划分,核心是一个宏任务队列 + 一个微任务队列(实际内部可能有细分,但对开发者透明)。
执行逻辑:- 执行一个宏任务(如
script
标签整体代码、setTimeout
回调)。 - 清空所有微任务(如
Promise.then
、MutationObserver
)。 - 执行浏览器渲染(如重绘、回流)(这一步是浏览器特有,Node.js 无渲染需求)。
- 重复以上步骤。
- 执行一个宏任务(如
-
Node.js 环境:
事件循环的阶段划分更复杂,由libuv
库实现,明确分为6个阶段(如 Timers、Poll、Check 等,详见之前的“事件循环机制”解释),每个阶段处理特定类型的宏任务。
执行逻辑:- 按顺序执行每个阶段的宏任务(如先处理定时器,再处理 I/O 回调)。
- 每个阶段结束后,清空所有微任务(如
Promise.then
、process.nextTick
)。 - 进入下一个阶段,重复循环。
2. 微任务的优先级细节不同
-
浏览器:
微任务队列中,Promise.then
、MutationObserver
等优先级一致,按添加顺序执行(process.nextTick
是 Node.js 特有,浏览器不支持)。 -
Node.js:
微任务分为两个层级:- 优先级最高:
process.nextTick
(有单独的队列,不属于微任务标准,但实际优先级高于其他微任务)。 - 普通微任务:
Promise.then
、queueMicrotask
等,按顺序执行。
即 Node.js 中,每个阶段结束后,会先清空process.nextTick
队列,再清空普通微任务队列。
- 优先级最高:
3. 宏任务的执行顺序差异
-
浏览器:
不同类型的宏任务(如setTimeout
、fetch
回调、DOM
事件)在队列中按“添加时间”排序,先添加的先执行(没有明确的类型优先级)。 -
Node.js:
宏任务的执行顺序由阶段顺序决定,例如:setTimeout
(Timers 阶段)一定早于setImmediate
(Check 阶段)执行(在无 I/O 阻塞的情况下)。- I/O 回调(Poll 阶段)的执行顺序依赖于 I/O 完成的时间和阶段切换逻辑,可能与定时器任务交叉。
4. 特有任务类型
- 浏览器有渲染相关的宏任务(如
requestAnimationFrame
、MutationObserver
),Node.js 没有。 - Node.js 有文件 I/O、进程通信等特有异步任务(由
libuv
处理),浏览器通常不支持(除非通过特定 API 模拟)。
三、小结
不同宿主环境的事件循环核心逻辑(微任务优先、循环处理宏任务)一致,但阶段划分、任务优先级、特有任务类型因宿主环境的功能需求(浏览器需处理渲染,Node.js 需处理服务器端 I/O)而不同。
这种差异导致:同样的异步代码(如 setTimeout
和 setImmediate
混合)在浏览器和 Node.js 中可能出现不同的执行顺序。因此,编写跨环境代码时需注意这些细节差异。
Node.js六大事件循环阶段大白话讲解
可以把 Node.js 的事件循环机制想象成一个“永不停歇的办事员”,专门处理程序里的各种任务,让单线程的 Node.js 能高效应对多任务。
这个办事员的工作流程很有规律,会按固定的“工作阶段”一轮轮处理任务,就像工厂的流水线一样:
-
先处理定时任务(Timers阶段):比如你设置了
setTimeout
或setInterval
的任务,到了时间就会在这里被执行。 -
处理延迟的I/O回调(I/O Callbacks/Pending callbacks阶段):一些之前没处理完的I/O操作(比如读文件、网络请求)的回调,会在这里继续处理。
-
内部准备工作(Idle, Prepare阶段):这是 Node.js 自己用的阶段,我们平时开发基本不用关心。
-
处理新的I/O事件(Poll阶段):这是最忙的阶段,会接收新的I/O请求(比如新的文件读取请求、新的网络连接),并执行它们的回调。如果暂时没新任务,就会在这里等一等,直到有新任务进来。
-
处理即时任务(Check阶段):专门执行
setImmediate
安排的任务,这些任务希望在 Poll 阶段之后立刻执行。 -
处理关闭回调(Close Callbacks阶段):比如某个网络连接断开了,它的
close
事件回调会在这里处理。
除了这些阶段,还有两种“插队任务”:
- 微任务:比如
Promise.then
、process.nextTick
,它们优先级很高,每个阶段的任务一结束,就会立刻清空所有微任务,再进入下一个阶段。 - 宏任务:就是上面6个阶段处理的那些任务,比如定时器、I/O回调等,优先级比微任务低。
整个过程就像:办事员按顺序走完6个阶段(处理宏任务),每走完一个阶段就检查有没有微任务,有就全处理完,然后再继续下一个阶段,循环往复,直到所有任务都处理完。
这样一来,单线程的 Node.js 不用等一个任务做完再做下一个,而是把任务按类型放进不同的“队列”,由事件循环按规则依次处理,实现了高效的并发处理。
现在回过头去看看异步代码部分Node异步事件实例的打印结果,加深一下理解吧!
小结
- 每个阶段的处理是有上限的,这个上限不是固定数值,而是由以下逻辑动态决定:
- 优先保证 “该阶段的核心任务”(如定时器到期必须执行、I/O 回调尽量处理);
- 避免单个阶段占用线程时间过长(通过时间阈值或数量限制),确保事件循环能流转到下一阶段,保证整体任务的公平性。
- 微任务的堵塞风险:微任务会在阶段间被全部执行(无上限),若存在大量或嵌套的微任务,会阻塞事件循环进入下一阶段。这是设计特性,需通过合理控制微任务数量避免滥用。
🪽成王之路
恭喜你,来自远方的勇者啊,你已经成功打败了‘异步’大魔王👺,请收下你的奖励‘循环’圣剑🗡️。有了它你便不再怕那些藏在‘回调深渊’里的小怪👹,也能劈开‘微任务迷雾’与‘宏任务荆棘’——当你挥动剑柄,‘事件队列’的脉络会在你眼前清晰展开,‘阶段之门’会按你的意志次第开启。
从此,无论是浏览器王国里的‘渲染守卫’,还是Node.js古堡中的‘I/O骑士’,都将对你俯首帖耳。若再遇‘定时器幽灵’👻作祟,你只需以圣剑轻叩地面,‘延迟迷雾’便会散去;若‘Promise幻影’在眼前闪烁,剑柄的微光会为你指明微任务的归途。
去吧,勇者!带着这柄圣剑🗡️,你已能在异步的荒野中开辟坦途——那些曾让你困惑的‘执行顺序谜题’,如今不过是你剑下的尘埃;那些让新手望而却步的‘阻塞陷阱’,终将成为你证明实力的勋章。”全栈“宝座正在向你招手!