知

模块一 · 语言与 DOM

第 4 / 14 节18 分钟阅读型

异步的本质:从回调到 Promise 到 async/await

Promise 不是"更优雅的回调",而是可组合的异步值。这一节讲清状态机、链式错误冒泡、并发控制与取消,以及 async/await 的等价转换。

读完这一节,你能做到

  1. 说清回调地狱的三个具体问题。
  2. 解释 Promise 的状态机与链式调用规则。
  3. 用 all/allSettled/race/any 解决不同并发场景。
  4. 写出带并发上限与取消的异步流程。

一、回调为什么不够用

回调本身没有问题,问题出在"组合":

  • 错误处理分散:每一层都要写 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 是值,不是流程控制;能返回值,才能组合,组合优于嵌套。先组合,再控制;组合即接口,先想值,再写流程。

七、常见坑

  1. 在 then 里忘记 return,下一个 then 拿到 undefined。
  2. 用 all 处理允许部分失败的批量任务。
  3. async 函数里串行 await 互不依赖的请求。
  4. 认为 abort 会取消所有异步操作(它只对支持 signal 的 API 生效)。
  5. 不处理 rejected Promise,导致 unhandledrejection。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 用 Promise 实现一个带超时的请求(race + 定时器)。
  2. 把一段回调嵌套改写成 async/await,并统一错误处理。
  3. 实现一个并发上限为 3 的请求池,打印完成顺序。

② 让 AI 写同一题

把第 2 题(并发控制)交给 AI,补一句 并发上限 3,允许部分失败且要能拿到失败列表。

③ 对比要盯的点

  • 它用了 Promise.all 吗?一个 reject 就让全部结果拿不到。
  • 并发上限它是真的限流,还是把任务切块后顺序执行?
  • 失败的任务它吞掉了吗?吞掉的话调用方根本不知道少了什么。

④ 一句话取舍

三个请求里有一个 500,它那版会发生什么?

九、自测

  1. Promise 的三种状态与不可逆性是什么?
  2. then 的返回值如何影响下一条链?
  3. all 与 allSettled 的区别是什么?
  4. 为什么连续 await 会变慢?
  5. 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 的回调是微任务,优先于定时器宏任务。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解