模块一 · 语言与 DOM
异步的本质:从回调到 Promise 到 async/await
Promise 不是"更优雅的回调",而是可组合的异步值。这一节讲清状态机、链式错误冒泡、并发控制与取消,以及 async/await 的等价转换。
读完这一节,你能做到
- 说清回调地狱的三个具体问题。
- 解释 Promise 的状态机与链式调用规则。
- 用 all/allSettled/race/any 解决不同并发场景。
- 写出带并发上限与取消的异步流程。
一、回调为什么不够用
回调本身没有问题,问题出在"组合":
- 错误处理分散:每一层都要写
if (err),漏一层就静默失败。 - 无法返回:回调没有返回值,调用方拿不到结果,只能继续嵌套。
- 无法取消:发起后只能等,页面切换了回调仍会执行。
Promise 的核心贡献是把"未来的值"变成一等公民:它可以被返回、被组合、被等待,也可以统一处理错误。
二、状态机:三种状态,两条路径
pending → fulfilled(resolve)
pending → rejected(reject)
状态一旦改变就不可逆,后续的 resolve/reject 会被忽略。这一点决定了:
- 一个 Promise 只会成功或失败一次。
then注册的回调一定在状态确定后执行。- 即使 resolve 传入另一个 Promise,也会等待它完成(状态跟随)。
const p = new Promise((resolve) => {
resolve(1);
resolve(2); // 被忽略
});
p.then((v) => console.log(v)); // 1
三、链式调用与错误冒泡
then 返回一个新的 Promise,返回值会被包装:
| 返回值 | 下一个 then 收到 |
|---|---|
| 普通值 | 该值 |
| Promise | 等它完成后的值 |
| 抛错 | 进入 catch |
错误会沿着链往后冒泡,直到遇到 catch。所以最佳实践是:在链的末尾统一 catch,而不是每个 then 都写一遍。
fetch("/api/user")
.then((r) => r.json())
.then((user) => fetch(`/api/orders/${user.id}`))
.then((r) => r.json())
.catch((err) => console.error("任意一步失败", err))
.finally(() => setLoading(false));
finally 不改变值,适合清理加载态;它也会把错误继续往后传。
四、四个静态方法的区别
| 方法 | 成功条件 | 失败条件 | 适用 |
|---|---|---|---|
Promise.all | 全部成功 | 任意一个失败 | 强依赖、缺一不可 |
Promise.allSettled | 永远成功 | 不会失败 | 批量任务,允许部分失败 |
Promise.race | 第一个完成(成功或失败) | 同左 | 超时控制 |
Promise.any | 第一个成功 | 全部失败 | 多源竞速,取最快可用 |
all 在第一个失败时立刻 reject,但其他请求不会被取消——这是常见的误解。
五、async/await 不是新机制
async 函数返回 Promise,await 等价于 then 的语法糖:
async function load() {
const r = await fetch("/api/user");
return r.json();
}
// 近似于
function load() {
return fetch("/api/user").then((r) => r.json());
}
两个高频陷阱:
- 串行变慢:连续 await 两个互不依赖的请求,会把并行变成串行。应先
Promise.all再 await。 - 错误被吞:async 函数里抛错会变成 rejected Promise,调用方不 await 或不 catch 就会静默失败。
六、并发控制与取消
批量请求不能一次发 100 个,需要限制并发:
async function pool(tasks, limit = 5) {
const results = [];
let index = 0;
async function worker() {
while (index < tasks.length) {
const i = index++;
results[i] = await tasks[i]();
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
取消用 AbortController:把 signal 传给 fetch,调用 abort() 后请求会以 AbortError 结束。注意取消是"协作式"的,普通 Promise 不会自动停止。
手写一个简化版 Promise
面试常考“手写 Promise”,核心是三件事:状态机、回调队列、then 返回新 Promise。
class MyPromise {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.callbacks = [];
const resolve = (v) => this.settle("fulfilled", v);
const reject = (e) => this.settle("rejected", e);
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
settle(state, value) {
if (this.state !== "pending") return;
this.state = state;
this.value = value;
this.callbacks.forEach((fn) => fn());
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const run = () => {
try {
const fn = this.state === "fulfilled" ? onFulfilled : onRejected;
if (!fn)
return this.state === "fulfilled"
? resolve(this.value)
: reject(this.value);
resolve(fn(this.value));
} catch (e) {
reject(e);
}
};
this.state === "pending" ? this.callbacks.push(run) : queueMicrotask(run);
});
}
}
真正的实现还要处理 thenable、循环引用与多次调用,但上面这段已经能解释“为什么 then 能链式”。
错误处理策略
- 业务错误用 rejected Promise 表达,不要 throw 到全局。
- 在边界统一 catch,并把错误转换成用户可读的提示。
- 对可重试的错误做退避重试,对不可重试的错误直接上报。
- 不要吞错:catch 里至少记录日志,否则线上无法定位。
Promise 与 async 的性能差异
两者本质相同,async/await 会多一层生成器与状态机开销,但在业务代码里可以忽略。真正的性能差异来自“串行还是并行”,而不是写法。
需要并行的请求,先 Promise.all 再 await;需要限制并发的,用池子;需要“先到先用”的,用 race。
并发池的边界
并发上限不是越大越好:太高会压垮服务端或触发限流,太低会拉长总耗时。经验值是 3-10,按接口承载能力调整,并配合重试与退避。
池子还要处理“某个任务永久挂起”的情况,给单个任务加超时,避免整个池被拖住。
一个超时封装
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), ms),
);
return Promise.race([promise, timeout]);
}
注意:超时只是“不再等”,原请求仍在跑;要真正取消,仍需 AbortController。
thenable 与 Promise.resolve
任何带 then 方法的对象都可以被 Promise 采纳,这叫 thenable。Promise.resolve(x) 会把 thenable 展开,也会把普通值包成 resolved Promise。
这个机制让不同库的“类 Promise”可以互操作,但也带来隐式转换:把一个 thenable 直接 resolve,会多出一次微任务。Promise 是值,不是流程控制;能返回值,才能组合,组合优于嵌套。先组合,再控制;组合即接口,先想值,再写流程。
七、常见坑
- 在 then 里忘记 return,下一个 then 拿到 undefined。
- 用 all 处理允许部分失败的批量任务。
- async 函数里串行 await 互不依赖的请求。
- 认为 abort 会取消所有异步操作(它只对支持 signal 的 API 生效)。
- 不处理 rejected Promise,导致 unhandledrejection。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 用 Promise 实现一个带超时的请求(race + 定时器)。
- 把一段回调嵌套改写成 async/await,并统一错误处理。
- 实现一个并发上限为 3 的请求池,打印完成顺序。
② 让 AI 写同一题
把第 2 题(并发控制)交给 AI,补一句 并发上限 3,允许部分失败且要能拿到失败列表。
③ 对比要盯的点
- 它用了
Promise.all吗?一个 reject 就让全部结果拿不到。 - 并发上限它是真的限流,还是把任务切块后顺序执行?
- 失败的任务它吞掉了吗?吞掉的话调用方根本不知道少了什么。
④ 一句话取舍
三个请求里有一个 500,它那版会发生什么?
九、自测
- Promise 的三种状态与不可逆性是什么?
- then 的返回值如何影响下一条链?
- all 与 allSettled 的区别是什么?
- 为什么连续 await 会变慢?
- abort 能取消普通 Promise 吗?
参考答案要点:pending/fulfilled/rejected;包装与冒泡;全成功 vs 全部完成;串行;不能,需 API 支持。
附录:高频追问
问:Promise 能取消吗? 标准 Promise 不能。取消要靠 AbortController 或自定义一个带 cancel 的包装,本质是让回调不再执行。
问:then 里抛错会怎样? 会变成 rejected,沿链冒泡到最近的 catch;如果一直没有 catch,会触发 unhandledrejection。
问:async 函数返回普通值会怎样? 会被包装成 resolved Promise;返回 Promise 则状态跟随。
问:await 一个非 Promise 值呢? 会立即 resolve,但仍会让出一次微任务。
问:all 里一个失败,其他请求会停止吗? 不会。all 只是提前 reject,请求本身继续执行。
问:race 和 any 的区别? race 取第一个完成(无论成败),any 取第一个成功,全部失败才 reject。
问:为什么 catch 之后还能继续 then? catch 返回的是新 Promise,错误被处理后链恢复,后续 then 会执行。
问:Promise 和 setTimeout 谁的优先级高? Promise 的回调是微任务,优先于定时器宏任务。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解