
很多刚接触JavaScript的同学,写代码时总会疑惑“为什么setTimeout里的代码不是立刻执行?”“Promise.then和setTimeout谁先执行?”这些问题背后,其实都和JavaScript的事件循环机制有关,想要彻底搞懂异步代码的执行顺序、解决回调地狱或者优化性能,事件循环是必须跨过去的坎,今天咱们就把事件循环拆碎了讲,从基础概念到实际场景,一步步弄明白它到底怎么运作。
得先从JavaScript的设计场景说起,它诞生是为了在浏览器里操作DOM、做交互,如果同时有两个线程改同一个DOM,浏览器根本不知道听谁的,所以从根上,JavaScript就被设计成**单线程**——同一时间只能执行一个任务,但问题来了:如果遇到网络请求、定时器这种耗时操作,单线程干等着岂不是会“卡壳”?比如点击按钮后发请求,要是等着服务器响应期间页面不能动,用户体验直接崩了,这时候,**异步处理机制**和事件循环就派上用场了。
“调用栈”是干啥的?同步代码咋执行?
咱们写的代码,会被拆成一个个“函数调用”,这些调用会被放进一个叫调用栈(Call Stack)的地方,调用栈是“后进先出”的结构,就像堆盘子,新函数调用往上堆,执行完再往下弹。
举个例子:
function a() {
function b() {
console.log('b执行');
}
b();
console.log('a执行');
}
a();执行顺序是:先把a()压入栈,执行a里的代码时遇到b(),把b()压入栈;b执行完打印“b执行”,b弹出栈;回到a里执行console.log('a执行'),最后a弹出栈,整个过程同步代码全靠调用栈按顺序执行。
异步任务咋处理?得靠“任务队列”和“Web APIs”
但遇到异步任务(比如setTimeout、fetch请求),调用栈不能干等着,否则页面就卡了,这时候JavaScript引擎会把异步任务丢给浏览器的Web APIs(可以理解成浏览器提供的后台线程)处理,等Web APIs把异步操作完成后(比如定时器到时间、请求拿到响应),就会生成一个“任务”,丢到任务队列(Task Queue)里等着被处理。
这里得区分宏任务(Macro Task)和微任务(Micro Task)两种队列:
宏任务:可以理解成“大任务”,像
setTimeout、setInterval、DOM渲染、script标签整体执行、requestAnimationFrame这些都属于宏任务。微任务:“小任务”,优先级比宏任务高,像
Promise.then/catch/finally、MutationObserver(监听DOM变化)、queueMicrotask这些属于微任务。
事件循环的核心流程:“执行栈→任务队列”循环
事件循环(Event Loop)的核心逻辑可以简化成:只要调用栈空了,就去任务队列里捞任务执行,但实际流程要分宏任务和微任务的优先级:
先把当前宏任务(比如整个script脚本)里的同步代码全塞进调用栈执行完,调用栈空了。
执行所有微任务队列里的任务(因为微任务优先级高,要一次性清完),每个微任务执行时,可能又生成新的微任务,也得接着执行,直到微任务队列为空。
微任务全处理完后,去执行一个宏任务(比如
setTimeout的回调),执行完这个宏任务里的同步代码后,再重复步骤2,处理微任务……循环往复。
举个经典例子理解顺序:
console.log('start');
setTimeout(() => {
console.log('setTimeout宏任务');
}, 0);
Promise.resolve().then(() => {
console.log('Promise微任务');
});
console.log('end');执行步骤拆解:
第一步:调用栈执行同步代码
console.log('start')→ 打印start;遇到
setTimeout,属于宏任务,丢给Web APIs计时,时间到了丢到宏任务队列;遇到
Promise.then,属于微任务,丢到微任务队列;执行
console.log('end')→ 打印end;现在调用栈空了,先处理微任务队列:执行
Promise.then的回调 → 打印Promise微任务;微任务队列为空后,处理宏任务队列里的
setTimeout回调 → 打印setTimeout宏任务。
所以最终输出顺序是:start → end → Promise微任务 → setTimeout宏任务。
宏任务和微任务具体有哪些?
得记清楚常见的任务类型,不然遇到复杂异步代码容易懵:
宏任务(Macro Task)常见类型:
setTimeout、setInterval:定时器类;setImmediate(Node.js环境);DOM渲染、页面重绘;
script标签的整体执行(可以理解成第一个宏任务);
requestAnimationFrame(虽然和渲染有关,但属于宏任务,不过它的触发时机和屏幕刷新率同步)。
微任务(Micro Task)常见类型:
Promise的then、catch、finally回调(注意new Promise里的执行器是同步的,只有.then这些才是微任务);MutationObserver:监听DOM节点变化的回调;queueMicrotask:手动创建微任务的API(比如queueMicrotask(() => { ... }));Node.js里的
process.nextTick(它比普通微任务优先级更高,属于“nextTick队列”,但浏览器里没有)。
复杂场景下事件循环怎么玩?再看个多层嵌套例子
实际开发中,异步代码经常嵌套,比如Promise里套setTimeout,或者微任务里又生成微任务,看这个例子:
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
});
console.log('6');一步步分析执行流程:
执行同步代码:
console.log('1')→ 打印1;遇到
setTimeout(宏任务),丢到宏任务队列;遇到
Promise.then(微任务),丢到微任务队列;执行
console.log('6')→ 打印6;调用栈空了,处理微任务队列:执行
Promise.then的回调 → 打印4;
回调里又遇到setTimeout(宏任务),丢到宏任务队列;微任务队列处理完了,现在处理宏任务队列里的第一个宏任务(最开始的
setTimeout):
执行回调里的console.log('2')→ 打印2;
然后遇到Promise.then(微任务),丢到微任务队列;这个宏任务里的同步代码执行完了,现在处理微任务队列(因为每次宏任务执行完后要清微任务):
执行刚丢进去的Promise.then回调 → 打印3;微任务处理完,再处理宏任务队列里的下一个宏任务(步骤5里丢的
setTimeout):
执行回调 → 打印5。
所以最终输出顺序是:1 → 6 → 4 → 2 → 3 → 5。
事件循环对开发有啥实际影响?避坑和优化思路
理解事件循环不是为了背概念,而是解决实际问题:
避免微任务过多阻塞渲染
比如在微任务里做大量DOM操作或者循环计算,因为微任务要一次性执行完才会触发渲染(渲染属于宏任务),如果微任务耗时太长,页面会出现“掉帧”或者交互延迟,举个反面例子:
function loop() {
queueMicrotask(loop); // 无限创建微任务,页面直接卡死
}
loop();这种情况要拆分成宏任务,或者用requestIdleCallback(不过兼容性一般)。
合理利用微任务做“即时更新”
比如用户点击按钮后,要先更新状态再触发渲染,用微任务就很合适,因为微任务执行完后,下一个宏任务才是渲染,所以微任务里改DOM属性,能保证渲染时是最新的状态。
定时器不准的原因
setTimeout的回调不是“到时间就执行”,而是“到时间后丢到宏任务队列,等调用栈空了、微任务也空了才执行”,如果前面有大量任务,定时器实际执行时间会比预期晚,所以不能依赖setTimeout做高精度计时(比如动画),优先用requestAnimationFrame。
事件循环的关键逻辑
最后帮大家把核心逻辑串起来:
JavaScript单线程→同步代码走调用栈→异步任务丢给Web APIs→完成后丢到任务队列(分宏/微任务);
事件循环逻辑:调用栈空了→先清所有微任务→再执行一个宏任务→再清所有微任务→循环;
微任务优先级高于宏任务,同个宏任务里的微任务要一次性处理完,再处理下一个宏任务。
把这些逻辑吃透,再遇到异步代码执行顺序的问题,就能像“拆积木”一样一步步分析清楚,平时写代码时,也能更合理地安排异步任务,避免页面卡顿或者逻辑执行顺序出错啦~


网友回答文明上网理性发言 已有0人参与
发表评论: