JS 异步编程:事件循环与 Promise
发布于: 2023/12/04
字数: 0
status
type
category
tags
slug
date
summary
icon

什么是异步编程

假设你正在准备晚餐,如果以同步的方式工作:你必须等鸡肉烤好后才能去煮意大利面。
但为了提高效率,可以通过使用异步操作:将鸡肉放进烤箱开始烤后设置一个计时器,并在在等待鸡肉的过程中,开始准备意大利面。
🐌
为什么 JS 需要异步编程?
  • JS 最初被设计为在浏览器中运行,它是单线程的,这意味着它一次只能执行一个任务。在执行长时间操作(如网络请求、文件读写等)时,整个程序将停滞,直到操作完成。
  • 为了避免阻塞这个线程,JS 使用了事件循环,允许非阻塞的操作,即异步编程的核心。

游览器的事件循环

Event Loop:一种用来管理多个事件和异步操作的机制
同步和异步任务会进入不同的执行环境
  • 同步的进入主线程,即主执行栈 (Call Stack)
  • 异步的进入任务队列 (Queue)
📢
主要有两个任务队列

微任务

Micro-tasks
  • Promise .then().catch().finally() 回调📦详见下文)
  • async/await 语法中的 await 关键字后面的代码
  • MutationObserver 监听 DOM 变化时的回调函数

宏任务

Macro-tasks
  • 定时器事件:setTimeoutsetInterval
  • 渲染事件:解析 DOM、计算布局、绘制
  • 交互事件:如鼠标点击、键盘事件
  • I/O 操作:网络请求、文件读写
📢
事件循环的每个循环中,同步任务 → 微任务 → 宏任务

例子

🔄
输出顺序 1 → 3 → 2
(即使延迟时间为 0,回调也只能在所有同步代码执行完毕后。)

Node.js 的事件循环

📢
Node.js 使用 C 语言构建的 Libuv 库实现 Event Loop ,且涉及更多任务队

process.nextTick

会在当前操作完成后立即执行,并且优先于事件循环的任何阶段,包括微任务队列。
  • 适用于希望在当前操作之后、事件循环的下一轮开始之前执行代码的场景,在异步操作中保持操作顺序。

微任务

(与游览器相同)

宏任务

📢
分为以下类型,需要按不同优先级执行。
  • Timers setTimeout setInterval 的回调。
    • 进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就回调函数,否则就这个阶段。
  • Pending Callbacks:处理延迟执行的 I/O 回调。
  • Idle, Prepare:内部操作,普通的用户代码不涉及。
  • Poll:轮询时间,执行与 I/O 相关的回调。比如服务器的回应、用户移动鼠标等。该阶段的时间较长,如果没有其它异步任务要处理(比如),会一直停留在这。
    • 即需要注意的是 poll 阶段的后面不一定是 check 阶段
  • Check setImmediate (Node.js 特有函数,JS 本身没有)的回调。
  • Close Callbacks:一些关闭的回调,例如 socket.on('close', ...)

每个阶段都有一个先进先出的回调函数队列。只有一个阶段的回调函数队列清空了,即该执行的回调函数都执行了,事件循环才会进入下一个阶段。
notion image

例 1

🔄
(通常)输出顺序 1 → 2
🔄
(一定)输出顺序 2 → 1
为什么前者结果是不确定,偶尔会产生 2 → 1?
  • Node.js 做不到完全的 0 毫秒。根据官方文档,第二个参数的最小取值会被默认设为 1 毫秒。也就是说,setTimeout(f, 0) 等同 setTimeout(f, 1)
  • 因此进入事件循环以后,timer 的执行顺序取决于系统当时的状况。
    • 如果同步代码的执行时间刚好等于 1 毫秒,那么 setTimeout 已经到时了,所以当进入宏任务时,会先执行 timer,即 setTimeout 优先于 setImmediate 执行。
    • 如果同步代码的执行时间稍微大于 1 毫秒,那么 timer 阶段就会跳过,进入 check 阶段,优先先执行 setImmediate 的回调函数。
为什么后者结果是一定的?
  • 同步代码的执行时间一定小于 timer 的 5 毫秒,所以宏任务会顺利进入 poll → check 阶段,执行完 setImmediate ,最后才绕回去执行 setTimeout

例 2

🔑
输出顺序是 6 → 7 → 5 → 1 → 2 → 4 →3

什么是 Promise

💣
Promise 出现前,JS 的异步编程依靠
回调函数容易导致代码结构混乱,产生 “回调地狱” (Callback Hell)
notion image
可以想象 Promise 成一个未来某个时刻可能得到结果的承诺。
它有三种状态:
  • Pending(待解决):初始状态。
  • Fulfilled(已兑现):操作成功。
  • Rejected(已拒绝):操作失败。
它有以下特点:
  • 不可取消:如果有一个长时间运行的异步操作,无法中途停止。
  • 错误处理:如果不设置回调函数,Promise 内部抛出的错误,不会反应到外部。
  • 单次解决: Promise 只能解决或拒绝一次,它们无法处理重复发生的事件。

基本构造

  • 如果 resolve 传入的是非 Promise 值,则 Promise 实例是 fulfilled 状态。
  • 如果 resolve 传入了另一个 Promise,当前的 Promise 将会 (adopt) 这个传入的 Promise,即等待传入的 Promise 完成状态后,采用传入 Promise 的状态fulfilledrejected

链式调用

适合处理简单的异步操作,或者当需要更精细的控制 Promise 链。
  • then 方法最多可以接受两个参数(第二个参数只捕获到它之前的 Promise 错误)
    • catch 能够捕获之前所有 Promise 链中的错误(catch 接收的也是一个 Promise)
    🖌️
    链式调用怎么实现的?
    1. 每个 .then() 方法都返回一个新的 Promise 对象,这个新的 Promise 对象可以用来连接下一个 .then() 调用,这样就形成了链式调用。
        • 如果某个 then() 回调函数没有显式返回值,那么它实际上返回的是 undefined
    1. Promise 内部使用了微任务来异步执行 .then() 方法的回调函数。这意味着每个 .then() 中的回调函数会等待当前 JS 执行栈清空后执行,这样可以确保异步操作按照正确的顺序执行。

    Async / Await

    ES8 /ES2017 中引入,建立在 Promise 之上的语法糖,允许以同步的方式编写异步代码。提供了更简洁和直观的方式来处理复杂的异步操作,特别是涉及多个步骤或条件语句。
    • async 函数:函数会隐式地返回一个 Promise。
    • await 关键字:在异步函数内使用,它会暂停函数的执行,等待 Promise 的解决。
    notion image

    Generator

    async/await 内部使用生成器函数来实现异步流程的控制 → Generator 允许函数的执行在某一点挂起(通过 yield 关键字),然后在稍后的某个时候恢复执行

    并发处理

    Concurrency methods
    Promise.all():当有多个异步操作需要执行,并希望等待它们完成时,它允许同时处理多个 Promise 对象,然后返回一个 Promise 对象。
    • Promise.race():在一组 Promise 中的解决或拒绝时就解决或拒绝,返回一个 Promise。
    • Promise.any():返回一组 Promise 中解决的结果。如果所有 Promise 都拒绝,它也拒绝。

    参考材料

     
    2023 - 2026