像前任一样捉摸不定的异步逻辑,一文让你彻底看透——JS 事件循环

一、进程线程那点事儿:浏览器里的 "打工人天团"

咱们先从最基础的概念聊起。很多人写了几年 JS,还分不清进程和线程,就像分不清奶茶里的珍珠和椰果 ------ 虽然都在一个杯子里,但本质不一样。

  • 进程:可以理解为 CPU 运行指令时加载和保存上下文的时间成本,相当于一个独立的 "工作车间"

  • 线程:是 CPU 实际执行指令的时间,相当于车间里的 "打工人"

举个例子:当你多开一个浏览器标签页,就相当于新增了一个进程。每个进程里至少有这几个核心线程:

  1. 渲染线程(负责画页面)

  2. JS 引擎线程(执行我们写的代码)

  3. HTTP 请求线程(帮我们发网络请求)

这里有个关键知识点:JS 引擎线程和渲染线程是互斥的。就像同一时间只能有一个人用厕所,JS 在执行时浏览器没法没法渲染页面,渲染页面时 JS 也歇着 ------ 所以别写太耗时的同步代码,不然页面会卡死,用户体验堪比便秘。

二、V8 引擎:单线程的 "倔强打工人"

咱们写的 JS 代码主要靠 V8 引擎执行,这货有个特点:默认只开一个线程

单线程意味着什么?所有任务得排队执行。就像只有一个收银台的超市,前面的人买完单,后面的才能上。但问题来了:如果前面有人买了 100 瓶矿泉水(比如一个耗时 3 秒的网络请求),后面的人岂不是要等到天荒地老?

于是,异步机制应运而生 ------ 这是单线程逼出来的智慧:

  • 同步任务:直接执行(比如console.log

  • 异步任务:先放到任务队列排队,等主线程空闲了再处理(比如setTimeout

三、事件循环(Event Loop):异步任务的 "调度总指挥"

如果把 JS 执行过程比作演唱会,事件循环就是那个负责叫号的工作人员。它的核心工作就是协调同步任务、微任务、宏任务的执行顺序。

3.1 任务队列的 "阶级划分"

异步任务不是随便排队的,这里面有明确的 "等级制度":

  • 微任务(VIP 队列)

    • Promise.then()Promise.catch()等 Promise 回调

    • process.nextTick()(Node 环境,优先级比 Promise 还高)

    • MutationObserver(监听 DOM 变化的 API)

  • 宏任务(普通队列)

    • 整个 script 标签的代码(最开始执行的宏任务)

    • setTimeout()setInterval()

    • AJAX 网络请求

    • I/O 操作(比如读写文件)

    • UI 渲染(浏览器负责,JS 只能触发不能直接控制)

3.2 执行顺序:牢记这个 "四字口诀"

事件循环的执行规则就像医院就诊流程,记住这四步准没错:

  1. 执行同步代码(属于宏任务的一种),过程中遇到异步任务就按类型放进对应队列

  2. 清空微任务队列:同步代码执行完,立刻把所有微任务按顺序执行完毕

  3. 可能的渲染:微任务全搞定后,浏览器可能会渲染一次页面(不是每次都有)

  4. 执行下一个宏任务:从宏任务队列里取一个任务执行,然后重复 1-3 步

四、async/await:披着同步外衣的异步 "卧底"

很多新手被async/await迷惑,以为它们是同步代码,其实这俩是Promise的语法糖,本质还是异步。咱们来拆解一下:

  1. async 函数 :在函数前面加async,相当于给函数装上了 "自动包装器"------ 无论你 return 什么值,都会被包装成Promise.resolve(返回值)。比如:
js 复制代码
async function fn() { return 1 }

// 等同于

function fn() { return Promise.resolve(1) }
  1. await 关键字 :必须和async搭配使用,就像泡面和热水的关系。它的作用是:
  • 等待后面的表达式执行(如果是 Promise 就等它状态变更,不是就直接拿结果)

  • 把自己后面的代码 "踢" 到微任务队列里(这是关键!)

举个例子:

js 复制代码
async function foo() {

 await a()  // 这里会暂停,把后续代码丢进微任务队列

 b()

 console.log('hello');

}

执行到await a()时,JS 会先执行a(),然后把b()console.log('hello')打包扔进微任务队列,接着继续执行主线程的其他同步代码。

五、代码实测:用案例打脸 "想当然"

光说不练假把式,咱们用几个经典案例验证一下上面的理论。

案例 1:最基础的异步顺序

js 复制代码
let a = 1

setTimeout(() => {

 a = 2

}, 1000)

console.log(a)  // 输出:1

解析setTimeout里的代码是异步宏任务,会在同步代码console.log(a)之后执行,所以打印 1 而不是 2。

案例 2:Promise 与 setTimeout 的较量

js 复制代码
console.log(1);

new Promise((resolve) => {

 console.log(2);  // Promise构造函数是同步的

 resolve()

})

.then(() => {

 console.log(3);  // 微任务

 setTimeout(() => {

   console.log(4);  // 宏任务

 }, 0)

})

setTimeout(() => {

 console.log(5);  // 宏任务

 setTimeout(() => {

   console.log(6);  // 宏任务

 }, 0)

}, 0)

console.log(7);  // 同步代码

执行结果1 2 7 3 5 4 6

解析

  1. 先执行同步代码:12(Promise 构造函数)、7

  2. 清空微任务:3

  3. 执行宏任务队列:先执行第一个setTimeout打印5,再执行它里面的setTimeout(加入宏任务队列);然后执行then里的setTimeout打印4;最后执行最里面的setTimeout打印6

案例 3:多层 setTimeout 的执行顺序

js 复制代码
console.log(1);

setTimeout(() => {

 console.log(2);

 setTimeout(() => {

   console.log(3)  // 1秒后执行

 }, 1000)

}, 0)

setTimeout(() => {

 console.log(4)  // 2秒后执行

}, 2000)

console.log(5);

执行结果1 5 2 3 4

解析

  1. 同步代码先执行:15

  2. 第一个setTimeout(0 延迟)先进入宏任务队列,执行后打印2,同时把内部的setTimeout(1 秒)加入队列

  3. 第二个setTimeout(2 秒)后进入队列,所以在3之后执行

案例 4:async/await 与 Promise 的混合战斗

js 复制代码
console.log('script start');//1

async function async1() {

 await async2()

 console.log('async1 end');  // 5微任务

}

async function async2() {

 console.log('async2 end');  // 2同步执行

}

async1()

setTimeout(() => {

 console.log('setTimeout');  // 8宏任务

}, 0)

new Promise((resolve, reject) => {

 console.log('promise');  // 3同步执行

 resolve()

})

 .then(() => {

   console.log('then1');  // 6微任务

 })

 .then(() => {

   console.log('then2');  // 7微任务

 });

console.log('script end');  // 4同步执行

执行结果script start async2 end promise script end async1 end then1 then2 setTimeout

解析

  1. 同步代码阶段:打印script start → 调用async1()执行async2()打印async2 end → 遇到await,把async1 end丢进微任务 → 执行Promise构造函数打印promise → 打印script end

  2. 微任务阶段:先执行async1 end → 再执行第一个then打印then1 → 触发第二个then打印then2

  3. 宏任务阶段:执行setTimeout打印setTimeout

案例 5:async 函数中的定时器

js 复制代码
function a() {

 return new Promise((resolve) => {

   setTimeout(() => {

     console.log('a');  // 1秒后执行的宏任务

     resolve()

   }, 1000)

 })

}

function b() {

 console.log('b');

}

async function foo() {

 setTimeout(() => {

   console.log('c');  // 1.5秒后执行的宏任务

 }, 1500)

 await a()  // 等待a()的Promise完成

 b()  // 微任务

 console.log('hello');  // 微任务

}

foo()

执行结果a b hello c

解析

  1. 调用foo()后,先执行里面的setTimeout(1.5 秒)加入宏任务队列

  2. 执行await a()时,先运行a()里的setTimeout(1 秒)加入宏任务队列

  3. 1 秒后,a()setTimeout执行,打印aresolve,此时b()console.log('hello')作为微任务执行

  4. 1.5 秒时,foo()里的setTimeout执行,打印c

六、避坑指南:这些 "坑" 我替你踩过了

  1. setTimeout (0) 不是立即执行:它只是告诉 JS"尽快",但必须等同步代码和微任务都执行完。实际延迟可能大于 0,取决于主线程忙碌程度。

  2. Promise 构造函数是同步的 :只有.then().catch()里的回调才是微任务。别看到new Promise就以为里面的代码是异步的。

  3. 多个 setTimeout 的执行顺序:不一定按代码顺序,取决于延迟时间和事件循环的时机。比如延迟 100ms 的可能比延迟 50ms 的后执行,如果前者先被加入队列但延迟更长。

  4. async/await 的错误用法 :如果await后面跟的不是 Promise,它就失去了 "等待" 的意义,后续代码会立即执行(相当于没加await)。

  5. 微任务的嵌套执行:在微任务里再添加微任务,会继续在当前微任务阶段执行,直到清空所有微任务。就像在医院急诊室里又新增了急诊病人,还是会优先处理。

七、总结:事件循环的 "一句话精髓"

同步代码先执行,遇到异步分两队(微任务、宏任务);同步干完清微队,微队清空看宏队,循环往复不停歇。

理解了事件循环,你会发现 JS 的异步行为其实非常有规律,就像广场舞大妈的舞步 ------ 看似杂乱,实则章法严明。下次再遇到代码执行顺序不符合预期,不妨画个任务队列图,一步一步分析,再复杂的情况也能迎刃而解~

相关推荐
Tzarevich1 小时前
JavaScript 继承与 `instanceof`:从原理到实践
javascript
Cache技术分享1 小时前
260. Java 集合 - 深入了解 HashSet 的内部结构
前端·后端
前端老宋Running1 小时前
你的代码在裸奔?给 React 应用穿上“防弹衣”的保姆级教程
前端·javascript·程序员
汤姆Tom1 小时前
前端转战后端:JavaScript 与 Java 对照学习指南(第四篇 —— List)
前端·编程语言·全栈
FinClip1 小时前
当豆包手机刷屏时,另一场“静悄悄”的变革已经在你手机里发生
前端
前端老宋Running1 小时前
“求求你别在 JSX 里写逻辑了” —— Headless 思想与自定义 Hook 的“灵肉分离”术
前端·javascript·程序员
阿珊和她的猫1 小时前
深入理解 HTML 中 `<meta>` 标签的 `charset` 和 `http-equiv` 属性
前端·http·html
alamhubb1 小时前
前端终于不用再写html,可以js一把梭了,我的ovs(不写html,兼容vue)的语法插件终于上线了
javascript·vue.js·前端框架
汉堡大王95271 小时前
告别"回调地狱"!Promise让异步代码"一线生机"
前端·javascript