很多刚接触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人参与
发表评论: