从回调到异步:等待期间是谁在工作
调用函数不意味着马上得到最终结果。读取文件、网络请求、计时器都需要宿主参与:JavaScript 注册工作后可以继续执行,等宿主准备好,再安排后续代码。这里要分清“把函数作为参数”和“稍后执行函数”。前者是回调,后者才涉及异步。
回调不一定异步
const result = [2, 4].map(n => n * 3);
console.log(result.join(',')); // 6,12
setTimeout(() => console.log('稍后'), 0);
console.log('现在'); // 现在,然后才是 稍后map 在返回之前已经调用回调;计时器回调则在当前同步代码结束之后才有机会运行。同一 API 如果缓存命中时同步回调、未命中时异步回调,调用方就很难稳定安排初始化顺序。设计接口时应统一时序,例如始终返回 Promise。
计时器来自宿主,不属于 ECMAScript 核心语言。setTimeout(fn, 0) 表示请求尽早调度,不是插队,也不是精确零毫秒。长同步计算、任务队列、后台页面节流都会延后它。测量耗时优先用单调时钟 performance.now(),日历时间戳 Date.now() 则可能受时钟校准影响。
回调里的 return 返回给谁
function readSize(callback) {
setTimeout(() => callback(5), 0);
}
const result = readSize(size => size * 2);
console.log(result); // undefined调用 readSize 的时候,计时器只被注册,函数没有返回值,所以调用方马上得到 undefined。未来回调返回 10,是把值交给调用它的那段计时器代码;外层 readSize 不会穿越回来再 return 一次。想把异步结果继续传递,调用方也要接受回调,或者返回一个代表未来结果的 Promise。异步会沿依赖关系改变接口,不能在最底层偷偷等待,然后假装上层仍是同步函数。
有时同步、有时异步,会怎样破坏初始化
下面的 queueMicrotask(fn) 把 fn 安排到当前同步代码结束之后运行。此处只用它保证“不会在 load 返回前调用”,它与计时器的完整调度关系见下一篇。
function load(cached, callback) {
if (cached) callback('缓存');
else queueMicrotask(() => callback('新结果'));
}
function inspect(cached) {
let ready = false;
load(cached, value => console.log(value, ready));
ready = true;
}
inspect(true); // 缓存 false
inspect(false); // 稍后输出 新结果 true同一调用位置,仅仅因为命中缓存,回调观察到的状态就不同。若约定始终异步,可以把缓存结果也安排到微任务,或统一返回 Promise,让消费者通过 then/await 处理。重点是接口承诺一致;不是所有回调都应该变成异步,例如 map 的同步语义就非常合理。
try 只保护当前执行路径
setTimeout(() => {
try { JSON.parse('{bad'); }
catch (error) { console.log(error.name); } // SyntaxError
}, 0);这里的 try 在真正抛错的回调里,所以能捕获。如果把 try 只包在 setTimeout(...) 外面,注册过程早已返回,未来回调抛错时那个调用栈不在了,外层 catch 无法跨时间伸手接住异常。
传统 Node 风格接口常用 callback(error, value) 传递失败;浏览器事件接口则可能有独立错误事件。约定来自具体 API,不能假定每个回调首参都是错误。回调通常还需要保证只调用一次,避免“先失败后成功”让状态发生两次变化。
把一次性回调转换为 Promise
先认识包装所需的四个动作。new Promise(executor) 立即调用 executor,并传入 resolve、reject 两个函数;本节的 resolve 接收成功值,reject 接收失败原因。消费者用 .then(value => ...) 接续成功步骤,用 .catch(error => ...) 处理失败;这些处理器都在当前同步代码之后执行。返回的 Promise 对象本身会立刻交给调用方,所以“立即返回对象”与“稍后才有最终值”可以同时成立。
先只跟踪一条成功路径:lengthLater 创建 Promise → 注册 readLater 回调 → 返回 Promise → 计时器调用回调 → resolve 交付长度 → then 处理长度。失败路径把 resolve 换成 reject,后续 catch 接收原因。下一篇再处理 thenable、状态锁定和链式传播的完整规则。
function readLater(text, callback) {
setTimeout(() => callback(null, text.length), 0);
}
function lengthLater(text) {
return new Promise((resolve, reject) => {
readLater(text, (error, length) => {
if (error) reject(error);
else resolve(length);
});
});
}
lengthLater('hello').then(console.log); // 5Promise 把一次最终结果统一成成功或失败。执行器本身立即执行,内部同步抛错会转换成拒绝;未来计时器回调中的异常不会自动被执行器捕获,仍需在实际异步边界调用 reject。也不要用 async 函数当 Promise 执行器:执行器返回值被忽略,异步函数自己的拒绝可能脱离外面的 Promise。
多次触发的事件不适合简单变成一个 Promise;点击流、持续数据流需要事件订阅或异步迭代。一次结果和一串结果是不同的接口承诺。
读懂接下来例子里的 await
await promise 表示暂停当前异步执行步骤:成功后以结果继续,失败后在 await 位置抛出异常,因此能用 try/catch 捕获。async function 定义可以使用 await 的函数,调用它总会返回 Promise;它不会阻塞整个线程。下面的顶层 await 例子请放在独立 .mjs 文件中用 Node 运行;若使用普通脚本,就包在 async 函数里并处理该函数返回的 Promise。此处只用这一层含义衔接示例,第三篇会按执行顺序展开暂停与恢复。
执行器捕获不到“未来回调里的解析”
下面把错误明确放在它实际发生的地方,再桥接到返回值。这个小接口约定回调首参是错误、第二参是文本:
function readText(callback) {
setTimeout(() => callback(null, '{invalid'), 0);
}
function readRecord() {
return new Promise((resolve, reject) => {
readText((error, text) => {
if (error) { reject(error); return; }
try { resolve(JSON.parse(text)); }
catch (parseError) { reject(parseError); }
});
});
}
try { await readRecord(); }
catch (error) { console.log(error.name); } // SyntaxErrorreadText(...) 调用本身若同步抛错,Promise 构造器能把它变成拒绝;但计时器回调执行时,执行器早已返回,所以解析处需要自己的捕获。也可以让第一层 Promise 只交付文本,再接 .then(JSON.parse),让处理器的异常规则承担解析失败。两种方式都明确把失败连接到了调用方等待的同一个结果。
包装对象方法时还要保留接收者,例如调用 reader.read(callback),不能只把 reader.read 取出来裸调用再惊讶于 this 丢失。多结果回调则要先决定成功值是数组还是对象;多次触发回调的订阅接口,不能因为 Promise 只采用首次结果就假设后续订阅已被清理。
计时器也是需要结束的资源
clearTimeout(id) 能取消尚未执行的计时器,不能倒转已经开始运行的回调。setInterval 按宿主规则重复安排回调,但不会等待 async 回调完成;前一次请求未结束时,下一次可能已经开始。需要“上次完成后再等待一段时间”,就用 await 加延时循环,并提供停止条件。
同一 agent 内的一段同步代码会运行到结束;异步不是让这段代码中间自动多线程切换。长计算要分片或交给 Worker,套一层 Promise 不会把计算搬到后台。
让下一轮明确发生在本轮结束后
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function inspectTwice() {
for (let round = 1; round <= 2; round++) {
console.log('开始', round);
await Promise.resolve(); // 表示一次需要等待的工作
console.log('结束', round);
if (round < 2) await pause(0);
}
}
await inspectTwice();
// 开始 1 → 结束 1 → 开始 2 → 结束 2下一轮是否启动由循环掌握,它只在上一次工作完成并经过间隔后继续。把工作换成较慢的操作,也不会让两轮重叠。例子故意只有两轮,所以能够自然终止;持续轮询还需要明确停止责任,后面的取消章节会把这个责任明确到 signal。
练习
为什么
map的回调是回调,却不是异步任务?将
try放到注册计时器的外层,能捕获回调中的解析错误吗?如果先 Promise 化文本,再用 then 解析,错误会沿哪条路径传播?为什么
setInterval(async () => ...)可能让网络请求重叠?给 reader 对象写 readJSON(reader),返回 Promise。reader.read(callback) 依赖 this.text;回调约定 (error, text)。同时处理调用 read 时同步抛错、回调传错、非法 JSON,成功返回解析后的值(包括对象、null、数字或布尔值)。用三个失败分支与一个成功分支验证,不能脱离对象裸调用 read。