JavaScript thenable 同化:普通对象带 then,为什么 await 会调用它

10-01 3阅读

给旧接口加上 async 包装后,原本应该返回一条记录,却突然返回数字;另一个对象只是读取字段就报错。此时不能只检查它是不是 Promise 实例。JavaScript 的异步结果接纳一种协议:如果对象的 then 属性是可调用函数,它就可能被当成 thenable 处理。

这个机制让不同异步库能够互通,也意味着 then 不是随便命名的普通方法。Promise 的解析过程会读取它,再安排调用,将提供的成功、失败回调交给该方法。最终等待到的值由这套交互决定,不一定是最开始传入的对象。

让一个普通对象自己报告调用次数

把下面代码保存为 demo.mjs,使用 node demo.mjs 运行。本例在 Node.js v24.19.0 验证,只操作内存,不请求网络。每次调用 then 都增加计数并交出计数值,因此两次等待是否共享结果可以直接观察。

JavaScript thenable 同化:普通对象带 then,为什么 await 会调用它

AI概念示意图:用抽象图形说明本文主题,不代表真实界面或运行结果。

import assert from 'node:assert/strict';
let calls = 0;
const source = {then(resolve) { calls++; resolve(calls); }};
assert.equal(source instanceof Promise, false);
const first = await source;
const second = await source;
assert.deepEqual([first, second], [1, 2]);
console.log(`awaits=${first},${second}`);
const shared = Promise.resolve(source);
assert.deepEqual([await shared, await shared], [3, 3]);
console.log('promise=3,3');
const ordinary = {then: 'later'};
assert.equal(await ordinary, ordinary);
const broken = {get then() { throw new Error('read failed'); }};
await assert.rejects(Promise.resolve(broken), /read failed/);
const packet = Object.create(null);
packet.value = source;
const received = await Promise.resolve(packet);
assert.equal(received.value, source);
assert.equal(calls, 3);
console.log(`wrapped=true calls=${calls}`);

等待对象,和复用一个 Promise 不同

输出的前两行是 awaits=1,2 与 promise=3,3。两次 await source 各自接纳这个对象,因而调用两次 then。先创建一个 Promise,再等待它两次,复用的是这个 Promise 已经确定的结果,source 只额外执行一次。协议本身并没有给普通对象增加结果缓存。

这在请求构造器或惰性查询对象里尤其重要。一个库可能把执行查询放在 then 中,重复等待就可能再次查询;是否如此应由库的契约和小实验确认,不能仅凭调用形式猜测。需要共享一次操作时,应在明确的生命周期里保存同一个 Promise。

只要有 then 字段,就一定会执行吗

普通记录的 then 值是字符串,不能调用,因此仍作为原对象返回。可调用性是这里的关键,不是属性名字出现过就必然发生异步执行。不过检查 then 本身可能触发 getter;例子里的 getter 主动抛错,Promise 因而拒绝,assert.rejects 验证错误确实传到了调用者。

不要先用 typeof obj.then 做一次“无副作用检测”,随后才调用 Promise.resolve。遇到访问器或代理时,检测本身已经读过字段,下一次读取还可能不同。接口接收任意对象时,应事先说明这是数据入口,还是允许执行 thenable 协议的入口。

要传原对象,可以包在另一层数据里

最后创建无原型的外层对象,并把 source 放到 value 字段。解析过程检查的是外层 then,不会递归等待所有嵌套字段,所以 value 仍然指向 source,调用计数也保持三。无原型外壳同时避免继承到意外的 then;这不是深拷贝,内部对象仍与调用方共享。

包装方式要成为接口契约的一部分:消费者应读取 packet.value,而不要立即再 await packet.value,否则又会进入同一协议。若业务真正需要异步执行,就直接接纳并处理失败;若业务需要传递一个带方法的数据对象,就用明确的外壳表达这层区别。

验收此类适配器时,至少覆盖普通值、不可调用的 then、会成功或失败的 thenable,以及读取 then 就报错的对象。另一个重要边界是永不调用回调的 thenable:它可能一直保持等待,因此不要把接纳协议误认为自动具备超时或取消能力。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。