深入JavaScript事件循环:从调用栈到异步队列

149 阅读6分钟

一、前置知识:进程与线程

1. 核心概念:

  • 进程: CPU 在运行指令和保存上下文所需要的时间 (比如: 手机打开微信,系统在执行打开指令到加载微信的上下文环境,直到彻底关闭微信之前的这段时间,都是一个进程)

  • 线程:是进程中的一个更小的单位,指的是执行一段指令所需的时间 (比如: 打开微信聊天界面,就需要一个渲染线程,同时获取到最新的消息,需要一个网络线程)

2. 浏览器新公司开张啦!  🎉

当浏览器新开 tab 页时,就像成立了分公司,核心团队阵容:

graph LR
A[新tab进程] --> B(HTTP线程-外卖小哥)
A --> C(JS引擎线程-魔法师)
A --> D(渲染线程-装修队)

3. 致命问题:魔法师和装修队打架了!  🤼♂️

看这段代码引发的血案:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <h2>hello world</h2>
</body>
</html>

灾难现场直播 📺:

1. HTTP线程(外卖小哥)送来Vue套餐 🍱
2. JS引擎线程(魔法师)开始念咒:🔮  
   "天灵灵地灵灵——DOM给我变!"
3. 渲染线程(装修队)正刷墙:🖌️  
   "让我把这行字描漂亮...卧槽墙怎么塌了?!"

根本矛盾 ⚔️:

魔法师( JS 线程)和装修队(渲染线程)永远不能同时工作
因为魔法师随手改 DOM 结构,会让装修队的梯子突然悬空🪜

线程之间通常都可以同时工作,但是只有 JS 引擎线程和渲染线程是互斥的,但是其它线程直接是可以同时工作的。 V8 引擎默认只开启一个主线程来执行 JS 代码,即常说的“单线程”特性。

二、时间管理大师:异步与事件循环 ⏱️✨

1. 单线程的魔法师遭遇中年危机 🧙♂️💥

当JS引擎线程(魔法师)独自扛起所有任务:

- 同步代码 = 紧急文件 ✍️  
  (立刻处理:`console.log('老板催命啦!')`)
- 异步代码 = 琐碎杂事 📦  
  (定时器/网络请求:`setTimeout(买咖啡,2000)`)

致命问题
如果魔法师亲自等外卖(网络请求)、盯烤箱(定时器)——
页面直接卡成PPT!💥

2. 浏览器祭出终极大招:任务队列 📭

  • V8 在执行 JS 代码时,默认之开启一个线程工作 ( JS 是单线程的),所以考虑到执行效率, V8 会先执行同步代码,遇到异步代码,会将异步代码存放到 任务队列 ,等待 JS 引擎线程空闲时,再从任务队列中取出异步代码执行

  • 任务队列 就像是魔法师的小助理,而小助理在处理文件时(处理异步代码)也有不同的处理方法

小助理的工作清单 📋:

任务类型代表任务紧急程度
微任务Promise.then , process.nextTick , MutationObserver⚡火箭级
宏任务setTimeout, setInterval, ajax, setImmediate, I/O,UI rendering/网络请求🐢龟速级

小助理的工作流程 📭:

  1. 先执行同步代码(这属于宏任务),这个过程中遇到异步,就存入任务队列
  2. 同步执行完毕后,先执行微任务队列中的代码
  3. 微任务全部执行完毕后,有需要的情况下渲染页面
  4. 渲染完毕后,执行宏任务队列中的代码(开启了下一次的事件循环)

小助理的练习 ①

console.log(1);
new Promise((resolve) => {
  console.log(2);
  resolve();
})
  .then(() => {
    console.log(3);
    setTimeout(() => {
      console.log(4);
    }, 0);
  });
setTimeout(() => {
  console.log(5);
  setTimeout(() => {
    console.log(6);
  }, 0);
}, 0);
console.log(7);

分析

第一轮事件循环开始: 执行到 console.log(1); 时,为同步代码直接输出。接着执行new Promise((resolve) => { console.log(2); resolve(); })为同步代码直接输出。执行到 .then(() => { console.log(3); setTimeout(() => { console.log(4); }, 0); });时,因为Promise.then 为微任务,所以将其放入微任务队列中。继续执行setTimeout(() => { console.log(5); setTimeout(() => { console.log(6); }, 0); }, 0);因为setTimeout为宏任务,所以将其加入宏任务队列,接着执行 console.log(7);因为其为同步代码,所以直接输出。同步执行完毕后,先执行微任务队列中的代码,所以开始执行.then(() => { console.log(3); setTimeout(() => { console.log(4); }, 0); }); 执行到console.log(3); 因为其为同步代码,所以直接输出,执行到setTimeout(() => { console.log(4); }, 0);时,因为setTimeout 为宏任务,将其加入到宏任务列表。此时微任务已经执行完毕且没有需要渲染页面,此时执行宏任务列表。此时第一轮事件循环结束,开始执行第二轮事件循环。

第二轮事件循环开始: 开始执行setTimeout(() => { console.log(5); setTimeout(() => { console.log(6); }, 0); }, 0);执行到console.log(5); 因为其为同步代码,直接输出,执行到setTimeout(() => { console.log(6); }, 0);时,因为其为宏任务,将其放入宏任务列表。此时第一轮事件循环结束,开始执行第三轮事件循环。

第三轮事件循环开始: 执行setTimeout(() => { console.log(4); }, 0); 因为console.log(4);为同步代码,所以直接输出。此时代码已经执行完毕所以第二轮事件循环结束,开始第四轮事件循环。

第四轮事件循环开始: 执行 setTimeout(() => { console.log(6); }, 0);执行到console.log(6); 时,因为为同步代码,所以直接输出,此时所有代码执行完毕,事件循环结束。

输出结果为:

1
2
7
3
5
4
6

三、async/await:拯救程序员的时空魔法 ⏳✨

1. 为什么需要 async?回调地狱的血泪史

// 2015年之前的黑暗时代
买材料(function() {
  煮水(function() {
    下面条(function() {
      加调料(function() {
        // 回调地狱已形成 🌀
      });
    });
  });
});

async 诞生的使命
用同步写法解决异步问题,让代码像读小说一样流畅

2.async/await的核心特性

核心特性一:微任务快递柜 📦

当遇到 await

async function 煮泡面() {
  await 等水开(); // 同步执行
  // ▼ 这行代码被快递员带走 ▼
  下面条(); // 塞入微任务队列
}

就像把泡面调料包暂存快递柜,等水烧开(Promise完成)才取出

核心特性二:时间折叠术 🪄

浏览器对 await 的魔改优化:

console.log('点火'); // 同步
await 烧水();       // 同步执行!浏览器偷偷优化的结果
console.log('下面'); // 微任务

看似连续的两步,实际被折叠成不同时空执行!

总结:

  1. 会将后续的代码挤入微任务队列
  2. 浏览器将 await 的执行时间提前了(await 后面的代码要当成同步代码来看待)

四、小助理的难题

通过了解 事件循环 的流程以及 async/await 的核心特性,相信你以及有足够的能力解决下面的难题:

console.log('script start');
async function async1() {
  await async2() // 
  console.log('async1 end'); 
}
async function async2() {
  console.log('async2 end');
}
async1()
setTimeout(() => {
  console.log('setTimeout');
}, 0)
new Promise((resolve, reject) => {
  console.log('promise');
  resolve()
})
.then(() => {
  console.log('then1');
})
.then(() => {
  console.log('then2');
});
console.log('script end');

请分析输出结果,打在评论区吧!

终极总结:浏览器宇宙的生存法则 🌌

在浏览器公司里,JS魔法师🧙♂️和渲染装修队🛠️因DOM修改权大打出手,逼得V8祭出事件循环小助理📭来调度任务!小助理手握两把神器:
1️⃣ 微任务火箭筒🚀(Promise.then等)—— 随叫随到秒处理
2️⃣ 宏任务乌龟车🐢(setTimeout等)—— 慢慢排队等叫号

而async/await这个时空魔术师🎩更狡猾:
🔥 把await后的代码塞进微任务快递柜📦
⏰ 却让await前的代码伪装成同步代码搞插队!

记住这三条宇宙真理:

✨ 同步代码 > 微任务 > 渲染 > 宏任务
💡 await前半场是影帝(装同步),后半场变快递(进微任务)
🚦 魔法师和装修队永远不能同台——除非你想看DOM坍塌的灾难片!

现在快去评论区破解小助理的终极谜题吧,赌一包辣条有人会掉进await的陷阱!🌶️💥