JS进阶 | 深入理解事件循环(Event Loop)[多图预警]


theme: v-green

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第 3 天,点击查看活动详情

PS:前端知识系列思维导图火热更新中,快来点个收藏慢慢看吧~~ ☞ 前端知识体系思维导图 – 掘金

面试真题开开胃

请问下述代码的控制台输出是什么?

const p = new Promise((resolve) => {
  console.log('A')
  setTimeout(() => {
    console.log('F')
    resolve('123')
  }, 1000)
})

console.log('B')
p.then((res)=> {
  console.log('G')
  console.log(res)
  return new Promise((resolve) => {
    console.log('C')
    setTimeout(()=> {
      console.log('D')
      resolve('456')
      console.log('E')
    }, 1000)
  })
}).then((res)=> {
  console.log('H')
  console.log(res)
})

如果你一头雾水,那你就来对地方了,顺着文章往下读,下次面试就能对答如流啦!

如果你胸有成竹,那么来看下正确答案吧:

A
B
F
G
123
C
D
E
456

你答对了吗?

我来试着分析一下这道题。

首先执行第 1 行代码,构造了一个 Promise p,这其中的构造函数,是立即执行的。所以输出了 A,并且开始了第一个 setTimeout 的倒计时。代码来到第 9 行,输出 B。随后为 p 添加落定回调函数,并为回调函数返回的新 Promise 添加另一个回调函数。

这时候主流程代码就执行完了,进入等待状态,等待宏任务队列中下一个任务的到来。

1000ms 倒计时结束,宏任务队列增加了 setTimeout 回调函数的执行任务。所以执行第 4、5 行代码,输出 F,并将 p 的结果落定为成功,结果值为 ‘123’。因为 p 的落定,微任务队列中也添加了新的任务,为 p 的回调函数。

宏任务 setTimeout 执行完毕,开始处理 微任务队列 中的任务。执行第 11、12 行的代码,输出G输出 ‘123’ 。构建一个新的 Promise,立即执行构造函数中的代码,输出 C,开始了 setTimeout 倒计时。由于此时 setTimeout 未倒计时完成,返回的 Promise 也未落定,宏任务队列和微任务队列都是空的,继续等待。

约 1000ms 后,倒计时结束,执行第 16 行代码,输出 D,落定 Promise 的状态为成功,结果值为 ‘456’,输出 E。宏任务执行完毕,微任务队列中,因为 Promise 已落定,也有了新任务。执行 then 语句,输出 H输出 ‘456’

概念理解:什么是事件循环?

事件循环是一门单线程语言多任务调度机制。单线程带来的限制,通俗地讲,是“一次只能做一件事”。事件循环,就是为了以最合理的方式,去完成多个任务的执行。

我们来举一个现实生活中的例子来理解。

假设有这么一个餐馆,里面有好几桌顾客,但是只有一名服务员。服务员的任务有:点餐,上菜,清理桌面等。服务员怎样才能最大限度地服务好这些顾客呢?

是服务一桌,点餐->上菜->清理桌面,再服务下一桌吗?这当然是不可能的。

合理的方式是,将出现的需求列在待办清单上,一项一项去执行。比如说:A桌点餐->B桌点餐->A桌上菜->C桌点餐->B桌上菜->A桌清理桌面…

与这个场景相对应的,就是 JS 的事件循环机制。好几桌顾客、多样化的任务对应着 JS 中各种各样的任务;一名服务员对应着 JS 的单线程执行特性;待办清单,则对应着宏任务队列、微任务队列,使用两个队列,实际上是更精细地管理这些任务,实现更高的处理。

图解事件循环执行流程

初步认识了 “JS 服务员” 这位小伙伴之后,我们来视察一下它每天具体的工作:

事件循环的流程:

  • 1、运行一段代码的时候,这段代码在主线程中执行。执行过程中,遇到宏任务,就添加到宏任务队列,遇到微任务,就添加到微任务队列。

  • 2、执行完毕,首先检查并执行微任务队列中的所有任务。此过程中遇到微任务和宏任务,同上述处理方式。注意,此时遇到的微任务也会在这一轮循环中执行,直至微任务队列完全清空。

  • 3、检查宏任务队列,取出最早入队的可执行的宏任务,放入主线程中执行。此过程中遇到微任务和宏任务,同上述处理方式。

还有些抽象,无法理解?没有关系,我们再来看几个具体的图:

图1:主线程中有一段代码等待执行。

图2:主线程代码执行,在这个过程中遇到了宏任务 A,交给浏览器辅助线程(后面会详细介绍)执行倒计时任务;遇到了微任务 D,放入微任务队列。

图3:主线程代码全部执行完毕,查看微任务队列。执行微任务 D。

图4:微任务 D 中触发了宏任务 D,由于需要计时,交给浏览器辅助线程。

图5:此时主线程已执行完毕,微任务队列和宏任务队列均为空,进入等待状态。经过 1s(代码运行速度很快,相较于 1s 的时间可以忽略不计,所以说等待时长为 1s),宏任务 A 到达可执行状态,放入宏任务队列中。

图6:宏任务 A 执行,产生了微任务 B,放入微任务队列;产生了宏任务 C,进入计时状态,由于计时时长是 0s,所以马上 C 就进入了宏任务队列。

图7:主线程代码执行完毕,处理微任务队列。执行微任务 B。

图8:微任务队列清空,处理宏任务队列。执行宏任务 C。

图9:微任务队列和宏任务队列均已清空,进入等待状态。计时完成后,E进入宏任务队列。

图10:E 进入主线程执行。

所以以上宏任务/微任务执行的顺序是:D A B C E

我们再来看刚刚这一段流程,对应的代码是怎么样的。(进入每个宏任务/微任务时,会打印出自己的字母代号)

const p = new Promise((resolve) => {
  setTimeout(() => {
    console.log('A')
    resolve('B')
    setTimeout(() => {
      console.log('C')
    }, 0)
  }, 1000)
})

Promise.resolve('D').then((res) => {
  console.log(res)
  setTimeout(()=> {
    console.log('E')
  }, 2000)
})

p.then(res => {
  console.log(res)
})

代码的输出就是

宏任务和微任务

具体有哪些?

macrotask 宏任务

  • DOM manipulation: DOM操作

  • User interaction:交互事件 事件回调 (addEventListener 的回调函数)

  • networking:网络事件 XHR回调

  • History traversal:历史回溯

  • setTimeout / setInterval

  • indexDB 数据库操作等 I/O

不同的 task 可能会放到不同的 task 队列中,例如浏览器可能单独为鼠标键盘事件维护一个 task 队列,所有其他 task 都放到另一个队列。通过区分 task 队列的优先级,使优先级高的 task 先执行,保证更好的交互体验。

Microtask 微任务

  • Promise async/await

  • MutationObserver

  • Object.observe (已移除)

  • queueMicrotask

只要微任务队列不为空,就会一直执行下去。也就是说,在微任务执行的时候,又有新的微任务入队的话,会在下一轮 task 之前执行。

思考题:setTimeout/ setInterval 的计时,是准确的吗?

想必看到这里,你对事件循环的流程有了比较清晰的了解,也对宏任务、微任务的划分有了详细的认知。那我可要考考你了,思考这样一个问题:setTimeout/ setInterval 的计时,是准确的吗?

答案是,setTimeout / setInterval 的计时不准确的。当计时完成后,任务进入宏任务队列,需要等待主线程代码执行完毕、微任务队列清空、当前任务前面所有的宏任务执行完毕后,才能执行当前任务。所以回调函数执行的时机,总是会大于等于定时时间,而且 setInterval 还有这严重的误差积累问题。

拓展:如何解决 setInterval 带来的误差积累问题?

使用 setTimeout 实现 setInterval 的功能,并与系统时间比对,每次校准误差。代码实现如下:


function v2(gap = 1000) {
  let count = 0
  
  function timer() {
    count += 1
    const now =  new Date().getTime()
    let offset = now - startTime - gap * count

    let nextGap = gap - offset
    if(nextGap < 0) nextGap = 0

    setTimeout(() => {
      timer()
    }, nextGap)
  }
  setTimeout(timer, gap);
}

深入浏览器架构

image.png

浏览器四大进程

  • Browser 进程:浏览器的主进程(负责协调、主控,只有一个)
    • 负责浏览器界面显示,与用户交互
    • 各个页面管理
    • 创建和销毁其他进程
    • 将渲染进程得到的内存中的 bitmap 绘制到用户界面上
    • 网络资源的管理、下载等

  • 第三方插件进程

  • GPU 进程

  • 浏览器渲染进程:页面渲染,脚本执行,事件处理

渲染进程包括哪些线程?

  • GUI 渲染管线

负责渲染浏览器界面:解析 HTML,CSS,构建 DOM 树和 Render 树,布局和绘制等。

GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起。

  • JS 引擎线程(单线程)

如谷歌浏览器的 v8 引擎。

JS 引擎线程负责解析 JavaScript 脚本,运行代码

JS 引擎一直等待着任务队列中任务的到来,然后加以处理。

  • 事件触发线程

并不归属于 JS 引擎线程哦~

用来控制事件轮询

当 JS 引擎执行代码块如鼠标点击、AJAX 异步请求等,会将对应任务添加到事件触发线程中。当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理任务队列的队尾,等待 JS 引擎的处理。

  • 定时器触发线程

定时器 setInterval 与 setTimeout 所在线程

浏览器定时计数器并不是由 JavaScript 引擎计数的(因为 JavaScript 引擎是单线程的,如果任务队列处于阻塞线程状态就会影响计时的准确)

同样的,计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行。

  • 异步 http 请求线程

用于处理请求 XHR,在连接后是通过浏览器新开一个线程请求

检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更时间,将这个回调再放入 JS 引擎线程的事件队列中,再由 JavaScript 引擎执行。

为什么 GUI 渲染进程跟 JS 引擎互斥?

由于 JS 是可以操作 DOM 的,如果再修改这些元素属性同时渲染界面,那么渲染线程前后获得的元素数据就可能不一致了。

为了防止渲染出现不可预期的结果,浏览器设置二者互斥。

为什么 JS 引擎是单线程的?

与它的用途有关。主要用途是与用户互动,操作 DOM。这决定了它只能是单线程了,否则会带来很复杂的同步问题。

为了利用多核 CPU 的计算能力,HTML5 提出 web worker 标准,允许 JS 创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。这个新标准并没有改变 JS 单线程的本质。

一些练习题:做完这些相信你就完全掌握事件循环啦!

例题 1

setTimeout(function () {
  console.log("setTimeout1");

  new Promise(function (resolve) {
      resolve();
  }).then(function () {
      new Promise(function (resolve) {
          resolve();
      }).then(function () {
          console.log("then4");
      });
      console.log("then2");
  });
});
console.log(0)

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("then1");
});
console.log(1)

setTimeout(function () {
  console.log("setTimeout2");
});

console.log(2);

queueMicrotask(() => {
  console.log("queueMicrotask1")
});

new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then3");
});

// -- 其他线程  // promise1 
// -- 同步脚本  // 2 
// 微任务环节  // then1  // queueMicrotask1  // then3 
// 第二轮  // 宏任务  // setTimeout1  // 微任务  // then2  // then4 
// 第三轮  // setTimeout2

例题 2

async function async1() {
  console.log('async1 start')
  await async2();
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

// 宏任务1 setTimeout(function () {
  console.log('setTimeout')
}, 0)

async1();

new Promise(function (resolve) {
  console.log('promise1')
  resolve();
}).then(function () {
  console.log('promise2')
})

console.log('script end')

// script start  // async1 start  // async2  // promise1  // script end 
// 微任务1  // async1 end  // 微任务2  // promise2  // 微任务3 
// setTimeout

解析:理解 async 语法: await 后的语句相当于 then 方法内的;构造器是立即执行的。

例题 3

Promise.resolve().then(() => {
  console.log('promise1');
  const timer2 = setTimeout(() => {
    console.log('timer2')
  }, 0)
});
const timer1 = setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(() => {
    console.log('promise2')
  })
}, 0)
console.log('start');

// 'start'
// 'promise1'
// 'timer1'
// 'promise2'
// 'timer2'

PS:前端知识系列思维导图火热更新中,快来点个收藏慢慢看吧~~ ☞ 前端知识体系思维导图 – 掘金

参考资料

https://www.youtube.com/watch?v=8aGhZQkoFbQ

https://www.youtube.com/watch?v=EI7sN1dDwcY

Event Loop 運行機制解析 – 瀏覽器篇

HTML Standard

[面试题]事件循环经典面试题解析 – it610.com

4种方案详解如何实现准时的setTimeout_前端开发博客的博客-CSDN博客

浏览器的回流与重绘 (Reflow & Repaint) – 掘金

浏览器四大进程 – ygunoil – 博客园

blog.csdn.net

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容