一文搞懂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的事件循环,但对于更大的 浏览器进程 没有什么介绍。

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

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

相关推荐
栈老师不回家16 分钟前
Vue 计算属性和监听器
前端·javascript·vue.js
前端啊龙22 分钟前
用vue3封装丶高仿element-plus里面的日期联级选择器,日期选择器
前端·javascript·vue.js
一颗松鼠26 分钟前
JavaScript 闭包是什么?简单到看完就理解!
开发语言·前端·javascript·ecmascript
小远yyds1 小时前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
阿伟来咯~2 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端2 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱2 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai2 小时前
uniapp
前端·javascript·vue.js·uni-app
也无晴也无风雨2 小时前
在JS中, 0 == [0] 吗
开发语言·javascript
bysking3 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js