2026-06-15
阅读约 10 分钟 | 专栏:每日一课
前言
先来看一道经典面试题:
console.log(1)
setTimeout(() => {
console.log(2)
}, 0)
new Promise((resolve) => {
console.log(3)
resolve(4)
}).then(res => {
console.log(res)
})
console.log(5)
// 输出顺序是什么?
如果你的答案是 1 → 3 → 5 → 4 → 2,恭喜你,这篇文章你已经掌握了七成。如果猜错了,也别急——这正是本文要帮你彻底解决的问题。
一、单线程:JavaScript 的设计宿命
1.1 为什么偏偏是单线程?
C++ 有多线程,Java 有线程池,Go 有 goroutine。JavaScript 为什么非要单线程?
答案藏在它的出生证明里:JS 是为浏览器而生的。它的核心任务是:
- 操作 DOM 节点
- 响应用户点击、滚动、输入
- 控制页面动画
想象一下多线程操作同一个 DOM 的场景:
线程A:我要删除这个按钮!
线程B:等等,我正给它改文字呢!
线程C:我刚给它换了颜色...
DOM:你们能不能先开个会?
这种竞态条件(Race Condition)会让页面渲染变得完全不可预测。为了避免这种灾难级复杂度,JS 从设计的第一天就选择了单线程。
// 单线程 = 可预测,一行一行来
let a = 1
let b = 2
let c = 3
console.log(a + b + c) // 6,永远是这个结果
1.2 单线程带来了什么代价?
单线程意味着所有任务必须排队。前一个不完成,后一个永远等。
如果有个任务需要等 3 秒——比如请求服务器数据——那用户在这 3 秒里就什么都做不了:按钮点不动,输入框打不了字,页面像是"死了"一样。这显然不可接受。
异步编程,由此诞生。
二、同步与异步的分工哲学
2.1 用一段代码感受差别
console.log('start')
setTimeout(() => {
console.log('async')
}, 1000)
console.log('end')
// 输出:start → end → async
setTimeout 里的回调明明写在 end 前面,却最后一个执行。这不是 bug——这是 JS 运行机制刻意为之。
2.2 "公司"类比:帮你建立心智模型
用一家公司来理解 JS 的运行时:
| 概念 | 类比 | 说明 |
|---|---|---|
| 进程(Process) | 董事长 | PID 是工号,负责分配系统资源 |
| 线程(Thread) | 经理 | TID 是工号,一个进程下可有多个线程 |
| 主线程 | 唯一干活的经理 | JS 所有代码都在这一个线程上跑 |
| Event Loop | 秘书台 | 协调同步任务和异步任务的调度 |
启动一个 JS 程序(无论浏览器还是 Node.js)的流程:
- 操作系统启动一个进程,分配内存
- 进程内启动一个主线程——JS 是单线程,就这一个
- 主线程火速执行所有同步任务——这是用户能最快看到页面的关键
- 遇到
setTimeout、fetch、事件监听等异步任务时,不等待,把它们挂到 Event Loop - 跳过异步,继续跑后面的同步代码
- 同步代码全部跑完后,Event Loop 开始处理异步队列
这就是为什么 end(同步)必须先于 async(异步)打印。
三、你想知道的异步任务都在这里
日常开发中,下面这些场景全部是异步的:
| 异步任务 | 触发时机 | 开发场景 |
|---|---|---|
setTimeout / setInterval | 定时到点后,回调进队列 | 延迟执行、轮询 |
| DOM 事件 | 用户交互触发 | 按钮点击、滚轮、键盘输入 |
| 网络请求 | fetch、XMLHttpRequest、axios | 调 API、加载远程资源 |
| Promise | resolve / reject 触发 .then / .catch | 链式异步编排 |
async/await | await 暂停当前函数,等 Promise 决议 | 现代异步编程标配 |
一句话总结:所有你预期"未来某个时刻才完成"的操作,JS 都会丢给 Event Loop,绝不阻塞主线程。
四、Promise:从回调地狱到链式优雅
4.1 没有 Promise 的时代
ES6 之前,异步靠回调函数。多层依赖的代码长这样:
// 回调地狱 👿
fetchUser(userId, (user) => {
fetchOrders(user.id, (orders) => {
fetchDetails(orders[0].id, (details) => {
fetchDelivery(details.address, (delivery) => {
// 我已经看不到缩进的开头了...
})
})
})
})
每一层依赖都必须嵌进上一层的回调里,像剥洋葱,越剥越想哭。Promise 就是来拯救我们的。
4.2 Promise 的本质
Promise(承诺)是一个异步任务的容器。它包裹着一个暂时还不存在的值,向你"承诺"在未来某个时刻给你结果。
Promise 有三种状态,且不可逆:
pending ──resolve()──→ fulfilled(履约)
└─────reject()───→ rejected (违约)
一旦状态改变(settled),就永远凝固住了。你调用多少次 .then() 拿到的结果都一样。
4.3 拆解一个 Promise 的完整生命周期
const p = new Promise((resolve, reject) => {
console.log('1. executor 立即同步执行')
// 模拟耗时任务
setTimeout(() => {
const success = Math.random() > 0.5
if (success) {
resolve('2. 履约了!')
} else {
reject('2. 违约了!')
}
}, 2000)
})
p
.then(data => {
console.log(data, '→ 3. resolve 时触发')
})
.catch(err => {
console.log(err, '→ 3. reject 时触发')
})
.finally(() => {
console.log('4. 不论成败,我都会执行')
})
关键细节:
new Promise(executor)里的 executor 函数是同步执行的resolve和reject是 Promise 内部提供给你的两个钩子——你来决定什么时候"履约"、什么时候"违约".then()接收 resolve 的结果,.catch()接收 reject 的原因.finally()不管成功失败都跑,适合做清理(关 loading、清定时器等)
4.4 一个容易踩的坑
console.log(1)
new Promise((resolve) => {
console.log(2) // ← 这是同步的!不是异步!
resolve(3)
}).then(data => {
console.log(data) // ← 这才是异步(微任务)
})
console.log(4)
// 输出:1 → 2 → 4 → 3
很多人以为 new Promise 里面就是异步的,其实 executor 是在创建 Promise 时就立刻跑了。真正异步的是 .then() / .catch() 里的回调。
4.5 fetch 就是 Promise 的马甲
浏览器内置的 fetch 底层就是 Promise:
console.log('start')
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' })
})
.then(res => res.json())
.then(data => console.log('请求成功', data))
.catch(err => console.log('请求失败', err))
console.log('end')
// 输出:start → end → (请求结果)
所以你能用 .then() 链式处理响应,也能用 .catch() 统一捕获网络异常——这都是因为 fetch 返回的是一个 Promise。
4.6 手写一个 sleep 函数
JS 没有原生的 sleep(),但一个 Promise 就能搞定:
function sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}
// 用起来像这样
async function demo() {
console.log('等 2 秒...')
await sleep(2000)
console.log('2 秒到了!')
}
这也是 Promise 的核心价值:把"完成时机"暴露成一个可组合的对象,用 .then() 或 await 来编排依赖关系,而不是在回调里疯狂缩进。
五、链式调用与错误冒泡
5.1 链式是 Promise 最强大的武器
每个 .then() 都返回一个新的 Promise,所以可以一直链下去:
fetchUser(userId)
.then(user => fetchOrders(user.id)) // 返回新 Promise
.then(orders => fetchDetails(orders)) // 继续链
.then(details => renderUI(details)) // 渲染
.catch(err => showErrorToast(err)) // 一处 catch,捕获全链路
5.2 错误冒泡机制
Promise 链里任何一个环节出错,错误会沿着链一路向下冒泡,直到遇到第一个 .catch():
Promise.resolve()
.then(() => { throw new Error('step 1 炸了') })
.then(() => { console.log('这行不会执行') })
.then(() => { console.log('这行也不会') })
.catch(err => {
console.log('捕获到:', err.message) // ← 在这接住了
})
.then(() => { console.log('catch 后面还能继续链') })
这意味着你不需要在每一层回调里单独处理错误——只需要在链尾放一个 .catch(),就能兜住前面所有环节的异常。相比回调地狱里每个回调都得写 if (err),这简直是降维打击。
六、总结:一张地图帮你回顾全文
| 核心概念 | 一句话理解 |
|---|---|
| JS 单线程 | 设计选择,避免多线程操作 DOM 的竞态复杂度 |
| 同步任务 | 在主线程排队,前一个不完成后一个永远等 |
| 异步任务 | 不阻塞主线程,交给 Event Loop 调度 |
| Event Loop | JS 运行时的"总调度",决定同步和异步的执行顺序 |
| Promise | 异步任务容器,提供 resolve / reject 两个出口 |
| executor 同步执行 | new Promise(fn) 里的 fn 是立刻跑,不是异步 |
| .then / .catch / .finally | Promise 消费者:成功 / 失败 / 最终 |
| 链式调用 | 每个 .then() 返回新 Promise,可无限链下去 |
| 错误冒泡 | 链上任何一处报错,一路冒泡到最近的 .catch() |
最后回到开篇那道题
console.log(1) // 同步 → 1
setTimeout(() => { console.log(2) }, 0) // 宏任务 → 最后
new Promise((resolve) => {
console.log(3) // executor 同步 → 3
resolve(4)
}).then(res => console.log(res)) // 微任务 → 比宏任务先
console.log(5) // 同步 → 5
// 最终输出:1 → 3 → 5 → 4 → 2
同步 → 微任务 → 宏任务——掌握这个顺序,你就抓住了 JS 异步执行的命脉。
理解 JS 的同步与异步从来不只是背面试题。它是一种编程思维方式的转变:学会用异步的视角组织代码,你才能写出流畅、健壮、能扛住高并发的 JavaScript 应用。