7AA7 Blog

JavaScript 异步演进:从回调到 async/await

凛川 Wave UID:74
2026-10-16 13:20
73 0 2

JavaScript 的异步史,某种意义上就是”怎么把回调写得像同步代码”的进化史。从 callback 到 Promise 再到 async/await,每一步都在解决上一阶段留下的可读性和错误处理问题。把事件循环和这三代写法的关系理顺,写异步代码时心里才有底。

一、事件循环:异步为什么”不卡”

JS 是单线程执行的,但宿主环境(浏览器或 Node)提供了事件循环。粗略地说:调用栈清空后,事件循环从任务队列里取任务继续执行。定时器、网络响应、用户点击,本质上都是”稍后往队列里塞一个任务”。

队列其实分两类,优先级不同:

  • 宏任务(macrotask):setTimeout、I/O、UI 事件、setInterval,一次循环取一个。
  • 微任务(microtask):Promise 的 .then/.catch/.finally、queueMicrotask、MutationObserver,在当前宏任务结束后、下一个宏任务开始前全部清空。
console.log("a");
setTimeout(() => console.log("b"), 0);
Promise.resolve().then(() => console.log("c"));
console.log("d");
// 输出:a d c b

setTimeout(fn, 0) 不是”立即”,而是”下一个宏任务”;Promise 的回调则在当前宏任务收尾时就跑。这个顺序解释了很多”为什么我的回调晚了一步”的困惑。

二、回调时代的问题

Node 早期的接口几乎全是回调风格:

readFile(path, (err, data) => {
    parse(data, (err, parsed) => {
        save(parsed, (err) => { /* ... */ });
    });
});

嵌套一多,就是所谓的回调地狱。但真正的问题不止是缩进:

  1. 可读性。业务流程被拆散在一层层缩进里,横向阅读变成纵向攀爬。
  2. 错误处理。每一层都要写 if (err),漏一层就吞掉一个错误;异常在回调里抛出时,不会被外层 try/catch 捕获。
  3. 流程控制。想做”串行若干步””并行若干步””带超时”,都得手写计数器和状态位。
  4. 控制反转。把后续逻辑交给别人调用,别人什么时候调、调几次、忘了调,你都管不着。

Node 社区后来流行过一阵 “error-first callback” 的约定:回调第一个参数永远是错误对象,没有错误时为 null。这个约定让错误处理至少有了一致的形状,但它是纪律而不是语言机制,靠人自觉遵守,写错了编译器和运行时都不会吱声。Promise 之后,这类约定才被语言层面的状态机取代。

三、Promise:状态机与链式

Promise 把”未来某个值或错误”包装成一个对象,状态只有三种:pending、fulfilled、rejected,且状态一旦变更就不可逆。这个不可变性是 Promise 链可靠的基础。

fetchUser(id)
    .then(user => fetchOrders(user))   // 返回新 Promise,链式继续
    .then(orders => render(orders))
    .catch(err => showError(err))      // 一条 catch 接住整条链上的错
    .finally(() => hideSpinner());

链式的几个要点:

  • .then(onFulfilled, onRejected) 里两个参数都在时,错误处理会被”吞掉”继续走正常流程,通常更推荐 .then(...).catch(...) 的写法,语义更清楚。
  • 回调里 return 一个普通值,链上拿到的就是这个值;return 一个 Promise,链会等它敲定;throw 或返回 rejected Promise,会跳到最近的 catch。
  • .catch 之后链会恢复到 fulfilled 状态,除非 catch 自己又抛错,所以”catch 里再报错”要小心。

Promise.all、Promise.race、Promise.allSettled、Promise.any 提供了并行组合的基本形态,这一点后面单独说。

四、async/await:本质是语法糖

async 函数一定返回 Promise,await 只能出现在 async 函数里(ES2022 之后顶层 await 也可用),它把”等 Promise 敲定”写成了顺序代码:

async function main() {
    try {
        const user = await fetchUser(id);
        const orders = await fetchOrders(user);
        render(orders);
    } catch (err) {
        showError(err);
    } finally {
        hideSpinner();
    }
}

语义上,await p 等价于把后续代码包进 p.then(...),所以它不会阻塞线程,只是让出当前 async 函数的执行。理解”async 函数是 Promise 工厂”这一点,就能解释:

  • 为什么 async 函数里 return 1 得到的是 Promise<1>。
  • 为什么 await 会把 try/catch 变得像同步代码一样好用。
  • 为什么忘了 await 时拿到的是 Promise 而不是值。

async 函数的返回值

一个常见误解是 async 函数里可以随便 return。实际上返回值会被 Promise.resolve 包一层,调用方不 await 的话拿到的始终是 Promise。还有个细节:await 会把非 Promise 的值也包成 Promise 再等,所以 await 42 是合法的,只是多绕一圈微任务,语义上等于”让出一次执行机会”。

另一个细节是顶层 await。在 ES 模块里可以直接写 const data = await fetch(...),不必包函数,这让脚本化的一次性代码清爽很多。但它只在模块作用域生效,普通脚本里还是要包一层 async 立即执行函数。

五、错误处理的几个陷阱

吞错。await p().catch(() => {}) 会把错误静默掉,除非确实有兜底逻辑,否则别这么写。.catch 里至少要打日志或转成用户可见的提示。

忘了 await。const data = getData() 拿到的是 Promise,data.length 永远是 undefined,而它产生的 rejection 变成未处理的 Promise 拒绝。ESLint 有一批规则专门盯这类问题,值得开着。

并发写成了串行。两个独立请求写成两行 await,是顺序执行;应该先都发出去再一起等:

const [a, b] = await Promise.all([fetchA(), fetchB()]);

async 里的同步异常。async 函数体内同步 throw 会被包成 rejected Promise,所以 await fn() 的 try/catch 能接住,但 fn() 不 await 的话就接不住。错误处理的边界要和调用方式对齐。

forEach + async 是个坑。arr.forEach(async x => await f(x)) 不会等所有任务完成,因为 forEach 不认 Promise。用 for...of 串行,或 Promise.all(arr.map(...)) 并行。

六、并发控制

Promise.all:全部成功才成功,有一个失败就失败,适合”都要拿到”的场景。Promise.allSettled:等全部敲定,返回每个的结果描述,适合批量任务里允许部分失败。Promise.race:第一个敲定的胜出,常用来做超时。Promise.any:第一个 fulfilled 胜出,全失败才 rejected,适合多路源取最快可用。

限制并发数是另一件事。Promise.all 直接 map 上千个请求,容易把连接池或第三方接口打爆。做法是维护一个固定大小的执行池:

async function pool(tasks, limit) {
    const running = new Set();
    for (const t of tasks) {
        const p = t().finally(() => running.delete(p));
        running.add(p);
        if (running.size >= limit) await Promise.race(running);
    }
    await Promise.all(running);
}

思路就是”满了就等一个跑完再继续”。Web 里也有 fetch 配合队列、或直接用现成的并发控制库,原理相同。

七、取消与超时

Promise 本身没有取消机制,这是一直被诟病的一点。AbortController 是目前的标准做法:

const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 2000);   // 超时就取消

const resp = await fetch(url, { signal: ctrl.signal });

fetch、部分数据库驱动、事件监听都支持 signal。自己包装时,可以把 signal 透传下去,或者用 Promise.race 拼一个超时:

function withTimeout(promise, ms) {
    let timer;
    const timeout = new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error("timeout")), ms);
    });
    return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

注意 Promise.race 超时只是”不再等待”,底层请求还在跑,真正省资源要靠 AbortController 让下游停止工作。资源清理这类事,finally 比 then/catch 更合适。

取消这件事在工程上容易被低估:用户切走页面、输入框快速变化触发一连串搜索、组件卸载后回调还在跑,都会留下”已经没人要的结果”。统一在请求层挂 signal,比在每个调用点手动清理靠谱,也更容易在测试里模拟超时和中断路径。

八、调试异步的常见困惑

异步代码难调,一半是因为调用栈断了。async/await 让 try/catch 好用了,但断点落在 await 前后时,调用栈往往只剩当前函数,看不到”谁发起的请求”。现代调试器对 async 栈的支持已经不错,开一个”异步调用栈”选项会好读很多。

还有两个高频疑问:一是”为什么我的日志顺序不对”,多半是微任务和宏任务的交错;二是”为什么错误只在控制台出现而没进我的 catch”,那是未处理的 Promise 拒绝,要么忘了 await,要么某个分支漏了 catch。unhandledrejection 事件可以在浏览器、unhandledRejection 在 Node 里兜底记录,是排查这类问题的好帮手。

九、怎么选

写业务代码,能用 async/await 就用,可读性和调试体验都好;底层库的接口设计,暴露 Promise 更灵活,让调用方自己决定要不要 await。批处理、队列、重试这些模式,内部用 Promise 组合,对外用 async 函数封装,是比较稳的分层。

异步这块最值得花时间的不是新语法,而是理解事件循环和 Promise 的状态机。语法层面的演进只是把同一个模型包得越来越像同步,底层始终是”任务队列 + 微任务清空 + 回调敲定”。想通了这一层,什么新 API 出来都是一看就会。

807Views
73Likes
0Comments
0Participants
14 minread time

Comments (0)

No comments yet — be the first.