从单线程到 Event Loop,从回调地狱到 Promise 优雅链式调用
目录
- 引言:为什么需要理解同步与异步
- 进程与线程:操作系统的视角
- [JavaScript 的单线程设计哲学](#JavaScript 的单线程设计哲学 "#3-javascript-%E7%9A%84%E5%8D%95%E7%BA%BF%E7%A8%8B%E8%AE%BE%E8%AE%A1%E5%93%B2%E5%AD%A6")
- [同步代码 vs 异步代码](#同步代码 vs 异步代码 "#4-%E5%90%8C%E6%AD%A5%E4%BB%A3%E7%A0%81-vs-%E5%BC%82%E6%AD%A5%E4%BB%A3%E7%A0%81")
- [JS 的执行机制:Event Loop 事件循环](#JS 的执行机制:Event Loop 事件循环 "#5-js-%E7%9A%84%E6%89%A7%E8%A1%8C%E6%9C%BA%E5%88%B6event-loop-%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF")
- [JS 中有哪些异步任务](#JS 中有哪些异步任务 "#6-js-%E4%B8%AD%E6%9C%89%E5%93%AA%E4%BA%9B%E5%BC%82%E6%AD%A5%E4%BB%BB%E5%8A%A1")
- Promise:异步任务控制的最佳机制
- [实战:封装 sleep 函数](#实战:封装 sleep 函数 "#8-%E5%AE%9E%E6%88%98%E5%B0%81%E8%A3%85-sleep-%E5%87%BD%E6%95%B0")
- [实战:fetch 网络请求](#实战:fetch 网络请求 "#9-%E5%AE%9E%E6%88%98fetch-%E7%BD%91%E7%BB%9C%E8%AF%B7%E6%B1%82")
- 总结
1. 引言:为什么需要理解同步与异步
在 Web 开发中,我们经常需要执行一些耗时操作,比如:
- 从服务器获取数据(网络请求)
- 读写文件
- 定时任务
- 用户交互事件
如果这些操作是"同步"执行的,意味着程序必须停下来等待它们完成,期间用户界面会完全冻结------这对用户体验是灾难性的。
异步编程正是为了解决这个问题而生的核心机制。理解它,是掌握 JavaScript 的关键一步。
2. 进程与线程:操作系统的视角
在深入 JS 之前,让我们先建立操作系统层面的基础概念:
scss
┌──────────────────────────────────┐
│ 操作系统 (OS) │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 进程 A │ │ 进程 B │ ... │
│ │ (PID=1) │ │ (PID=2) │ │
│ │ │ │ │ │
│ │ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │主线程 │ │ │ │主线程 │ │ │
│ │ ├──────┤ │ │ ├──────┤ │ │
│ │ │子线程1│ │ │ │子线程1│ │ │
│ │ ├──────┤ │ │ └──────┘ │ │
│ │ │子线程2│ │ │ │ │
│ │ └──────┘ │ │ │ │
│ └──────────┘ └──────────┘ │
│ │
│ CPU 时间片轮询:每个进程分配 │
│ 几十毫秒的执行时间 │
└──────────────────────────────────┘
| 概念 | 类比 | 说明 |
|---|---|---|
| 进程 (Process) | 董事长 | 拥有独立的内存空间和资源,由 PID 唯一标识 |
| 线程 (Thread) | 经理 | 进程内的执行单元,共享进程的内存空间 |
| 主线程 | 总经理 | 每个进程至少有一个主线程 |
| 子线程 | 部门经理 | 从主线程派生的辅助执行单元 |
多线程语言(C++、Java 等)可以同时启动多个线程并发执行,效率高但编程复杂------需要处理锁、竞态条件、死锁等问题。
3. JavaScript 的单线程设计哲学
与 C++/Java 不同,JavaScript 天生被设计为单线程语言。
arduino
┌─────────────────────────────────┐
│ JS 引擎进程 │
│ │
│ ┌─────────────────────────┐ │
│ │ 主线程 (唯一) │ │
│ │ │ │
│ │ ① 执行同步代码 │ │
│ │ ② 处理异步回调 │ │
│ │ ③ 渲染页面 │ │
│ │ ④ 响应用户交互 │ │
│ └─────────────────────────┘ │
│ │
│ "JS 足够简单,单线程即可" │
└─────────────────────────────────┘
为什么设计为单线程?
- JS 最初的设计目标是操作 DOM 和响应用户交互
- 如果多个线程同时操作同一个 DOM 节点,会产生难以预料的冲突
- 单线程让编程模型变得简单可靠,开发者无需处理复杂的并发控制
📌 但这引出了一个问题:单线程如何处理耗时任务而不阻塞 UI?
4. 同步代码 vs 异步代码
来看一个最经典的例子(1.js):
js
// 同步代码 sync
console.log('start')
// 异步代码
setTimeout(() => {
console.log('222')
}, 1000)
console.log('end')
执行结果:
sql
start
end
222 ← 1秒后才输出
arduino
时间轴
↓
┌─────────┐
│ start │ 同步代码,立即执行
├─────────┤
│ end │ 同步代码,立即执行(不等 setTimeout)
├─────────┤
│ ... │ ← 跳过,等待 1 秒
├─────────┤
│ 222 │ 异步回调,1 秒后执行
└─────────┘
🔑 核心原则:JS 先把所有同步代码快速执行完,再回头处理异步任务。这保证了用户界面始终响应迅速。
5. JS 的执行机制:Event Loop 事件循环
JS 是如何做到"单线程但不阻塞"的?答案就是 Event Loop(事件循环)。
scss
┌──────────────────────────┐
│ JS 调用栈 │
│ (Call Stack) │
│ │
│ console.log('start') │
│ setTimeout(...) ──┐ │
│ console.log('end') │ │
│ │ │
└──────────────────────┼─────┘
│
↓
┌──────────────────────────┐
│ Web APIs / libuv │
│ (浏览器/Node.js 提供) │
│ │
│ timer 倒计时 1000ms ←──┘
│ fetch 网络请求 │
│ DOM 事件监听 │
└──────────┬───────────────┘
│ 倒计时结束
↓
┌──────────────────────────┐
│ 任务队列 (Task Queue) │
│ │
│ [ callback_1 ] │
│ [ callback_2 ] │
│ [ callback_3 ] │
└──────────┬───────────────┘
│
↓
┌────────────────────────────────┐
│ Event Loop │
│ │
│ while (true) { │
│ if (调用栈为空 && 队列有任务) │
│ 取出一个回调,推入调用栈执行 │
│ } │
└────────────────────────────────┘
执行流程:
- 同步代码逐行进入调用栈,立即执行
- 遇到异步任务(
setTimeout、fetch、事件监听等),交给 Web APIs / libuv 处理,JS 继续往下执行 - 异步任务完成后,其回调函数 被放入任务队列
- Event Loop 不断检查:调用栈为空时,从任务队列取出回调放入调用栈执行
💡 这就是为什么
setTimeout不会阻塞后续代码------它只是"注册"了一个未来的回调,然后 JS 继续跑。
6. JS 中有哪些异步任务
| 类型 | 示例 | 说明 |
|---|---|---|
| 定时器 | setTimeout、setInterval |
延迟执行或周期性执行 |
| 网络请求 | fetch、XMLHttpRequest |
与服务器通信,耗时不确定 |
| 事件监听 | click、keydown、scroll |
用户交互,触发时机不确定 |
| 文件操作(Node.js) | fs.readFile、fs.writeFile |
磁盘 I/O |
| Promise | new Promise(...) |
异步任务控制的统一抽象 |
7. Promise:异步任务控制的最佳机制
7.1 为什么需要 Promise?
在 Promise 出现之前,异步编程依赖回调函数嵌套,这就是著名的"回调地狱":
js
// 😱 回调地狱 ------ 层层嵌套,难以维护
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetail(orders[0].id, (detail) => {
// 终于拿到了数据...
})
})
})
7.2 Promise 的核心概念
Promise(承诺)代表一个未来才会完成的异步操作的结果。
markdown
Promise 生命周期
pending(进行中)
│
├──→ fulfilled(已成功) ──→ .then()
│
└──→ rejected(已失败) ──→ .catch()
7.3 Promise 的构造
来看 3.js 的完整示例:
js
// Promise ES6 中用于异步任务控制的最佳机制
const p = new Promise((resolve, reject) => {
console.log('许诺言') // ← 同步执行!
// 耗时性任务
setTimeout(() => {
// resolve(666) // 成功:覆约
reject('网络错误') // 失败:没有覆约
}, 2000)
}) // 许诺言
console.log(p.__proto__)
p
.then((data) => {
console.log(data) // resolve 的返回值
console.log('end')
})
.catch((err) => {
console.log(err) // reject 的错误原因
})
.finally(() => {
console.log('finally') // 无论成功失败都执行
})
关键点剖析:
scss
new Promise(executor)
│
↓
executor 函数立即同步执行
│
┌─────────┴─────────┐
│ │
resolve() reject()
│ │
↓ ↓
.then() .catch()
│ │
└─────────┬─────────┘
↓
.finally()
| 要素 | 说明 |
|---|---|
executor |
传入 Promise 的函数,立即同步执行,是耗时任务的容器 |
resolve |
异步任务成功时调用,将结果传给 .then() |
reject |
异步任务失败时调用,将错误原因传给 .catch() |
.then() |
注册成功回调,返回新的 Promise(支持链式调用) |
.catch() |
注册失败回调,捕获前面任一环节的错误 |
.finally() |
无论成功或失败,最终都会执行 |
7.4 Promise 的链式调用
Promise 最大的优势是链式调用,将嵌套变成平铺:
js
// 😊 Promise 链式调用 ------ 清晰优雅
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetail(orders[0].id))
.then(detail => console.log(detail))
.catch(err => console.error('出错了:', err))
8. 实战:封装 sleep 函数
JS 本身没有 sleep 函数,但我们可以用 Promise 封装一个(5.html):
js
function sleep(t) {
const p = new Promise((resolve, reject) => {
console.log('同步') // executor 中的同步代码
setTimeout(() => {
resolve() // t 毫秒后 resolve
}, t)
})
return p
}
// JS 系统不支持 sleep,我们自己实现
sleep(2000)
.then(() => {
console.log('2s 后再做')
})
scss
执行流程
sleep(2000)
│
├── "同步" ← 立即输出
│
├── 启动 2 秒定时器
│
├── 返回 Promise 对象
│
├── .then() 注册回调
│
├── ... (2 秒等待) ...
│
└── "2s 后再做" ← 2 秒后输出
💡 这个模式非常实用!在实际开发中,我们经常需要"等一段时间再执行",
sleep函数让这件事件变得优雅。
9. 实战:fetch 网络请求
fetch 是浏览器内置的 HTTP 请求 API,底层基于 Promise (4.html):
js
console.log('start')
// fetch 底层是 Promise
fetch('https://api.example.com/chat/completions', {
method: 'post'
})
.then((data) => {
// 处理响应数据
})
.catch((err) => {
console.log(err) // 网络错误,被 Promise reject 捕获
})
console.log('end')
执行结果:
sql
start ← 立即输出
end ← 立即输出(fetch 不会阻塞)
... ← fetch 请求在后台进行
(请求完成后) .then() 或 .catch() 被调用
scss
fetch 异步流程
console.log('start') 同步
│
fetch('...') 注册异步任务
│ │
console.log('end') 同步 │ 网络请求进行中...
│ │
同步代码全部完成 │
│ │
Event Loop ◄─────────────────────┘
│ (请求完成)
↓
.then() / .catch() 执行回调
⚠️ 注意:
fetch只在网络错误 时才会 reject(触发.catch())。HTTP 错误状态码(如 404、500)仍然会触发.then(),需要手动检查response.ok。
10. 总结
scss
┌─────────────────────────────────────────────────────────┐
│ JS 异步编程知识地图 │
├─────────────────────────────────────────────────────────┤
│ │
│ 底层原理 │
│ ├── 进程 (PID) vs 线程 │
│ ├── JS 单线程设计哲学 │
│ └── Event Loop (事件循环) │
│ │
│ 执行机制 │
│ ├── 同步代码:立即执行,放入调用栈 │
│ ├── 异步代码:交给 Web APIs,回调放入任务队列 │
│ └── Event Loop 在调用栈为空时从任务队列取回调执行 │
│ │
│ 异步任务类型 │
│ ├── setTimeout / setInterval (定时器) │
│ ├── fetch (网络请求) │
│ ├── DOM 事件 (用户交互) │
│ └── Promise (异步任务控制抽象) │
│ │
│ Promise 三态 │
│ ├── pending → 进行中 │
│ ├── fulfilled → resolve() → .then() │
│ └── rejected → reject() → .catch() │
│ │
│ 最佳实践 │
│ ├── 用 Promise 封装异步操作(如 sleep) │
│ ├── 链式调用替代回调嵌套 │
│ └── .catch() 统一错误处理 │
│ │
└─────────────────────────────────────────────────────────┘
一句话总结
JavaScript 是单线程的,但它通过 Event Loop 机制,将耗时任务交给浏览器/Node.js 底层处理,回调放入任务队列,在不阻塞主线程的前提下实现异步执行。而 Promise 是 ES6 提供的统一异步控制机制,让异步代码写得像同步代码一样清晰。