你好,JavaScript 异步编程——理解 JavaScript 异步的美妙
Category(分类): JavaScript Status: 已整理
本文保留原文从“为什么需要异步”到“宏任务与微任务”的学习路线,并根据当前 ECMAScript、HTML Standard 和 Node.js 文档修正术语。文中的
Event Table、Event Queue是帮助初学者理解的示意模型,不是浏览器内部统一公开的真实数据结构。
潜心修炼一段时间的我又回来了
每天不能不写业务,但也不能只写业务。所以选择了一个自己还在学校期间没有学明白的内容进行巩固:同步与异步问题。学习一个知识之前,先给自己一个问题三连:为什么要有这个?这个怎么用?这个怎么回事?
为什么要有同步异步?
首先,JavaScript 的一段脚本代码通常在同一个执行代理(agent)的某个执行线程上按顺序执行。这也是我们常说的“JavaScript 单线程”,但不能把它理解成“浏览器只有一个线程”或“整个浏览器只有一个事件循环”。浏览器可能使用多个线程完成网络、计时器、渲染、垃圾回收等工作;Web Worker 还可以运行在独立的 JavaScript agent 中。不同 agent 不能直接共享普通 JavaScript 对象,DOM 也不能从普通 Worker 直接操作。
为什么页面脚本不默认允许多个线程同时修改同一个 DOM?如果一个线程正在根据 DOM 结构计算并写入内容,另一个线程同时删除或修改同一节点,就需要复杂的锁和一致性协议。浏览器选择让一个文档的 DOM 操作在相应的脚本执行上下文中有清晰的顺序;Worker 则适合执行计算、处理数据,再通过消息把结果交给页面线程。
但是,网络请求、图片加载、定时器和用户输入都可能需要等待。如果把等待过程写成同步阻塞代码,当前执行线程就无法处理其他脚本、输入或渲染工作。异步 API 的思路是:发起操作后先返回,让宿主继续处理其他任务;当操作完成时,再把回调或 Promise reaction 排入后续的调度流程。
因此,“异步非阻塞”不是说任务没有等待,而是说等待期间不占住当前 JavaScript 执行栈。异步回调仍然要等到宿主允许执行、并且当前任务和必要的微任务检查点完成后才能运行。
基础的内容我们讲解完毕,下面开始真正的干货
第一次了解这个主题,可以先补习定时器、Promise 和执行栈。下面的示例可以直接在浏览器控制台中运行。
setTimeout(() => {
console.log(111)
}, 500)
console.log(222)
请问打印出 111 会在什么时候?答案不是“恰好 500ms 时”。更准确地说,500 是一个不早于该时间的调度延迟;计时器到期后,回调还要等待当前脚本、已经排队的任务和微任务执行完毕。页面隐藏、浏览器节流、设备负载等因素也可能让它更晚运行。
初学时可以把过程想象成:调用 setTimeout 时,宿主登记一个计时器;达到延迟后,把相应回调安排为一个 timer task;事件循环在合适的时候选择它执行。所谓 Event Table 和 Event Queue 是这个过程的教学化名称。HTML Standard 实际上定义了多个 task source 和 task queue,用户代理可以在可运行任务之间进行选择,并不保证所有来源组成一个简单的全局 FIFO 队列。
如果当前调用栈还没有执行完,计时器回调不会插入当前函数中间。它必须等当前 task 结束,随后才有机会被选中。
来吧,聊一聊宏任务与微任务吧
setTimeout(() => {
console.log(111)
}, 0)
const promise = new Promise((resolve) => {
console.log(222)
resolve(3333)
})
const promise2 = new Promise((resolve) => {
console.log(555)
resolve(6666)
})
setTimeout(() => {
console.log(4444)
}, 0)
promise.then((value) => {
console.log(value)
})
promise2.then((value) => {
console.log(value)
})
在常见浏览器环境中,输出顺序是:
222
555
3333
6666
111
4444
原因是:Promise 构造器的 executor 在当前脚本中同步执行;then 注册的 Promise reaction 会进入微任务处理流程;两个 setTimeout 回调则会在计时器到期后作为 task 执行。当前脚本结束后,宿主会先执行本次 microtask checkpoint 中的微任务,再选择后续 task。
“宏任务”是社区常用称呼,HTML Standard 更常使用 task;“微任务”对应 microtask。可以先用下面的简化顺序帮助记忆:
- 执行一个 task(例如整段脚本、计时器回调、用户事件回调)。
- 当前 task 结束后执行 microtask checkpoint,直到微任务队列清空;微任务中新增的微任务也会继续处理。
- 浏览器在有渲染机会且确实需要时,执行更新渲染相关步骤;这不是每个 task 后必然绘制。
- 选择下一个可运行 task。
这不是“一个宏任务后固定执行一个微任务”的轮流模型,也不是所有异步回调都进入同一个队列。Promise reaction 在 ECMAScript 中是 job,由宿主通过 HostEnqueuePromiseJob 接入自己的 microtask 机制;MutationObserver、queueMicrotask 也有各自的规范来源。
在 Node.js 中,process.nextTick() 是 Node 特有的高优先级队列,不应直接当成浏览器微任务来背诵;Node 还提供 setImmediate() 和基于 libuv 的事件循环阶段。浏览器代码不要依赖 process.nextTick,Node 代码也不要把浏览器渲染时机套进去。
为什么我一行独立的异步代码也写不出来?
在这个概念理解清楚之后,我大约明白了一些,可我还是想写一行真正脱离定时器、事件和 Ajax 的异步代码。我一直在想,如何实现“未来通知我”的机制?观察者模式可以帮助组织通知,但真正的调度仍然由宿主或 Promise job 机制完成。
例如,Promise 可以把一个已经确定的值也安排到后续的微任务中:
console.log('同步 1')
Promise.resolve().then(() => {
console.log('微任务 3')
})
console.log('同步 2')
输出为:
同步 1
同步 2
微任务 3
Promise 并没有创建一个新的 JavaScript 线程。它提供了状态、链式组合和异步 reaction 的抽象;真正的网络、文件或计时器操作由具体宿主提供。
在浏览器中,Web Worker 可以运行独立的 JavaScript 执行环境:
// 页面线程
const worker = new Worker('./worker.js')
worker.postMessage({ value: 21 })
worker.onmessage = (event) => {
console.log(event.data) // 42
}
// worker.js
self.onmessage = (event) => {
self.postMessage(event.data.value * 2)
}
Worker 不能直接访问页面 DOM;页面线程与 Worker 通过 postMessage 传递可结构化克隆的数据。SharedArrayBuffer 和 Atomics 可以在满足安全条件时提供共享内存通信,但这不是让多个线程无锁地同时修改 DOM。
现代异步代码应该怎么写?
回调、Promise 和 async/await 都仍然有用,区别在于组合方式:
function delay(value, ms) {
return new Promise((resolve) => {
setTimeout(() => resolve(value), ms)
})
}
async function run() {
const first = await delay('first', 10)
const second = await delay('second', 10)
return [first, second]
}
run().then(console.log)
如果两个任务互不依赖,可以并行等待,而不是无意中串行:
async function loadBoth() {
const results = await Promise.all([
fetch('/api/a'),
fetch('/api/b'),
])
return results
}
loadBoth()
如果只需要“发起后在未来通知”,可以使用事件、Promise、queueMicrotask 或计时器;具体选择取决于是否需要返回值、错误传播、取消和调度时机。长时间的 CPU 密集工作则应考虑拆分任务或放入 Worker,避免阻塞页面的主线程。
小结
JavaScript 的同步代码会占用当前执行栈;异步 API 把等待交给宿主,并在未来把回调或 Promise reaction 安排回来。理解事件循环时,记住三点就足够可靠:
setTimeout(fn, 500)表示尽量不早于约 500ms 调度,不是精确闹钟。- Promise executor 同步执行,
then回调异步执行,并在 microtask checkpoint 中运行。 - 浏览器 task、microtask、渲染机会和 Node.js 的 libuv 阶段是不同宿主模型,不能混为一谈。
作者:酸楚与甘甜 原文链接:https://juejin.cn/post/6844903648162480142 来源:稀土掘金。原文著作权归作者所有;转载或公开发布时请遵守原作者的授权和署名要求。
参考: