“回声咖啡馆”开业第一天,门口排起长队。后厨只有一个咖啡师,顾客点一杯热拿铁,他做一杯;再做下一杯……队伍越排越长,急性子的顾客开始敲桌子。第二天店长学聪明了:每位顾客拿一个取餐器——“您的咖啡做好了会震动提醒您,不用站在柜台前干等。”于是大家点单后各自落座、看书、聊天,取餐器一响,从容去取。同样一个人做咖啡,店里能同时服务的顾客却翻了好几倍。 这套“取餐器 + 后厨叫号”的智慧,正是 JavaScript 异步编程的核心。JS 在浏览器里也只有“一个咖啡师”(单线程),但靠事件循环这把“总调度”,它可以同时照顾成千上万个任务——点击、滚动、网络请求、动画渲染,全都井井有条。Promise 就是那一张张取餐器,async/await 则是你听到叫号后从容起身的优雅姿态。这篇文章,我们就从咖啡馆的运营流程,看懂前端异步编程的整个系统。
掌握事件循环、Promise 与 async/await,是前端工程师从“会写 JS”迈向“吃透 JS”的分水岭。面试必考、工程必备、性能优化绕不开的基石。
目录
可视化演示
演示事件循环中微任务与宏任务的调度、Promise状态流转与异步链式调用,展示JavaScript异步编程的核心执行机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
事件循环 微任务 Loop 先于宏任务执行
同步与异步:别在柜台前干等
回想咖啡馆改革前的场景:顾客A点单后,咖啡师转身做咖啡,A就站在柜台前盯着;做完端给A,才开始接待顾客B。如果一杯咖啡要 3 分钟,三个人就要 9 分钟,期间所有人的时间都被“冻”在队伍里。这种“一件事没做完,天塌下来也得等”的模式,就是同步。
前端世界的“同步”也一样。JavaScript 是按顺序逐行执行代码的,比如 const a = 1 + 2; 这行不执行完,下一行永远不会开始。如果在代码里写一个巨大的循环去计算,浏览器整个页面会卡住不动,就像顾客全堵在柜台前,后面的客人进都进不来。这个“排队结算”的机制,专业名字叫调用栈(Call Stack)——它像一个堆叠的盘子架,函数调用一层层压上去,执行完一层再弹出,永远后进先出。
那异步长什么样?改革后,顾客A点单后拿到取餐器就走开了,咖啡师立刻接待顾客B。同样一个人做咖啡,三个人只花 3 分钟多一点点就都能拿到。对应到代码里,异步就是:发起一个任务(比如请求接口),不傻等结果,先注册一个“做好了叫我”的回调,然后该干嘛干嘛。
这就是“非阻塞”的魔力。同步和异步没有绝对好坏:同步适合必须按顺序完成的逻辑,异步则专门对付耗时的操作——网络请求、定时器、文件读写。咖啡馆的改革告诉我们:把“等待”从主流程里摘出去,系统的吞吐量会质变。下一节,我们看看那个躲在柜台后面的“叫号总台”是怎么工作的。
- 同步执行链三个任务按顺序一个接一个完成,前一个不结束,后一个无法开始。
- 异步登记任务 \(T_3\) 先被“登记”为回调,主流程不等待它。
- 主流程继续登记完 \(T_3\) 后,立刻执行 \(T_2\),不再回头看。
- 迟到的回调将来某个时刻,\(T_3\) 的结果准备好了,它才会被拿出来执行。
事件循环:后厨的叫号总台
取餐器为什么会响?因为咖啡馆里有一个看不见的总调度:它盯着后厨的出品台,看到某杯咖啡做好了,就立刻触发对应的取餐器震动,同时把还在排队的订单按顺序送到后厨。这个总调度,在 JavaScript 里就叫事件循环(Event Loop)。
事件循环的工作逻辑极其简单,一句话:调用栈空了,就去队列里取下一个任务来执行;执行完了,再回去取下一个。 调用栈就是“正在被咖啡师动手做的这一单”,任务队列就是“等位的订单们”。咖啡师永远不会同时做两杯咖啡(JS 单线程),但他永远不会闲着——只要队列里有订单,他就取出来做。
那“任务队列”里到底装的是什么?异步任务的回调。比如 setTimeout(fn, 1000),是把 fn 放进了队列;fetch 成功后的回调,也是被丢进队列。事件循环就像一个尽职的店员,不停重复“看一看调用栈空没空 → 从队列拿任务放上栈 → 执行 → 再看一眼”。
事件循环最巧妙的地方在于:它让“等待”不再占用咖啡师。咖啡师(主线程)永远在干活,而顾客(异步任务)不需要傻等。不过眼尖的你可能发现了——队列其实不止一条。咖啡馆里有“普通订单”和“加急订单”两个队列,加急订单永远优先。这就是后面要讲的宏任务与微任务。先别急,我们得先认识那种“会响的取餐器”——Promise。
- 检查调用栈只有当前所有同步代码执行完了(调用栈变空),事件循环才会去取新任务。
- 取队头任务从任务队列最前面取出一个等待中的回调。
- 入栈执行把这个回调压入调用栈,开始执行它的全部代码。
- 回到第一步执行完后调用栈再次变空,于是开启下一轮检查,周而复始。
Promise 基础:一张会响的取餐器
改革后的咖啡店里,每个顾客手里都有一个取餐器。取餐器有三种状态:等待中(Pending)——下单了,咖啡还没好;已成功(Fulfilled)——咖啡做好了,震动通知你来取;已失败(Rejected)——对不起,今日份牛奶用完了,做不了。而且取餐器的状态一旦从“等待中”变成“已成功”或“已失败”,就永远不可逆,不可能从“成功”再退回“等待中”。
这就是 Promise 的本质:一个代表“未来结果”的对象。它有三个状态、只能转移一次。从前用回调函数处理异步时,代码经常长成“回调套回调套回调”的锯齿形——学术名叫回调地狱(Callback Hell)。Promise 的出现,就是要把这种“锯齿”拍平。
创建 Promise 的语法很直观:new Promise((resolve, reject) => {...}),你把异步操作放进这个函数里,成功时调用 resolve(值),失败时调用 reject(原因)。然后通过 .then() 接收成功的结果,.catch() 接收失败的原因,.finally() 不管成败都收尾。.then() 每次返回的还是一个 Promise,所以可以一直链下去。
用取餐器做类比,你会瞬间记住:new Promise 就是“发取餐器”,resolve 是“咖啡好了震一下”,reject 是“抱歉做不了响一声”,.then 是你听到震动后去取咖啡的动作。但一张取餐器只能响一次,所以 Promise 的状态也只允许改变一次。搞懂了这张取餐器,下一节我们看看“多张取餐器”怎么组合使用。
- 初始等待Promise 创建后立即处于 Pending,此时结果未知。
- 成功转移异步操作成功,调用
resolve(v),Promise 变成 Fulfilled,携带结果值 \(v\)。 - 失败转移异步操作失败,调用
reject(e),Promise 变成 Rejected,携带原因 \(e\)。 - 不可逆约束状态只能从 Pending 出发转移一次,之后任何
resolve/reject都无效。
Promise 进阶:多杯咖啡的组合魔法
你在咖啡馆帮三位同事一起点单:一杯拿铁、一杯美式、一杯热可可。你有三个选择:等三杯全部做好再一起端过去;谁先做好就先喝谁的;不管哪杯做砸了,好的先拿,坏的事后单独说。这正是 Promise 家族四大金刚的分工。
假设三杯咖啡对应的 Promise 分别是 \(p_1, p_2, p_3\):
Promise.all([p1, p2, p3]):三杯全部做好才 resolve,只要有一杯失败就整体 reject。适合“三个接口数据都齐了才能渲染页面”的场景。
Promise.race([p1, p2, p3]):谁先出结果(成功或失败都算)就返回谁。适合“三秒超时竞速”——让一个真实的请求跟一个setTimeout赛跑。
Promise.allSettled([p1, p2, p3]):不管成功失败,全部有结果后一起返回,每个结果带status标记。适合“批量上传,逐个上报成败”。
Promise.any([p1, p2, p3]):谁先成功就返回谁,全部失败才整体失败。适合“多个备用服务谁先通就用谁”。
它们的核心价值是并行——三张取餐器同时震动,而不是你端着盘子等完第一杯再点第二杯。
使用这些组合方法时有个常见坑:Promise.all 一旦某个任务失败,整体立刻 reject,但其他兄弟任务并不会被“取消”,它们仍在后台默默运行。所以设计时要给每个子任务单独加 .catch 兜底,或者干脆用 allSettled。咖啡师把单都下了,你不想让一杯失败的咖啡毁掉整个订单——这就是进阶组合的智慧。
T_{\text{all}} = \max(T_1, T_2, \ldots, T_n)\quad\quadT_{\text{race}} = \min(T_1, T_2, \ldots, T_n)- all 的总耗时并行发起后,总耗时等于所有子任务中的最大值——最慢的一杯决定了整个订单。
- race 的总耗时谁最先到达终点就采用谁,所以总耗时是子任务的最小值。
- allSettled 兜底不论成功失败,全部结束后统一汇总,不因单个失败而“掀桌子”。
- any 的容错只要有一个成功就返回;全部失败才拒绝——多备选方案的典型策略。
async/await:优雅地听语音叫号
取餐器固然好用,但要你在 .then 里写一堆回调,遇到“先等登录、再拉资料、再渲染页面”的流程,依然会套娃成三层。咖啡馆后来升级了:不用盯取餐器,店里用语音叫号——“请 37 号顾客取餐”,你听到后从容起身。async/await 就是这声“语音叫号”:它让异步代码长得像同步代码一样平直,但本质上完全不阻塞。
async 关键字用来声明一个异步函数,这个函数的返回值会被自动包装成一个 Promise。await 只能用在 async 函数内部,它表示“在这里暂停,等这个 Promise 出结果,然后再继续往下走”。暂停期间,主线程并没有闲着——它继续去跑别的任务,等结果好了再回来恢复执行。
举个最直观的对比。用 Promise 链:
用 async/await:
后者读起来就像同步代码——从上到下,一目了然。错误处理也从 .catch 恢复成熟悉的 try/catch。
不过要注意:await 只是“看起来”同步,它依然是异步的。如果三个 await 串行写,那就是“等第一杯做完再点第二杯”;如果三杯互不依赖,应该用 Promise.all 并行——这就是“并行优化”的学问。语音叫号让你写得优雅,可别因此忘了咖啡师其实可以同时做三杯。
- await 暂停遇到
await p时,当前函数挂起,不再往下执行——但函数外部的主线程继续运行。 - 注册续体
await内部等价于给 \(p\) 注册了一个.then回调:成功后把值解出来。 - 失败分支如果 \(p\) 被拒绝,
await会把这个错误原样抛向你所在的try/catch。 - 恢复执行Promise 落定后,事件循环把后续代码作为一个新任务推回,函数从这里继续。
fetchUser().then(user => fetchPosts(user.id)).then(posts => render(posts));const user = await fetchUser();const posts = await fetchPosts(user.id);render(posts);
任务优先级:谁先被叫到
咖啡馆有两个订单队列:一个放加急单(微任务),一个放普通单(宏任务)。规则是:咖啡师每做一个普通单,中间只要有加急单进来,就必须先把加急单全部做完,才能碰下一个普通单。这听上去不太公平,但正是事件循环的真相。
宏任务(Macro Task)包括:setTimeout、setInterval、I/O 操作、用户点击事件、页面渲染等;微任务(Micro Task)包括:Promise.then 回调、async/await 后续代码、queueMicrotask、MutationObserver 回调。两者的执行顺序有一套铁律:
来一个经典输出题:console.log('A')、setTimeout(() => console.log('B'), 0)、Promise.resolve().then(() => console.log('C'))、console.log('D') 依次执行,你猜输出顺序?答案是 A D C B。因为 A、D 是同步代码先跑完;C 是微任务,排在下个宏任务 B 之前。记住这个规则,面试里 90% 的“输出顺序题”你都能秒杀。这就像顾客大喊“麻烦插个队(微任务)”,店员必须马上处理完所有插队的,才继续叫下一个普通号。
- 先做宏任务事件循环从宏任务队列取出一个任务,执行完它的全部代码。
- 清空微任务这个宏任务执行期间注册的所有微任务,全部按顺序执行完,一个都不剩。
- 有机会渲染浏览器趁这个空隙决定是否更新页面,保证动画流畅。
- 进入下一轮再取下一个宏任务,如此循环。
工程实战:把咖啡馆开成连锁
单店运营熟练了,店长要开连锁店——搬到前端工程里,就是把异步编程的招式用在真实业务上。这里有四个高频场景:防抖节流、请求重试、并发控制、竞态处理。
防抖(Debounce):顾客在收银台疯狂改单,每次改你都去后厨重做,咖啡师要崩溃。防抖的策略是“等他 300 毫秒不再改单,才真的下单”。前端里,用户在搜索框连续输入关键词,我们就用防抖只发最后一次请求。节流(Throttle):每秒最多做 10 杯,做多了排队;对应到滚动事件、拖拽事件,就是保证回调最多每 100 毫秒触发一次,不拖垮渲染。
请求重试:咖啡做砸了,店员道个歉重新做。网络请求偶尔失败很正常,于是我们写一个“带指数退避的重试器”——第一次失败等 500ms 再试,第二次等 1000ms,第三次等 2000ms,给服务器喘息的时间。
并发控制:连锁店不会让 100 个顾客同时涌进后厨。前端做批量上传时,我们用一个“并发池”限制同时最多发 3 个请求,而不是一次性狂发 50 个把服务器打挂。竞态处理:顾客连续点了三次不同的咖啡,最后一次叫号却先响——你取的到底是哪杯?前端里表现为快速切换筛选条件时,先发出的慢请求后返回,把旧数据覆盖了新数据。解决办法是给每个请求编号,只认最新编号的结果。这些工程招式,都是把异步编程的底层原理,打磨成稳定可靠的用户体验。
- 首次失败第一次请求失败后,记录重试次数 \(k = 1\),准备进入退避等待。
- 计算退避时间基准时间以 2 的幂次增长,让服务器有时间恢复。
- 延时后重试等待结束后重新发起请求,若成功则结束,失败则 \(k\) 加一。
- 放弃兜底重试次数用完仍然失败,转为展示降级提示,不让用户永远转圈。
AI 应用场景
AI 对话页面的流式打字机效果
阶段:前端交互与渲染优化
解决的问题:传统做法等整个回复生成完再一次性渲染,用户要盯着空白页发呆很久;AI 模型 Token 是流式返回的,前端必须边收边渲染。
具体案例:你在做一个 ChatGPT 风格的对话应用。后端通过 SSE(Server-Sent Events)逐段推送文本,前端用 async/await 循环读取数据流,每收到一段 Token 就把它追加到页面。因为用了事件循环的非阻塞机制,“边打字边输出”的过程中,用户还能同时滚动页面、点击按钮,界面丝滑不卡顿。
多模型并行聚合:一句话同时理解语音和图片
阶段:多模态推理聚合
解决的问题:多模态理解需要同时调用语音识别、图像分类、文本生成三个模型,串行调用会让总耗时等于三者之和,用户等得失去耐心。
具体案例:AI 助手接收用户上传的一张“拍下来的菜单”并接收语音指令“这道菜怎么做”。前端用 Promise.all 同时发起三个请求:语音转文字、OCR 识别菜单、图像分类。等三路数据都回来后,再交给大模型生成菜谱。allSettled 兜底保证即使 OCR 失败,语音和分类结果也能先用上。
AI 绘画提示词的防抖输入
阶段:用户体验优化
解决的问题:用户在提示词输入框连续打字,如果每敲一个字符就触发一次 AI 绘画请求,不仅浪费算力费用,还会因并发太多导致服务限流。
具体案例:绘画工具的 prompt 输入框绑定 input 事件,通过 300ms 防抖把“连续输入”合并为“最后一次输入”,停止敲键盘 300ms 后才真正发起生图请求。过程中如果上一条请求还没返回,就通过“请求序号竞态保护”丢弃旧响应,保证画布上永远展示最新 prompt 生成的结果。
Python 演示
import collections class MiniEventLoop: """极简事件循环:复刻 JS 的宏任务/微任务调度规则。""" def __init__(self): self._macro_q = collections.deque() self._micro_q = collections.deque() def add_macro(self, fn): self._macro_q.append(fn) def add_micro(self, fn): self._micro_q.append(fn) def run(self): while self._macro_q or self._micro_q: while self._micro_q: self._micro_q.popleft()() if self._macro_q: task = self._macro_q.popleft() task() while self._micro_q: self._micro_q.popleft()() def simulate_cafe(): loop = MiniEventLoop() logs = [] def order(name, will_spawn_micro): def task(): logs.append("宏任务:制作" + name) if will_spawn_micro: loop.add_micro(lambda n=name: logs.append("微任务:给" + n + "送餐")) return task loop.add_micro(lambda: logs.append("微任务:处理开店的加急单")) loop.add_macro(order("拿铁", True)) loop.add_macro(order("美式", True)) loop.add_micro(lambda: logs.append("微任务:处理会员的加急单")) loop.run() for line in logs: print(line) if __name__ == "__main__": simulate_cafe()
self._micro_q = collections.deque()用双向队列充当微任务队列,先进先出。self._macro_q = collections.deque()宏任务队列,和微任务队列平等共存。while self._micro_q:开跑前先把已有微任务全部清空,对应“微任务优先”。if self._macro_q:微任务清空后,才取出一个宏任务执行。task()执行宏任务的主体逻辑,过程中可能注册新的微任务。loop.add_micro(...)宏任务内部动态添加微任务,模拟 Promise.then 的行为。loop.add_macro(order("拿铁", True))制作拿铁时会产生一个“送餐”微任务。loop.run()启动事件循环,直到两个队列都为空才结束。
常见误区
误区我用了 `await`,代码就从上往下执行,这不就是同步吗?那 await 肯定也会卡住页面、阻塞后面的动画。
正解await 只是“看起来同步”。真实机制是:遇到 await 时当前 async 函数被挂起、让出主线程,事件循环继续跑别的任务;等 Promise 完成后,后续代码以「微任务」的身份被插队回来。就像取餐时你回座位刷手机,听到叫号才起身——等待期间你并没有站在柜台前堵路,页面一点不卡。
为何常见:因为代码书写的顺序是“等结果再继续”,阅读体验太像同步了,初学者很容易把“语法观感”当成“执行机制”。
误区只要用 `Promise.all` 就能并发,那把所有请求都塞进去准没错。
正解Promise.all 是并发发起,但它的失败语义是“一票否决”——只要有一个 reject,整体立刻 reject,其他请求的结果全部拿不到。如果某个请求失败但你仍然想要其他成功结果,应该用 allSettled;如果只想拿最快成功的,用 race 或 any。好比帮四个同事点单,一杯做砸了整个订单作废,可同事们只想喝到自己的那杯。
为何常见:很多人只记住了 all 的“并行加速”优点,忽略了它的“快速失败”特性,遇到真实的弱网环境才发现连页面都渲染不了。
误区延时 0 毫秒?那就是马上执行!
正解0 不是“立即”,而是“最早安排到下一个宏任务”。当前同步代码、微任务队列里的所有 Promise 回调,都会排在它前面。说白了,你喊“马上来一杯”,但后厨得先把加急单(微任务)清完,再按顺序做普通单。而且浏览器对嵌套定时器还有最少 4ms 的延迟限制。
为何常见:0 这个数字太有迷惑性,加上很多教程用“setTimeout 模拟异步”却没说清楚它的排队位置。
误区Promise 失败了就失败了呗,反正 UI 还能显示,控制台报错无所谓。
正解没有 .catch 捕获的 rejected Promise 会变成“未处理拒绝”(unhandledrejection),在浏览器控制台抛出警告,在 Node.js 较新版本里甚至直接让进程退出。取餐器响了没人接,后厨会在柜台一遍遍喊,最终记上一笔事故。每条 Promise 链都必须有终点:要么 catch,要么 await 到 try/catch 里。
为何常见:许多入门示例只演示成功路径,导致初学者根本没建立“Promise 链必须收尾”的工程意识。
面试题
Q1. 以下哪个特征最能描述“同步代码”?
- A.执行完才继续下一步← 正确答案
- B.注册后立即返回
- C.必须等待网络完成
- D.由事件循环调度
答案:A。同步代码按顺序逐行执行,前一行不结束,后一行不能开始,就像顾客站在柜台前死等。B、D 是异步任务的特征;C 只是异步的一种来源,不代表同步本身。
Q2. Promise 的“等待中”状态英文名是?
- A.Fulfilled 已成功
- B.Pending 等待中← 正确答案
- C.Rejected 已失败
- D.Settled 已定型
答案:B。Promise 初始状态就是 Pending,对应取餐器刚发出去、咖啡还没做好的时刻。Fulfilled 已成功、Rejected 已失败、Settled 是“已定型”的统称。
Q3. 执行 Promise.resolve().then(() => console.log(1)),回调中的 console.log(1) 何时运行?
- A.调用 then 的同一帧同步执行
- B.当次宏任务结束后执行← 正确答案
- C.延迟固定一帧后执行
- D.页面渲染完成后再执行
答案:B。Promise 回调属于微任务,它会在当前宏任务结束后、下一个宏任务开始前执行,相当于加急单插队。A 错在同步执行;C、D 都不是微任务的固定时机。
Q4. 下列哪种回调会在事件循环中“插队”优先执行?
- A.setTimeout 定时器回调
- B.用户点击事件回调
- C.Promise.then 微任务回调← 正确答案
- D.网络请求成功回调
答案:C。Promise.then 是微任务,会被优先清空。setTimeout、点击事件、网络请求都属于宏任务,每轮只执行一个。就像咖啡馆的加急单必须在普通单之间全部处理完。
Q5. 关于 async 函数,下列说法正确的是?
- A.async 必须搭配 await 才有意义
- B.async 函数总是返回 Promise← 正确答案
- C.await 可出现在普通函数中
- D.async 返回值不做任何包装
答案:B。async 函数无论内部返回什么,都会被包装成 Promise。A 错,async 单独用也合法;C 错,await 只能在 async 函数内使用;D 错,普通返回值会被自动包装。
Q6. 执行 Promise.all([p1, p2, p3]),当 p2 先 reject 时,all 的行为是?
- A.立即 reject 其余后台继续← 正确答案
- B.等待所有完成再返回
- C.忽略失败继续等待完成
- D.直接返回已完成的结果
答案:A。Promise.all 是“快速失败”策略,任一子任务 reject,整体立刻进入 rejected。注意其他子任务并不会被取消,仍在后台运行。好比一杯做砸了整单作废,但其他咖啡其实还在做。
Q7. 依次执行以下代码,输出顺序是?
- A.A D C B← 正确答案
- B.A B C D
- C.A D B C
- D.C D A B
答案:A。A、D 是同步代码先输出;C 是微任务,排在下一个宏任务 B 之前。经典的事件循环顺序:同步代码 → 微任务队列清空 → 宏任务。记住“加急单先做”就不会错。
Q8. 下列哪些属于“宏任务”?
- A.setTimeout 回调函数← 正确答案
- B.Promise.then 回调
- C.用户点击事件回调← 正确答案
- D.queueMicrotask 任务
答案:AC。宏任务包括 setTimeout、setInterval、I/O、用户交互事件等;微任务包括 Promise.then、async/await 后续、queueMicrotask。B、D 都是微任务,会在宏任务之间插队执行。
Q9. 关于 async/await 与事件循环,下列说法正确的有?
- A.await 会阻塞页面渲染
- B.await 后续是微任务← 正确答案
- C.串行比并行更耗费时间← 正确答案
- D.await 只能在 async 函数内← 正确答案
答案:BCD。await 不会阻塞页面,它让出主线程,所以 A 错。await 之后的代码以微任务形式恢复,B 对;多个不相关的 await 串行会累计耗时,不如 Promise.all 并行,C 对;await 只能写在 async 函数内部,D 对。
Q10. 关于 Promise 状态,下列说法正确的有?
- A.Promise 状态不可逆← 正确答案
- B.resolve 后还能再 reject
- C.Pending 是初始状态← 正确答案
- D.then 回调属于微任务← 正确答案
答案:ACD。Promise 状态只能从 Pending 转移一次,到 Fulfilled 或 Rejected 后不可逆,所以 A 对 B 错。初始状态一定是 Pending,C 对;then 注册的回调是微任务,D 对。取餐器只响一次,响完就定了。