我学习 Node.js 异步模型时,最先被一个很小的输出顺序题卡住了。
当时我看到这段代码:
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve().then(() => {
console.log('C');
});
console.log('D');
我的第一反应是:setTimeout(..., 0) 既然延迟是 0,那是不是应该很快输出 B?
但实际输出是:
A D C B
这个结果背后有一个关键点:0ms 不代表“立刻插队执行”,它只是把回调放进宏任务队列。当前同步代码和微任务都处理完之后,才轮到它。
也就是从这个小题开始,我意识到 Node.js 异步开发不是“代码写在前面就先执行”,而是有一套明确的调度规则。理解这套规则之后,再看 Promise、async/await、fetch 超时和重试,就顺很多了。
Promise 像奶茶取餐号,不是奶茶本身
如果用生活里的例子理解 Promise,我觉得它很像奶茶店的取餐号。
你点了一杯奶茶,店员不会立刻把奶茶交给你,而是先给你一张取餐号。这张取餐号不是奶茶本身,但它代表一个未来结果:
奶茶做好了 -> 你拿到奶茶
材料卖完了 -> 店员告诉你做不了
Promise 也是这样。它不是结果本身,而是一个“未来会有结果”的对象:
取餐号 -> Promise
拿到奶茶 -> resolve(result)
材料卖完 -> reject(error)
await 就像你拿着取餐号等结果出来。结果成功,你就拿到值;结果失败,就会进入 try/catch 的 catch。
如果从 Java 经验类比,Promise 有点像 CompletableFuture:它表达的是未来会完成的结果,而不是一个新线程本身。
所以我现在会这样记:
Promise:未来结果的凭证
resolve:成功交付结果
reject:失败交付错误
await:等待这个凭证兑现
以前写 Node 风格回调时,经常会看到:
callback(error, result)
改成 Promise 后,对应关系就变成:
callback(null, result) -> resolve(result)
callback(error) -> reject(error)
再往前一步,async/await 只是让 Promise 的写法更接近业务流程:
try {
const result = await delayAddPromise(10, 20);
console.log(result);
} catch (error) {
console.error(error.message);
}
它不是把 Node.js 变成同步阻塞,而是让“等待未来结果”这件事写起来更直观。
事件循环:前台窗口、小纸条和重新排队
如果直接解释“同步代码、微任务、宏任务”,很容易变成背概念。我后来更容易理解的一种方式,是把 Node.js 想象成一个正在处理事务的前台。
前台一次只能处理一件事。你站在窗口前,正在办理业务,这就是同步代码。比如:
console.log('A');
console.log('D');
这类事情不需要等待别人回复,也不需要排到别的地方,前台会当场处理完。所以 A 和 D 会先输出。
微任务像什么?像前台刚办完当前业务,手边还有几张“必须马上补签”的小纸条。它们不是新的大业务,但必须在叫下一位客户之前处理掉。Promise.then 和 queueMicrotask 就属于这种小纸条:
Promise.resolve().then(...)
queueMicrotask(...)
所以当前同步代码结束后,Node 会先清空这些微任务。例子里的 C 就会在 B 前面输出。
宏任务更像是重新排队的一件新业务。比如你跟前台说:“过一会儿叫我回来办这个事。”哪怕你说的是“0 分钟后”,它也不是插队,而是进入下一轮排队。setTimeout 的回调就属于这种任务:
setTimeout(() => {
console.log('B');
}, 0);
这里最容易误解的是 0ms。它不是“马上执行”,而是“尽快安排到宏任务队列”。但只要当前窗口的同步代码没办完,手边的微任务小纸条没清空,它就还轮不到。
所以我们再回头看开头的例子,顺序就清楚了:
A、D:当前窗口正在办的业务,马上处理
C:手边必须马上补签的小纸条,当前业务结束后处理
B:重新排队的新业务,下一轮再处理
对应输出:
A D C B
这个类比不一定覆盖事件循环的所有细节,但足够建立第一层直觉:
同步代码:当前正在办理的业务
微任务:当前业务结束后、叫下一位之前必须清空的小纸条
宏任务:下一轮排队的新业务
等这个顺序有感觉之后,再去看 Promise、async/await 和 fetch,就不会觉得它们是在“随机插队”。
async/await:让异步代码更像业务流程
在 Weather CLI 里,我最喜欢的一段代码是主流程:
const location = await findCity(city);
const weather = await getCurrentWeather(location);
printWeather(location, weather);
这三行很接近人脑里的步骤:
先找到城市位置
再根据位置查天气
最后打印结果
这里有一个关键点:getCurrentWeather(location) 依赖 findCity(city) 的结果。天气 API 需要经纬度,而用户输入的是城市名,所以必须先把城市名转换成经纬度。
因此这两个请求不能直接并发:
// 不适合:天气请求还没有 location
await Promise.all([
findCity(city),
getCurrentWeather(location),
]);
async/await 的价值就在这里:它让异步流程仍然保持清楚的业务顺序。你不用陷在一层套一层的回调里,也不用把 Promise 链写得很长。
但也要记住:await 不是让整个 Node 进程卡住。它只是让当前 async 函数在这里等待 Promise 结果,事件循环仍然可以处理其他任务。
Promise 工具:组队通关、收作业和外卖比赛
学到 Promise.all、Promise.allSettled、Promise.race 时,我一开始会把它们混在一起。后来我发现可以用三种生活场景区分。
Promise.all 像组队通关。所有关键队友都到齐,任务才算成功;只要有一个关键队友没来,整个计划就失败。
对应到代码就是:
全部成功 -> 成功
任意一个失败 -> 整体失败
它适合“所有任务都是核心任务”的场景。
Promise.allSettled 像老师收作业。有人交了,有人没交,但老师最后会拿到每个人的状态,而不是因为一个人没交就不看其他人。
对应到代码就是:
每个任务都有结果
成功是 fulfilled
失败是 rejected
它适合“允许部分失败”的场景。比如天气查询是核心功能,随机笑话是附加功能,笑话失败不应该拖垮整个 CLI。
Promise.race 像两个外卖骑手比赛。谁先送到,你就先吃谁那份。它不关心另一个骑手后面会不会送到,也不等所有人结束。
对应到代码就是:
先 resolve -> race 成功
先 reject -> race 失败
所以用 Promise.race 做超时时,本质上是让“真实请求”和“超时错误”比赛:
await Promise.race([
fetchData(),
timeoutAfter(1000),
]);
如果 fetchData() 先完成,就用它的结果;如果 timeoutAfter(1000) 先失败,就得到超时错误。
但这里有一个坑:race 只是“先听谁的”,不会自动取消另一个还没结束的任务。就像你先吃到了 A 骑手送来的外卖,不代表 B 骑手的订单自动消失了。
所以如果慢任务是 fetch,真正取消请求还需要 AbortController。
AbortController 更像是你主动打电话给平台取消还没送到的订单。代码里就是把一个取消信号传给 fetch:
const controller = new AbortController();
const response = await fetch(url, {
signal: controller.signal,
});
如果超时时间到了,就调用:
controller.abort();
这时请求会被中止,并抛出类似 AbortError 的错误。这样才是“真的取消请求”,而不仅仅是“我不等了”。
fetch:快递到了,不等于东西已经拆出来
我一开始还误会过 fetch:以为 await fetch(url) 之后,拿到的就是 JSON 数据。
后来我更愿意把它想象成收快递。
fetch(url) 像是快递员把包裹送到你手里。这个包裹上有状态信息,比如是不是送达成功、状态码是多少、内容类型是什么。但你还没拆开它。
const response = await fetch(url);
这一步拿到的是 Response,不是里面的 JSON。
response.json() 才像是拆开包裹,把里面的东西拿出来:
const data = await response.json();
所以 fetch 常见写法是两次 await:
const response = await fetch(url);
const data = await response.json();
还有一个容易踩的点:快递送到了,不代表里面一定是你想要的东西。HTTP 404、500 这类状态,fetch 通常不会自动进入 catch,所以要自己看状态:
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
这一步就像先检查包裹状态:如果已经明确失败,就不要继续假装里面有正常数据。
我把这些细节封装进了一个 fetchJson 函数里:
async function fetchJson(url, options = {}) {
const timeoutMs = options.timeoutMs ?? 3000;
const retries = options.retries ?? 2;
const baseDelayMs = options.baseDelayMs ?? 500;
// 请求、超时、状态码检查、重试逻辑都放在这里
}
这样业务函数就不需要每次都重复写 fetch、response.ok、response.json() 这些细节。
用 Weather CLI 把知识串起来
最后我用一个 Weather CLI 把这些概念串起来。它的主流程并不复杂:
读取城市名
-> 调用地理编码 API 获取经纬度
-> 调用天气 API 获取当前天气
-> 把 weathercode 映射成中文描述
-> 打印结果
为什么要先查经纬度?因为 Open-Meteo 的天气 API 不直接接收 Shanghai 这种城市名,它需要 latitude 和 longitude。所以我们要先调用地理编码 API,把城市名转换成坐标。
项目里对应的主流程是:
const location = await findCity(city, requestOptions);
const weather = await getCurrentWeather(location, requestOptions);
printWeather(location, weather);
这个流程里刚好用到了前面几块知识:
- 城市名转经纬度、经纬度查天气,是两个有依赖关系的异步步骤,所以不能直接
Promise.all。 - 请求接口用
fetch,但要先检查response.ok,再response.json()。 - 请求太久没响应时,用
AbortController取消。 - 临时网络错误或 502/503/504,可以用重试处理。
- API 返回的
weathercode是机器码,需要客户端映射成人能读懂的天气描述。
重试这块也可以用生活例子理解:如果你打电话给一个服务窗口,第一次没人接,最好的方式不是一秒内疯狂打 100 次,而是隔一小会儿再试;如果还失败,就隔更久再试。
这就是指数退避:
第 1 次失败 -> 等 500ms
第 2 次失败 -> 等 1000ms
第 3 次失败 -> 等 2000ms
代码里对应的核心公式是:
const delayMs = baseDelayMs * 2 ** (attempt - 1);
现在 CLI 可以这样运行:
node projects/01-weather-cli/weather.js Shanghai --retries=0 --timeout=5000
输出示例:
城市:Shanghai, China
天气:阴天
温度:26.9°C
风速:9.2 km/h
到这里,这个 CLI 已经不只是“查天气”。它把第一周几个关键知识点都串起来了:
命令行输入
异步请求
超时取消
失败重试
数据转换
命令行输出
这周最容易踩的坑
我会把这一周的坑总结成几条:
fetch(url)返回的是 Promise,await后得到 Response,不是 JSON。response.json()也需要await。fetch遇到 HTTP 404/500 不一定进入catch,要检查response.ok。Promise.race不会自动取消慢任务。- 取消
fetch要用AbortController。 - 不是所有错误都适合重试,城市不存在这类业务失败不应该重试。
- API 返回的
weathercode是机器码,需要客户端映射成人能读懂的文案。
其中我觉得最值得记住的是:异步开发不只是“请求接口然后 await 一下”。真正写成小项目时,你还要考虑超时、错误、重试、数据转换和输出格式。
结尾
这一周最大的收获,不是“写了一个天气查询工具”这么简单。
更重要的是,我开始能分清几件以前容易混在一起的事:
Promise 不是线程,而是未来结果的凭证
async/await 不是同步阻塞,而是更清晰地等待 Promise
Promise.race 可以做超时判断,但不会自动取消慢任务
fetch 拿到的先是 Response,不是 JSON
AbortController 才是真正取消请求的方式
这些概念单独看都不算大,但放进 Weather CLI 这个小项目里,就变成了一条完整链路:
读取输入 -> 请求 API -> 处理超时 -> 失败重试 -> 转换数据 -> 输出结果
下一篇,我会继续把这套能力往前推进一步:用 Express 把它封装成自己的 HTTP API。