一文搞懂JS 事件循环 —— Event Loop 机制(含面试题实战)

一、前言

JS 作为单线程脚本语言,在面对主线程阻塞时,需要借助事件循环,即 Event Loop 来同时执行其他任务,本文将基于此为大家梳理一下相关的知识点,后面还有一系列的面试题供大家食用~

二、详解事件循环

2.1 JS为何为单线程?

JS 起初是为了在浏览器中运行脚本而设计的,保持简单和易用,并避免多线程带来的复杂性和安全问题。如:两个线程同时对一个DOM对象进行处理。

2.2 什么是执行栈、主线程、任务队列

让我们先初步了解一些概念:

  • JS 分为同步任务、异步任务
  • JS的任务都在执行栈顺序执行
  • 执行至同步任务,进入主线程;执行至异步任务,将被加入任务队列(Event Queue)中
  • 执行栈中所有任务执行完毕,系统读取任务队列,将异步任务的回调事件添加到执行栈中,如此反复循环

我们借用流程图来进一步理解一下:

接下来咱们趁热打铁,来到题目尝尝:

js 复制代码
setTimeout(() => {
  console.log(0)
}, 0)

const p = new Promise(r => console.log(3))

Promise.resolve(1).then((res) => {
  console.log(res)
})

console.log(2)

我们来解析一下流程:

  • 第一步:setTimeout 为异步任务,存入任务队列;p的 resolve 执行,输出3;Promise 对象执行 then,为异步任务,存入队列;最后同步任务直接打印2。
  • 第二步:此时执行栈为空,检查任务队列,存在 setTimeoutPromise.then,此处考查到宏任务和微任务的概念,后面会详细解释,此时 Promise.then 作为微任务,回调事件优先于宏任务 setTimeout 进入执行栈,顺序执行后分别输出1、0。最后得到输出顺序为:3、2、1、0。

是不是对此有比较明确的认知了?那接下来填一下刚刚题目里埋的坑吧!

2.3 宏任务、微任务

2.3.1 宏任务

其实每一个 script 脚本都是一个宏任务(即执行栈),每一个宏任务都会从头到尾执行完成,不会执行别的任务。因此,JS脚本线程GUI渲染线程 是一个互斥的关系,为此浏览器在每一次宏任务执行完成后,执行GUI渲染,渲染完成后再进行下一轮宏任务,如此反复......

以下是常见的宏任务:

  • script
  • setTimeout、setInterval
  • I/O 操作(例如读取文件、发送请求)
  • UI 渲染(绘制、重布局等)

举个🌰,开发者讲某dom元素的宽度初始值为50px,通过点击事件将宽度修改为100px,同时写一个 setTimeout 将宽度修改为150px,该dom元素会在点击后宽度变为100px,并在规定时间后宽度变为150px。

2.3.2 微任务

事实上任务队列分为:宏任务队列、微任务队列 微任务队列用于处理 Promis 的回调函数、async/awaitMutationObserver 等产生的微任务。当执行栈中的任务为空时,会优先检查微任务队列,并依次执行队列中的所有微任务。 在每个宏任务执行结束后,会检查是否存在微任务队列,并在宏任务下一个周期执行之前执行微任务队列中的所有微任务。这样的机制保证了微任务的优先级更高,可以在 UI 渲染之前执行,使页面得到更新。

我们再回到上一个🌰,如果我们把 setTimeout 改为 Promise.then ,那么dom元素在点击后会直接变为150px(因为微任务执行在GUI渲染之前)

与此同时,前面 setTimeoutPromise.then 的输出顺序在这里也得到了很好的解释。

以防读者还有不清晰的地方,我总结了一个流程图给大家过目:

三、面试题实战

既然都清楚了Event Loop的机制,接下来就来点题目尝尝吧~ (持续更新中) 在此之前,如果大家对Promise理解还不够深入,可以看看这篇文章详解Promise ------ 手撕源码

3.1 基础

js 复制代码
setTimeout(()=>{
   console.log(0) 
},0)
new Promise((resolve)=>{
    console.log(1)
    resolve()
}).then(()=>{
   console.log(2) 
}).then(()=>{
   console.log(3) 
})
console.log(4) 

这里我分析一下执行过程:

  • 第一步:主线程输出1、4,任务队列存入sT、Promise.then
  • 第二步:存在微任务,输出2、3
  • 第三步:执行宏任务,输出0
  • 输出:1、4、2、3、0

是不是很easy,那我们再来点难度吧👊

3.2 深入微任务

js 复制代码
new Promise((resolve,reject)=>{
    console.log("p1-0")
    resolve()
}).then(()=>{
    console.log("p1-1")
    new Promise((resolve,reject)=>{
        console.log("p2-0")
        resolve()
    }).then(()=>{
        console.log("p2-1")
    }).then(()=>{
        console.log("p2-2")
    })
}).then(()=>{
    console.log("p1-2")
})

老样子:

  • 第一步:Promise1输出p1-0,Promise1.then进入微任务队列
  • 第二步:执行Promise1.then输出p1-1,读取到Promise2,输出p2-0,Promise2.then进入微任务队列;Promise1.then读取完毕,Promise1.then.then进入微任务队列,此时微任务队列的顺序为:[Promise2.then,Promise1.then.then]
  • 第三步:执行栈为空,执行微任务队列,输出p2-1,Promise2.then.then进入微任务队列,输出p1-2,此时微任务队列的顺序为:[Promise2.then.then]
  • 第四步:执行栈为空,执行微任务队列,输出p2-2
  • 输出:p1-0 p1-1 p2-0 p2-1 p1-2 p2-2

这一题清晰的体现了微任务的执行方式,接下来我们结合一下宏任务做一道题~

3.3 结合宏任务

js 复制代码
new Promise((resolve, reject) => {
  console.log("p1-0")
  resolve()
}).then(() => {
  setTimeout(() => {
    console.log('macrotask-1')
    new Promise((resolve, reject) => {
      console.log("p2-0")
      resolve()
    }).then(() => {
      setTimeout(() => {
        console.log('macrotask-2')
      }, 0)
      console.log("p2-1")
    }).then(() => {
      console.log("p2-2")
    })
  }, 0)
  console.log("p1-1")

}).then(() => {
  console.log("p1-2")
})

不要慌,按照老样子一步一步分析即可:

  • 第一步:Promise1输出p1-0,Promise1.then进入微任务队列
  • 第二步:sT1进入宏任务队列,输出p1-1,Promise1.then.then进入微任务队列
  • 第三步:执行栈为空,执行微任务队列,输出p1-2;执行宏任务队列,输出macrotask-1,p2-0,Promise2.then进入微任务队列
  • 第四步:sT2进入宏任务队列,输出p2-1,Promise2.then.then进入微任务队列
  • 第五步:执行栈为空,执行微任务队列,输出p2-2;执行宏任务队列,输出macrotask-2
  • 输出:p1-0、p1-1、p1-2、macrotask-1、p2-0、p2-1、p2-2、macrotask-2

接下来咱们再玩点好玩的

3.4 结合async/await

js 复制代码
async function async1() {
  console.log("a1-0");
  await async2();
  console.log("a1-1");
  setTimeout(() => {
    console.log('macro-2')
  }, 0)
}
async function async2() {
  console.log("a2-0");
  setTimeout(() => {
    console.log('macro-3')
  }, 0)
}

setTimeout(() => {
  console.log('macro-1')
}, 0)
async1();

看到async/await不要慌,转换为Promise观察即可 (ps:还有一个土方子:await后面都作为等待内容)

  • 第一步:sT1进入宏任务队列,执行async1(),输出a1-0,后续内容存入微任务队列,执行async2(),输出a2-0,st3进入宏任务队列
  • 第二步:执行栈为空,执行微任务队列,输出a1-1,sT2进入宏任务队列;执行宏任务队列,输出macro-1,macro-2,macro-3
  • 输出:a1-0、a2-0、a1-1、macro-1、macro-2、macro-3

接下来的题目是我在看一位大佬的文章后打算补充的,也算是进一步提高一下大家对Promise的熟练度 原文在这里

3.5 Promise.all

js 复制代码
function runAsync(x) {
  return new Promise((resolve, _) => {
    setTimeout(() => {
      console.log(x)
      resolve(x)
    }, 1000)
  })

}
Promise.all([runAsync(1), runAsync(2), runAsync(3)]).then((res) => {
  console.log('res', res)
})

已知Promise.all会在所有的异步任务完成后再执行回调。

  • 第一步:all遍历传入的数组,执行每一个Promise对象,分别将sT1、sT2、sT3存入宏任务队列
  • 第二步:执行栈为空,执行宏任务队列,输出1、2、3;all的所有异步任务完成,执行回调,输出res [1,2,3]
  • 输出:1、2、3、res[1,2,3]

3.6 Promise.race

js 复制代码
function runAsync(x) {
  return new Promise((resolve, _) => {
    setTimeout(() => {
      console.log(x)
      resolve(x)
    }, 1000 * x)
  })

}
Promise.race([runAsync(1), runAsync(2), runAsync(3)]).then((res) => {
  console.log('res', res)
})

已知Promise.race会在第一个异步任务完成后马上执行回调,不返回其他的异步任务结果(但是会执行)

  • 第一步:race遍历传入的数组,执行每一个Promise对象,分别将sT1、sT2、sT3存入宏任务队列
  • 第二步:sT1优先完成,输出1,race执行回调,输出res 1,后续逐个输出2、3
  • 输出:1、res 1、2、3

四、结语

本文帮大家梳理了 JS的事件循环,但对于更大的 浏览器进程 没有什么介绍。

这边还是建议货比三家,多看看大佬们的文章,尝试独立去描述一整个事物,形成自己的认知,本文也是在我学习了很多大佬的见解后总结出来的。后续我也会基于浏览器进程写一篇文章~

最后,希望本文对大家有帮助,如果有误欢迎指出!

相关推荐
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼4 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax