事件大厅的插队者

定时器天还没亮就来排队,旁边的小门却“唰”地蹿出一个微任务:“我没插队,我走的是 VIP 通道。”队伍眼看越排越长,JavaScript 老大一口咖啡差点喷出来——Promise 什么时候还有这待遇了?

分享
事件大厅的插队者

小门又开了

本文承接上一篇:《JavaScript 老大的叠盘子游戏》

执行大厅的盘子刚收拾干净,门外的争吵声就更响了。

“明明是我先来的!” 一个定时器任务举着号码牌,气得满脸通红。

“别乱说。” 旁边的小门里,一名身材轻巧的微任务探出脑袋,“我没插队,我走的是 VIP 通道。”

“把插队说得这么清新脱俗?”

“抱歉,没空聊,后面又来了一位。”

小门“唰”地再次打开,第二名微任务抱着公文包冲了出来:“借过借过,我也有 VIP 卡!”

JavaScript 老大端着咖啡赶到现场,差点被撞个正着:“谁又插队?咖啡都要插我鼻孔里了!”

“注意用词,是 Promise 反应任务。” 微任务整理了一下衣领,“Promise 的 .then() 该执行了。”

“换个名字就不算插队了?”

普通任务们立刻围了上来。老大被吵得头疼,只好把双方都带进执行大厅。

“从头说。Promise 到底给你们发了什么通行证?”

这灯不能反悔

白幕上亮起一段代码:

const order = new Promise((resolve, reject) => {
    console.log("开始制作");

    setTimeout(() => {
        resolve("咖啡做好了");
    }, 1000);
});

一个圆滚滚的 Promise 抱着订单箱走了进来。箱子比肚子还大,顶部装着三盏灯:pendingfulfilledrejected

老大敲了敲亮着的 pending:“这盏灯的意思是‘先别催,还没结果’。工作成功,调用 resolve;工作失败,调用 reject。最后会进入 fulfilledrejected。”

Promise 赶紧点头,抱着箱子往后缩了缩,生怕真的有人上来催单。

老大看了一眼代码,伸手圈住第一句 console.log:“别看外面套着 Promise,这句可不用等一秒。传给 new Promise(...) 的执行器函数会立即同步执行。Promise 刚出生,工牌还没戴稳,执行器就已经开工了。”

老大又把笔尖移到 setTimeout 上:“真正等待一秒的是定时器安排的工作。Promise 本身可不是什么‘代码扔进去就自动异步’的魔法锅。”

屏幕立刻打印:

开始制作

一秒后,定时器任务进入任务队列。等老大领取并执行它,resolve("咖啡做好了") 才把订单箱的 fulfilled 灯点亮,并保存结果。

“那我再改成失败!” 一个调皮的函数冲上来,连续按下两个按钮:

resolve("咖啡做好了");
reject(new Error("咖啡机坏了"));

Promise 抱紧箱子,猛地往后一躲:“晚了!这又不是奶茶订单,做好了还能改三分糖。”

老大一巴掌盖住第二个按钮:“它说得对。状态一旦从 pending 变成 fulfilledrejected,就不能再改。后面的 resolvereject 不会把已经敲定的结果翻过来。”

“一份承诺,只认第一次敲定的结局。” 老大在制度牌上写道。

现在不行,待会儿优先

定时器任务指着 Promise 问:“结果都装进箱子了,微任务是什么时候冒出来的?”

Promise 打开登记簿,上面写着:

order.then((message) => {
    console.log(message);
});

老大翻到登记簿最新一页:“.then() 会登记结果变成 fulfilled 后该做什么。结果准备好时,对应的处理函数不会立刻闯进当前调用栈,而会领一张微任务通行证。”

“就算登记时结果早就准备好了,也不能马上执行?” 定时器追问。

“也不能。” 老大晃了晃手里的咖啡,“熟饭也得等这一口咽完。”

白幕换成了更短的例子:

console.log("A");

Promise.resolve("结果").then(() => {
    console.log("B");
});

console.log("C");

老大先执行第一句,屏幕出现 A。Promise 已经是 fulfilled,但 .then() 的处理函数只是走进微任务队列。老大继续跑完当前同步代码,打印 C,调用栈才空下来。

随后,老大在领取下一个普通任务前清空微任务队列,屏幕最后出现 B

A
C
B

“看见没有?” 老大敲了敲调用栈的立柱,“.then() 的处理函数不会突然打断正在执行的同步代码。所谓优先,是当前任务结束以后优先,不是半路把栈顶的盘子抢走。真敢抢,上一篇刚收拾好的盘子又得塌。”

普通任务们互相看了一眼。火气消下去一点,但没有完全消失。

箱子还会生箱子

Promise 又推来三个首尾相接的传送箱:

Promise.resolve(2)
    .then((value) => value * 3)
    .then((value) => Promise.resolve(value + 1))
    .then((value) => {
        console.log(value);
    });

“三只?” 老大绕着传送带数了一遍,“刚才进门的不是只有一只吗?”

老大指着三个箱子:“每次调用 .then(),都会立刻返回一个新的 Promise。”

第一名处理员拿到 2,拿计算器一顿猛按。

6!”

这个返回值决定了下一只 Promise 的结果,第二名处理员因此收到 6

第二名处理员没有直接交出数字,而是递来另一只 Promise。下一站掀开箱盖,发现里面还有一只箱子,脸都绿了:“怎么还套娃?”

“不拆箱,也不把整只 Promise 当普通货物往后扔。” 老大按住想动手的处理员,“等里面那只敲定结果,再把最终的 7 继续传下去。”

最后,屏幕打印:

7

“所以 Promise 链不是同一只箱子反复刷工牌。” 老大终于数明白了,“每个 .then() 都接出一只新的 Promise,上一段处理函数的返回值,会影响下一只 Promise 最终得到什么。”

老大又在最后一只箱子上敲了两下:“还有,处理函数什么都不返回,下一段通常会收到 undefined;处理过程中抛出错误,新的 Promise 就会变成 rejected。”

话音刚落,传送带“咣当”一声卡住了:

Promise.resolve("开始")
    .then(() => {
        throw new Error("机器卡住了");
    })
    .then(() => {
        console.log("这里不会执行");
    })
    .catch((error) => {
        console.log("已处理:", error.message);
        return "恢复工作";
    })
    .finally(() => {
        console.log("关闭机器");
    });

一团错误从传送带上弹了起来,越过中间几名成功处理员,直奔大厅出口。

“看我的!” .catch() 处理员甩出一张大网,把错误兜了个正着。

错误会沿着链向后传,直到拒绝处理函数接住它。.catch(onRejected) 本质上相当于 .then(undefined, onRejected);处理员返回 "恢复工作" 后,链条又回到了成功方向。

最后,.finally() 拎着扫帚慢悠悠地走了进来:“成功也好,失败也好,机器总得关,地总得扫。”

它不关心货物是成功结果还是失败原因,只负责执行收尾工作。正常情况下,它不会把原来的结果换掉;但如果收尾时再次抛错,或者返回了一个最终被拒绝的 Promise,链条仍会被新的失败结果改变。

“清理现场也可能把现场再弄坏。” 老大看着冒烟的机器,“很合理。”

零毫秒也得排队

定时器任务越听越不服,把一张测试单“啪”地拍在桌上,胸前还挂着一块硕大的 0

console.log("同步任务");

setTimeout(() => {
    console.log("定时器任务");
}, 0);

Promise.resolve()
    .then(() => {
        console.log("微任务 1");
    })
    .then(() => {
        console.log("微任务 2");
    });

console.log("同步结束");

“看见没有?零毫秒!” 定时器挺起肚子,“四舍五入就是瞬移,凭什么还在后面?”

“谁教你这么四舍五入的?” 老大把它按回椅子,“现场走一遍。”

当前任务里的同步代码先执行,打印 同步任务同步结束。定时器到点,只代表它有资格进入任务队列,并不代表能立刻占用调用栈。

当前任务结束后,老大开始进行微任务检查。微任务 1 刚跑完,链条又把 微任务 2 塞进队伍。

“现生的也算?” 定时器瞪大了眼睛。

“算。检查要持续到微任务队列清空。”

于是,微任务 2 也在这一轮被执行。

最后,老大才领取定时器任务。

同步任务
同步结束
微任务 1
微任务 2
定时器任务

“浏览器通常会在一次任务结束后进行微任务检查。” 老大拿笔圈住中间两行,“在领取下一个任务前,会一直处理微任务;处理微任务时新产生的微任务,也要在同一轮继续处理。页面是否在这之后获得渲染机会,还要由浏览器根据情况决定,并不是每轮事件循环都必然绘制一次。”

“所以 0 不是瞬移许可证。” 老大弹了一下它胸前的牌子,“延迟时间到了,只代表回调可以在条件允许时进入任务队列。什么时候真轮到执行,还得看前面堵了多少活。”

定时器低头看着胸前的 0,默默把牌子翻了过去:“原来写着零,也不代表马上轮到我……”

只许暂停一个

事件大厅刚安静下来,一名 async 函数举着暂停牌走进执行大厅:

async function prepareCoffee() {
    console.log("开始点单");

    const coffee = await Promise.resolve("拿到咖啡");

    console.log(coffee);
    return "喝完了";
}

const result = prepareCoffee();

console.log("先处理别的工作");

result.then((message) => {
    console.log(message);
});

prepareCoffee() 被调用后立刻开始执行,先打印 开始点单。遇到 await 时,老大计算它后面的表达式,“撕拉”一声,把函数剩下的工作单撕了下来。

“这一段先挂起。等结果。”

“整个 JavaScript 大厅都暂停吗?” 定时器眼睛一亮,“那是不是可以集体下班?”

“想得倒美。暂停的只是这个 async 函数当前这次执行的后续部分。” 老大把暂停牌挂在 prepareCoffee 的工作盘上,“调用它的外部代码继续跑,谁也不许摸鱼。”

“可这杯咖啡明明已经好了!” prepareCoffee 指着 Promise.resolve("拿到咖啡") 抗议。

“好了也得领号。” 老大把剩下的工作单送往微任务通道。

即使 await 后面是一只已经成功的 Promise,后续代码也不会在当前同步流程里立刻接上,而会等到微任务中恢复执行。因此屏幕接着打印 先处理别的工作,随后才打印 拿到咖啡

“还有一只箱子呢?” 老大指了指外面的 result

async 函数被调用时会返回一只 Promise。普通返回值 "喝完了" 会让这只 Promise 变成 fulfilled;如果函数抛出未处理的错误,它就会变成 rejected

最终顺序是:

开始点单
先处理别的工作
拿到咖啡
喝完了

“所以 async/await 没有把 Promise 开除。” 老大摘下暂停牌,“只是给它换了身更像顺序执行的制服。await 后面的代码,仍要通过 Promise 相关的微任务继续。”

永远清不空

定时器任务正准备离开,小门又“唰唰唰”地开了起来。

function keepCuttingIn() {
    Promise.resolve().then(keepCuttingIn);
}

keepCuttingIn();

第一个微任务冲进执行大厅,出来时又往门口的登记箱塞了一张新申请单。

第二个微任务冲进去,出来时又递上一张。

第三个也是。

队伍看起来永远只有短短一人,却怎么也清不空。一个人,硬是排出了千军万马的气势。

“轮到我了吗?” 定时器任务从门外探头。

“还没有!”

“页面申请重新绘制!” 渲染工人举着刷子来敲门,“再不让我进去,用户要以为按钮焊死了!”

“再等等!”

老大在执行大厅和事件大厅之间跑得只剩残影,咖啡放凉了三次,愣是一口没喝上。

微任务虽然每次都很短,但只要不断产生新的微任务,浏览器就迟迟无法进入下一个任务,也可能迟迟得不到渲染机会。按钮依然会卡住,页面依然可能失去响应。

“原来轻巧的工作也能堵门。” 老大终于拉下紧急闸门,“微任务优先,不等于微任务可以无限占用大厅。”

Promise 们排成一排,低头修改工作守则:不要用无穷的 Promise 链或 queueMicrotask() 循环长期占住微任务检查;大量工作需要主动拆分,让后续任务和渲染有机会喘口气。

插队写进制度

争执终于平息。

JavaScript 老大把新的制度牌挂到事件大厅门口。定时器踮起脚看了一眼,差点又气晕过去:

  1. 当前同步代码先沿调用栈执行完。
  2. Promise 的处理函数在结果可用后作为微任务运行,不会打断当前调用栈。
  3. 每次任务结束后,要处理微任务,直到微任务队列清空,才有机会进入渲染或下一个任务。
  4. async 函数总会返回 Promise;await 只暂停这次异步函数的后续执行,恢复部分仍通过微任务运行。
  5. 优先通行也有限度。微任务源源不断,普通任务和页面渲染照样会挨饿。

普通任务们读完制度,终于勉强点头:“行,可以优先,但不准在门里买房。”

老大刚端起咖啡,电话突然响了。

“JavaScript!” HTML 解析器在另一头怒吼,“一个普通 <script> 又挡在工地中间,DOM 才搭到一半,所有人都在等!这活没法干了!”

电话“啪”地挂断。

紧接着,一张皱巴巴的罢工通知从门缝里塞了进来。

定时器凑过来,小心翼翼地问:“这次又是谁插队?”

老大看着通知,长长叹了口气:“不是插队。是有人直接躺工地中间了。”