挑战ChatGPT提供的全网最复杂“事件循环”面试题

932 阅读9分钟

该系列是本人准备面试的笔记,或许有描述不当的地方,请在评论区指出,感激不尽。

其他篇章:

  1. Promise.try 和 Promise.withResolvers,你了解多少呢?
  2. 从 babel 编译看 async/await
  3. Vue.nextTick 从v3.5.13追溯到v0.7.0
  4. Vue 怎么监听 Set,WeakSet,Map,WeakMap 变化?
  5. Vue 是怎么从<HelloWorld />、<component is='HelloWorld'>找到HelloWorld.vue

前言

前面已经复习了 Promise 和 async/await 两个用于处理异步操作的语法工具,所以今天来学习事件循环的知识。

images.jfif

首先,跟 ChatGPT 要了一份“事件循环”面试题,祝贺各位勇士挑战成功。

console.log('1');

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

Promise.reject('8').catch((err) => {
  console.log(err);
  return '9';
}).then(async (res) => {
  console.log(res);
  await Promise.resolve().then(() => {
    console.log('10');
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log('11');
        resolve('12');
      }, 30);
    });
  }).then((res) => {
    console.log(res);
  });
  console.log('13');
}).finally(() => {
  console.log('14');
});

(async () => {
  console.log('15');
  await new Promise((resolve) => {
    console.log('16');
    setTimeout(() => {
      console.log('17');
      resolve();
    }, 20);
  });
  console.log('18');
})();

setTimeout(() => {
  console.log('19');
  Promise.resolve().then(() => {
    console.log('20');
    return Promise.reject('21');
  }).catch((err) => {
    console.log(err);
    return '22';
  }).then((res) => {
    console.log(res);
    return new Promise((resolve) => {
      console.log('23');
      setTimeout(() => {
        console.log('24');
        resolve('25');
      }, 10);
    });
  }).then((res) => {
    console.log(res);
  });
}, 10);

console.log('26');

概念

同步与异步

  • 同步任务是指代码按顺序执行,每一行代码必须等待上一行代码执行完成。
  • 异步任务则不会阻塞后续代码的执行,而是将任务交由其他模块(如定时器、网络请求等)处理,等到结果返回时再执行回调。

调用栈和任务队列

  • 调用栈(Call Stack)是一个管理代码执行顺序的数据结构。当函数被调用时,它会被推入调用栈;当函数执行完成时,它会从栈中弹出。
  • 任务队列(Task Queue)用于存放异步任务的回调函数。当调用栈清空后,事件循环会从任务队列中取出一个任务放入调用栈执行。

调用栈是“服务台”,只能处理一件事。而任务队列是“待办事项箱”,分为紧急任务(微任务)和普通任务(宏任务)。 而其中的异步任务就像交给外包团队,完成后放回队列等待处理。

单线程 JavaScript

因为 JavaScript 最初被设计为一种操作 DOM 的脚本语言。多个线程同时操作 DOM 容易产生冲突(如同时添加和删除节点),单线程避免了这种复杂性。因为 JavaScript 和渲染共用主线程,所以当 JavaScript 代码运行时,浏览器的渲染引擎会暂停工作。如果某段 JavaScript 长时间执行,页面渲染和用户交互(如点击、滚动)都会被阻塞。

pngtree-original-hand-drawn-characters-are-busy-busy-nervous-and-hard-cartoon-png-image_4264486.png

事件循环

  1. 执行任务队列中的第一个任务。
  2. 处理所有微任务队列中的任务,直到微任务队列为空。
  3. 触发渲染步骤(如有必要)。
  4. 返回任务队列并重复上述流程。

事件循环就像厨房流水线,先处理紧急订单(同步和微任务),再准备次日配送(宏任务),最后打扫(渲染)。

TipsLoupe 可视化展示函数调用的执行情况。

随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法,提出了任务类型的说法:

  • 每个任务都有任务类型,同类型的任务必须在同一个队列,不同类型的任务可以分属不同队列。在一次事件循环中,浏览器可以根据实际情况从不同队列中拿取任务执行。

  • 浏览器必须准备好一个微队列,微队列中任务的优先级高于其他所有任务执行。

    • 延时队列:用于存放计时器到达后的回调任务,优先级中
    • 交互列队:用于存放用户操作后产生的事件处理任务,优先级高
    • 微队列:用户存放需要最快执行的任务,优先级最高

运行以下代码,在5秒内点击按钮,就会发现 Promise callback! > click callback! > setTimeout callback!

<html>
  <body>
    <button>click</button>
  </body>
  <script>
    document.querySelector("button").addEventListener("click", function () {
      console.log("click callback!");
    });
    window.onload = function () {
      setTimeout(function () {
        console.log("setTimeout callback!");
      }, 0);
      console.log("setTimeout end");

      Promise.resolve().then(() => {
        console.log("Promise callback!");
      });
      console.log("Promise end");

      // 堵塞,触发点击事件
      const start = Date.now();
      while (Date.now() - start < 5000) {}

      console.log("end");
    };
  </script>
</html>

image.png

Q: setTimeout 定时器是精准的吗?

A: setTimeout 并不精准,因为它的回调执行时间受到事件循环和调用栈的影响。如果主线程忙于执行其他任务,定时器回调会被延迟。setTimeout 是闹钟,当闹钟响而厨师正忙着时,它只能等厨师闲下来再处理。

任务分类

宏任务

  • 初始执行的代码块
  • setTimeout / setInterval
  • UI 渲染
  • 用户交互(如 clickkeydownmousemove 等)的事件处理函数
  • 通过 MessageChannel API 传递消息时,生成的任务
  • 使用 window.postMessage 传递跨域消息时,回调会加入宏任务队列
  • 网络任务(如 XMLHttpRequestfetch
  • 文件操作(如 FileReader

微任务

  • Promise 回调
  • MutationObserver 回调
  • queueMicrotask

任务类型

根据 W3C 的最新定义,任务来源(Task Sources)被分为以下几类,用于规范不同功能的任务调度与执行顺序:

  1. DOM 操作任务来源(The DOM manipulation task source)

    • 用于处理与 DOM 操作相关的任务。
    • 例如,某些非阻塞的任务在元素被插入文档后触发。
  2. 用户交互任务来源(The user interaction task source)

    • 用于响应用户交互(如键盘输入、鼠标点击)的任务。
    • 与用户输入相关的事件(如点击事件)必须通过该任务来源的队列触发。
  3. 网络任务来源(The networking task source)

    • 用于处理网络活动触发的任务。
    • 例如,网络请求的响应处理。
  4. 导航与历史任务来源(The navigation and traversal task source)

    • 用于处理导航和历史记录相关的任务。
    • 例如,页面跳转或历史记录的遍历。
  5. 渲染任务来源(The rendering task source)

    • 专用于更新页面渲染的任务。
    • 确保浏览器按照正确的时序进行布局和绘制。

这些任务来源明确划分了不同类型的任务优先级和作用范围,有助于浏览器协调事件循环,提升性能和响应效率。

面试题解析

1. 同步任务(主线程)

  1. console.log("1"); :输出 1

  2. 遇到第一个 setTimeouttimeout 为0,将其回调注册为宏任务,排队等待。注意:如果 timeout 小于 0,则将 timeout 设置为 0;如果嵌套层级大于 5,并且 timeout 小于 4,则将 timeout 设置为 4。

  3. Promise.reject("8") :被拒绝,返回值传递至 catch 块进入微任务排队。

  4. async IIFE 块立即执行:

    1. console.log("15");:输出 15
    2. 遇到 awaitnew Promise 部分还是属于同步任务,继续同步执行。
    3. console.log("16");:输出 16
    4. 遇到 setTimeout,20 毫秒后注册回调为宏任务。
  5. 遇到 setTimeout,10 毫秒后注册回调为宏任务。

  6. console.log("26");:输出 26

当前同步任务完成,主线程清空,进入微任务阶段。

此时,队列等待情况如下,用上述序号表示:

  • 定时器模块:[1.2(0), 1.4.4(20), 1.5(10)]
  • 微任务队列:[1.3]

2. 微任务阶段

  1. 获取微任务:Promise.reject("8").catch
  2. console.log(err);:输出 8
  3. return "9";: 返回值,将 .then 添加微任务。
  4. 检查微任务队列是否为空:false
  5. 取出 .then 微任务执行。
  6. console.log(res);:输出返回值 9
  7. await Promise.resolve().then:继续添加微任务。
  8. 因为 await 原因,console.log("13"); 暂不执行。
  9. 检查微任务队列是否为空:false
  10. 取出 Promise.resolve().then 微任务执行
  11. console.log("10");:输出 10
  12. 遇到 new Promise,同步执行
  13. 遇到 setTimeout,30 毫秒后注册回调为宏任务。
  14. 微任务队列为空,结束微任务执行。

所有微任务执行完毕后,进入下一任务阶段。

  • 定时器模块:[1.4.4(20), 1.5(10), 2.13(30)]
  • 事件队列:[1.2]

3. [1.2] setTimeout 任务

上述 1.2 setTimeouttimeout 为 0,所以加入任务队列进行执行。

  1. console.log("2");:输出 2
  2. Promise.resolve().then 加入微任务队列。
  3. 结束 setTimeout
  • 定时器模块:[1.4.4(20), 1.5(10), 2.13(30)]
  • 微任务队列:[3.2]

4. 微任务阶段

  1. console.log("3");:输出 3
  2. new Promise:继续同步执行。
  3. console.log("4");:输出 4
  4. setTimeout: 40 毫秒后注册回调为宏任务
  5. 因为还未 resolve,所以 .then 暂不加入微任务队列
  • 定时器模块:[1.4.4(20), 1.5(10), 2.13(30), 4.4(40)]

10毫秒后

  • 定时器模块:[1.4.4(10), 2.13(20), 4.4(30)]
  • 事件队列:[1.5]

5. [1.5] setTimeout 任务

  1. console.log("19");:输出 19
  2. Promise.resolve().then:加入微任务队列。
  • 定时器模块:[1.4.4(10), 2.13(20), 4.4(30)]
  • 微任务队列:[5.2]

6. 微任务阶段

  1. console.log("20");:输出 20
  2. return Promise.reject("21"):将 .catch 加入微任务队列。
  3. console.log(err);:输出 21
  4. return "22";:将 .then 加入微任务队列。
  5. console.log(res);:输出 22
  6. new Promiseexecutor 同步执行。
  7. console.log("23");:输出 23
  8. setTimeout:10 毫秒后注册回调为宏任务。
  • 定时器模块:[1.4.4(10), 2.13(20), 4.4(30), 6.8(10)]

10毫秒后

  • 定时器模块:[2.13(20), 4.4(30), 6.8(0)]
  • 事件队列:[1.4.4]

7. [1.4.4] setTimeout 任务

  1. console.log("17");:输出 17
  2. resolve():将 await 至下一 await 或 函数体结束 添加为微任务。
  • 定时器模块:[2.13(10), 4.4(20), 6.8(0)]
  • 微任务队列:[7.2]

8. 微任务阶段

  1. console.log("18");:输出 18
  • 定时器模块:[2.13(10), 4.4(20)]
  • 事件队列:[6.8]

9. [6.8] setTimeout 任务

  1. console.log("24");:输出 24
  2. resolve("25");:将 .then 加入微任务。
  • 定时器模块:[2.13(10), 4.4(20)]
  • 微任务队列:[9.2]

10. 微任务阶段

  1. console.log(res);:输出 resolve 值 25
  • 定时器模块:[2.13(10), 4.4(20)]

10 毫秒后

  • 定时器模块:[4.4(10)]
  • 事件队列:[2.13]

11. [2.13] setTimeout 任务

  1. console.log("11");:输出 11
  2. resolve("12");:将 .then 加入微任务。
  • 定时器模块:[4.4(10)]
  • 微任务队列:[11.2]

12. 微任务阶段

  1. console.log(res);:输出 12
  2. 结束 Promise,将 await 剩余部分加入微任务队列。
  3. console.log("13");:输出 13
  4. 完成 .then,将 .finally 加入微任务。
  5. console.log("14");:输出 14
  • 定时器模块:[4.4(10)]

10 毫秒后

  • 事件队列:[4.4]

13. [4.4] setTimeout 任务

  1. console.log("5");:输出 5
  2. resolve("6");:将 .then 加入微任务队列。
  • 微任务队列:[13.2]

14. 微任务阶段

  1. console.log(res);:输出 6
  2. setTimeout:将回调注册为宏任务。
  • 定时器模块:[14.2(10)]
  • 事件队列:[14.2]

15. [14.2] setTimeout 任务

  1. console.log("7");:输出 7

终于结束了!!!

最后

这道题称得上全网最复杂吗?ChatGPT说它还可以出一道全宇宙最复杂的。

1732689797155.jpg

掘友们可以在评论区留下你们遇到最复杂的事件循环面试题,让大家长长见识。

记得点赞收藏评论一键三连~

1-1720751939.jpeg