本节摘要:Ajax 代码的外形被 JavaScript 的异步模型塑形。本节讲清这条塑形线:事件循环如何调度回调与 Promise(宏任务与微任务的差别为什么会影响你的请求代码)、Promise 的状态机与错误传播规则、async/await 的糖衣本质与常见误用(forEach 里的 async、漏掉的 await)、以及 AbortError 与异步迭代这些新能力如何继续改变写法。
浏览器里执行 JavaScript 的主线程同一时刻只做一件事。那"请求在后台跑、页面还在响应"是怎么回事?
答案是分画面看。主线程像一位只处理柜台业务的柜员:跑你的脚本、渲染页面、响应输入;网络收发由浏览器内部的其他线程完成,不占柜员的时间。响应到达后,"该处理你的回调了"这件事被写成一张票据投进队列;柜员把手头的活干完,就从队列里按规则取下一张。这个"队列加取票"的机制就是事件循环。
队列不止一条,规则是理解调度行为的钥匙:每次执行栈清空,先把微任务队列(Promise 回调、queueMicrotask)全部清完,才取一个宏任务(定时器、IO 回调、渲染),然后又清微任务,如此往复。这个"微任务插队全清"的规则,直接解释了几处请求代码的观察:
观察一:Promise.resolve().then(...) 永远比 setTimeout(..., 0) 先跑——前者是微任务后者是宏任务,中间可能隔着整条微任务队列。
观察二:连续 await 的代码不是"注册了就不管",每个 await 都让出主线程,其间插进来的微任务(其他 Promise 的回调)会先执行。用代码验证:
console.log('1 同步'); setTimeout(() => console.log('4 宏任务'), 0); Promise.resolve().then(() => console.log('3 微任务')); console.log('2 同步'); // 输出顺序恒为 1 2 3 4
观察三:渲染也排在宏任务之间。一段同步代码里循环十次改 DOM,用户只看到最终结果(中间的渲染被合并);而每个 await 都是一次让出,界面有机会在两次让出之间更新——这就是"处理大批数据时加个 await 让进度条能动"的原理。

Promise 是"未来才有值的容器",三条规则撑起全部行为:
**规则一:状态只能变一次。**pending 到 fulfilled 或 rejected,落定不可逆。这就是 3.4 节竞态防护里"过期响应自然失效"的形式基础——一个 Promise 的结局在落定时已写死,后到的代码无法篡改。
规则二:错误沿链传播直到被捕获。链上任何一环抛错(或返回 rejected Promise),会跳过后续的 then、直达最近的 catch。3.6 节订单页的"全链一处 catch"靠的就是这条。但要警惕 catch 的位置语义:catch 放在链中部,它后面的 then 照常执行(错误被消化了);想"一处兜底全部",catch 必须在链尾。
**规则三:then 返回值会被包装成 Promise。**返回普通值,下一环收到 resolved;返回 Promise,下一环等它落定;返回自己所在的链会报环错误。链式组合的一切技巧(3.6 节把旧值混进 Promise.all)都建立在这条包装规则上。
一个容易被忽略的暗坑:没有 catch 的 rejected Promise 是静默失败(控制台一行未处理拒绝的警告,仅此而已)。同步代码的未捕获异常至少会炸得明显,异步的未捕获拒绝安静得像没发生过——这就是全局 unhandledrejection 监听(3.5 节上报配置)必须存在的原因。
async/await 不是新机制,是 Promise 的语法外观:async 函数返回 Promise,await 是"then 加取值"的缩写。糖衣的价值是把 3.6 节那种编排复杂度降到接近同步代码的可读性。但糖衣也藏着几个高频误用,每个都对应一类真实 bug:
**误用一:forEach 里的 async 是哑弹。**forEach 不等回调完成,async 回调的 Promise 被直接丢弃——循环"瞬间跑完",请求在后台乱飞,后续代码先执行:
// 错:循环不等 请求结果无人保证 items.forEach(async id => { await load(id); }); render(); // 对:串行 for (const id of ids) { await load(id); } render(); // 或对:并行(3.6 节的语义辨析) await Promise.all(ids.map(load)); render();
误用二:漏写 await。const data = fetchSome() 拿到的是 Promise 而不是数据,后面对 data 的操作全部错位。代码检查器能抓大部分,但动态调用点上的漏网之鱼仍需 review 警惕——症状是"对象上没有这个属性"式的莫名报错。
**误用三:串行化了本该并行的请求。**三个互不依赖的请求被依次 await(3.6 节病案),页面白白慢了两倍。改法是把 Promise 先建好再统一 await:
// 慢:三个请求排队 const a = await getA(); const b = await getB(); const c = await getC(); // 快:先并发再统一等 const pa = getA(), pb = getB(), pc = getC(); const [a, b, c] = await Promise.all([pa, pb, pc]);
误用四:try/catch 的虚假安全。async 函数里 try 包住 await 能接住异步错误(这是它优于回调的地方),但 try 块里没 await 的异步调用(fire-and-forget)抛的错接不住。原则:要么 await 它,要么给它挂 catch,别裸放。
AbortError 的标准化:AbortController 取消后各 API 抛出统一的 AbortError(4.4 节已大量使用),配合较新实现里 abort 可携带原因,取消的语义区分(超时对用户主动)越来越顺手。
异步迭代器与流式读取:Fetch 的响应体是可异步迭代的流,逐块处理响应不再需要手拼:
const res = await fetch('/api/logs'); const reader = res.body.getReader(); while (true) { const { done, value } = await reader.read(); // 逐块到达 逐块处理 if (done) break; handleChunk(value); // 大文件不全量进内存(4.3 节的流式下载) }
顶层 await(模块环境):入口模块可以直接 await 初始化数据再导出,配置加载这类"模块级异步"有了正路。
Promise.withResolvers 与微任务队列 queueMicrotask 这类小工具,把过去要用构造函数技巧写的"外部控制 Promise"变成一行——3.4 节竞态防护的序号方案、4.3 节包装 XHR 的 Promise 化,都在被这些新工具逐渐简化。趋势清晰:异步的原语越来越声明式,但底下的调度规则(事件循环、微任务优先)一寸没变——这也是为什么值得把本节前半部分讲透。
实例一:"请求回来后页面卡了一下"。某接口响应体大,回调里同步解析加渲染耗时上百毫秒,期间主线程占满、动画冻结。思路:解析与渲染分帧(分块处理加 await 让出),或把重解析移到 worker 线程。识别特征:卡顿与响应到达时刻精确同步。
实例二:"loading 状态一闪就没了"。同步代码里先设 loading 再发请求再清 loading——全程没有让出主线程,浏览器根本没机会渲染 loading 那一帧,用户看到的是直接出结果。修法是让"清 loading"发生在异步回调里:
showLoading(); const data = await request('/api/heavy'); // await 让出 渲染了 loading 帧 hideLoading(); render(data);
两个实例的共同启示:界面更新发生在让出的间隙,同步代码块的中间状态用户永远看不见。写交互代码时问一句"这个状态用户有机会看到吗",能预防一大类"逻辑对但看不见"的 bug。
合法,直接返回该值(会被包装再拆包)。偶尔用于刻意让出主线程(await undefined 让渲染有机会执行),但可读性差,明确的让出用 queueMicrotask 或注释说明意图更好。
理论上会——微任务里再产生微任务可以无限插队。别在微任务里做重活或递归产生微任务,这是调度规则给使用者的纪律。
分层兜底:函数内部 catch 掉"能就地处理的";其余让调用方接;入口层(事件处理器、页面加载器)必须有最后一道 catch——配合全局 unhandledrejection 监听,保证没有任何 rejection 是静默的。
事件循环的知识最适合用实验固化,四个小实验各十行以内,全在控制台可完成。
实验一验证微任务插队:先注册一个定时器零毫秒的回调,再注册一个 Promise 的 then,再同步打印一行——输出顺序固定为"同步、微任务、宏任务",亲眼看到微任务把宏任务挤到后面。实验二验证渲染合并:同步循环里改一个元素的颜色一百次(每次不同值),页面只显示最后一个颜色——中间状态被渲染合并吞掉了;把循环体换成一个带 await 的循环(每次迭代让出),中间的颜色就全部可见。这个对比是"界面更新发生在让出间隙"的直接证据。实验三验证await 的让出点:在 await 前后各打一行日志,中间插入一个 queueMicrotask 的回调——你会看到微任务回调的输出夹在两行之间,证明 await 恢复执行本身就是一次微任务调度。实验四测长任务的卡顿:同步执行一段几百毫秒的循环,期间试着点击页面上的按钮——点击毫无响应;把循环切片(每片几十毫秒,片间 await),按钮恢复响应。
四个实验做完,事件循环从"知识"变成"体感"——以后写任何异步代码,脑中会自动放映这几段输出序列。顺带一个建议:把这些实验的代码存进个人的 snippets 库,带新同事入门事件循环时,十分钟的演示胜过一小时的概念讲解,而且演示者自己的理解也会每次都被刷新一遍。
再把本节知识与请求实战接一次线,确保它不是悬空的理论。你写的每一个 await request,落到调度层面是三拍子:发出请求(同步段)→ 让出主线程(微任务队列空、可能穿插渲染与其他微任务)→ 响应到达(宏任务入队)后恢复执行(清微任务时跑你的后续代码)。理解这三拍子,几个实战困惑立刻消解:为什么"连续 await 的两段代码之间,别的请求的回调可能插进来执行"(它们都是微任务,按入队顺序排队);为什么"一个耗时八百毫秒的解析回调会让页面动画冻住"(它是宏任务里的一段同步执行,渲染排在其后);为什么"await 后立刻读界面尺寸可能读到旧值"(渲染还没轮到)。调度知识对异步代码,就像路况知识对路线规划——不改变路的长短,但决定了你对时间的预期能不能兑现。把这条认知带进下一节的测试调试:可控延迟、假时钟这些"把时间拿走"的测试手段,操纵的正是本节讲的这套调度规则;理解了规则的测试代码,读起来才不是咒语而是实验设计——这也是把机制章与质量章排在相邻位置的用意。调度知识还有一层长期价值:它是所有异步运行时的公共语言,浏览器的事件循环、Node 的执行模型、各类协程实现,差异只在队列细节,骨架相通——学会这一份,等于给未来的每一份都预付了学费。