我们在前几篇文章拆解了事件的定义、EventTarget接口、事件传播机制,这一篇我们终于要聊整个JS异步体系最核心的概念:事件循环,理解了它,才能真正搞懂JS异步到底是怎么跑起来的。
一、先搞懂前提:JS天生是单线程的
很多新手会疑惑:JS既然是单线程,那异步操作到底是怎么实现的?不应该一次只能做一件事吗?
这个问题其实要拆分来看:JS本身是单线程语言,它运行在浏览器的渲染主线程中,而渲染主线程只有一个。这意味着同一时间JS只能执行一段代码,不可能多个JS函数同时并行跑。
那为什么我们能一边滚动页面,一边让定时器倒计时,还能同时发网络请求?这些异步操作不会卡住页面吗?
答案很简单:异步任务不是JS自己做的,是浏览器(宿主环境)帮忙做的。JS作为单线程只负责执行回调,复杂的异步流程交给宿主环境的多线程处理,等操作完成了,浏览器再把回调放到队列里,等JS主线程空了再拿过来执行,这套调度机制就是我们说的「事件循环」。
二、事件循环的核心流程:从调用栈到任务队列
我们用最通俗的话拆解整个事件循环的执行步骤:
同步代码先执行:所有同步任务都会直接进入调用栈,按顺序执行,执行完就出栈。
异步任务交给宿主:遇到异步操作(比如setTimeout、网络请求、点击事件),浏览器会把异步任务交给对应的API线程处理,等条件满足(比如定时器倒计时到了、网络请求返回了、用户点击了按钮),就会把对应的回调函数放到对应的任务队列里排队,不打扰当前主线程执行。
调用栈空了才处理异步:只有当调用栈完全清空,所有同步代码执行完,事件循环才会从任务队列里取出排队的回调,放到调用栈执行。
循环往复永不停止:执行完当前回调后,调用栈再次清空,事件循环继续检查队列,取出下一个任务执行,一直循环下去。
整个流程可以总结成一句话:同步执行先占坑,异步做完排队等,栈空才拿出来跑,循环往复不停歇。
三、异步任务分两类:微任务和宏任务的优先级差异
很多人搞不清事件循环,最大的误区就是不知道异步任务其实分两类,优先级完全不一样:微任务和宏任务,我们先给大家整理清楚常见分类:
表格
任务类型 优先级 常见举例
微任务 高 Promise.then、queueMicrotask、MutationObserver
宏任务 低 setTimeout、setInterval、I/O操作、DOM事件、requestAnimationFrame
核心执行规则只有一条:每次当前调用栈执行完之后,必须先把当前所有排队的微任务全部执行完,才会去执行下一个宏任务。
我们用最经典的面试题来验证这个规则,看看下面这段代码:
javascript
console.log("Start");
setTimeout(() => {
console.log("setTimeout Callback");
}, 0);
Promise.resolve().then(() => {
console.log("Promise Resolved");
});
console.log("End");
我们一步步拆解执行顺序:
同步执行console.log("Start"),输出Start,出栈。
遇到setTimeout,交给浏览器定时器线程处理,回调放到宏任务队列等待,主线程继续往下走。
遇到Promise.then,回调放到微任务队列排队,主线程继续往下走。
同步执行console.log("End"),输出End,出栈。此时调用栈已经清空。
按照规则:调用栈清空后,先处理所有微任务,取出Promise.then回调执行,输出Promise Resolved,微任务队列清空。
微任务全部处理完,才去宏任务队列取出setTimeout的回调执行,输出setTimeout Callback。
最终的输出顺序就是:Start → End → Promise Resolved → setTimeout Callback,这完全符合我们的规则,很多新手会以为setTimeout写了延迟0毫秒就会先执行,实际上就算延迟0,它也是宏任务,必须等所有微任务执行完才会跑。
四、更完整的例子:多层嵌套的事件循环
我们再看一个稍微复杂一点的例子,帮大家彻底理清楚嵌套场景的执行逻辑:
javascript
function a(){
console.log(1);
Promise.resolve().then(function(){
console.log(2);
});
}
setTimeout(function(){
console.log(3);
Promise.resolve().then(a)
},0);
Promise.resolve().then(function(){
console.log(4);
});
console.log(5);
我们一步步推执行顺序:
同步执行,先输出5,调用栈清空。
处理当前所有微任务:第一个微任务输出4,微队列为空。
处理第一个宏任务:setTimeout回调执行,输出3,执行过程中遇到新的Promise.then,把a放到微队列。
当前宏任务执行完,调用栈清空,再次检查微队列,发现有排队的a,执行a输出1,执行过程中又产生一个新的Promise.then,放到微队列。
a执行完,再次检查微队列,取出新的Promise.then执行,输出2,微队列清空。
所有任务执行完成。
最终输出顺序就是:5 → 4 → 3 → 1 → 2,完全符合我们说的规则——每一轮宏任务执行结束后,都会清空当前所有微任务,再进入下一轮循环。
五、Web Worker:真·多线程的补充方案
事件循环解决了单线程下的异步调度问题,但如果我们要跑一个非常 heavy 的计算任务,还是会卡住主线程,导致页面无法交互,这时候就要用到JS提供的真多线程方案:Web Worker。
Web Worker的原理其实很简单:它会给你开一个独立的线程,跑单独的JS解释器,有自己独立的事件循环和任务队列,不会占用主线程资源,大计算任务丢给Web Worker,跑完了再通过消息传递把结果发给主线程,完全不会卡住页面。
而且为了效率,Web Worker支持转移大体积数据的所有权,不需要复制整个数据缓冲区,直接把内存所有权转移给对方,大大提升了大数据传输的性能。
需要注意的是,Web Worker不能直接操作DOM,也拿不到主线程的变量,只能通过消息传递通信,这恰恰保证了线程安全,避免了多线程竞争的问题。
六、总结:事件循环的本质是什么
说了这么多,我们最后提炼一下核心要点:
JS本身是单线程,依托浏览器(宿主)的多线程能力实现异步,事件循环就是这套调度的核心规则。
同步代码优先执行,调用栈清空后,先清空所有微任务,再执行下一个宏任务,这是铁律,记牢这一点就能搞定90%的事件循环问题。
不同优先级的任务划分,本质是为了让高优先级的异步响应(比如接口返回后的状态更新)更快执行,提升用户体验。
真重负载任务可以交给Web Worker,独立线程不卡主线程,适合大计算场景。
理解了事件循环,你才算真正摸懂了JS异步的底层逻辑,以后遇到异步执行顺序的问题,按规则一步步推就绝不会错。