前端学习笔记 · 第 123 天:回调与异步流程控制——错误优先、串行与并发限制
- 2026-11-08 11:14:53
前端学习笔记 · 第 123 天:回调与异步流程控制——错误优先、串行与并发限制
Day 123 · 第五阶段 · Node.js 与后端开发
排队的写法有三类:回调、Promise、async 与 await。回调最原始,也最容易把错误处理写漏;Promise 与 await 改善的是书写方式,不会自动给出并发上限。六个任务同时发出,与两个两个地发,代码只差几行,对下游的压力完全不同。
一、问题现场
四个现象:
回调里写 if (err) throw err,外层的try/catch没有任何反应,进程直接退出。读取用户、订单、统计三层数据,回调套了三层,每一层都要重复一次错误判断。 六个请求交给 Promise.all一起发出,接口返回 429,日志显示瞬时并发为 6。想按顺序执行,写成 for循环却漏了await,循环瞬间结束,任务全部并发。
前两个属于回调的错误处理,后两个属于流程控制。
二、错误优先回调
回调是一个作为参数传入的函数,在异步操作完成时被调用。Node 的约定是错误优先:回调的第一个参数是错误对象,成功时为 null,失败时是 Error。
import { readFile } from "node:fs";readFile("notes.txt", (err, data) => { if (err) { console.log(err.code); // 失败:ENOENT return; } console.log(data.length); // 成功:返回 Buffer});实测缺失文件时 err.code为ENOENT,data为undefined;读取成功时err为null,data是Buffer,内容为hello\n时长度是 6 字节。失败分支里data不可用。
三条使用规则:
先判断 err,非空就记录并return,不继续使用data。回调里的 throw不会被调用方的try/catch接住。回调在异步阶段执行,调用栈早已返回;实测外层catch没有触发,同步流程先打印完,随后抛出未捕获异常,退出码为 1。一个回调只应被调用一次。重复调用会让后续逻辑执行两遍,需要在封装时自行保证。
回调也可以转成 Promise。promisify 接收「最后一个参数是错误优先回调」的函数,返回 Promise 版本:
import { promisify } from "node:util";import { readFile } from "node:fs";const readText = promisify(readFile);const text = await readText("notes.txt", "utf8");实测返回值为字符串 "hello\n",与直接传 "utf8" 的结果一致。转换只改变调用方式,错误仍然在 Promise 里以拒绝的形式出现。
三、回调嵌套
每一步都依赖上一步的结果时,回调只能层层套进去:
function getUser(id, callback) { setTimeout(() => callback(null, { id, name: "小明" }), 50);}function getOrders(user, callback) { setTimeout(() => callback(null, [{ id: 1, amount: 99 }]), 50);}function getStats(orders, callback) { setTimeout(() => callback(null, { total: 99 }), 50);}getUser(1, (err, user) => { if (err) return console.log(err.code); getOrders(user, (err, orders) => { if (err) return console.log(err.code); getStats(orders, (err, stats) => { if (err) return console.log(err.code); console.log(user.name, orders.length, stats.total); }); });});三层调用得到 小明 1 99。结构上能跑,问题在扩展:每加一步就多一层缩进,错误判断重复一遍,中间变量被外层包住,单独复用其中一段只能整段拷贝。
拆分的方式是把每层写成命名函数,回调里只做「判断错误、调用下一层」两件事。层级不会因此减少,但每一步都能单独阅读,错误处理也集中在一处。
四、串行
串行要求前一个任务结束后再开始下一个,最直接的写法是 for 循环里 await:
async function serial(tasks) { const results = []; for (const task of tasks) results.push(await task()); return results;}每个 await 都会暂停循环,直到当前任务完成。六个延时分别为 300、200、400、100、500、250 毫秒的任务,延时合计 1750 毫秒,实测串行耗时 1784 毫秒,最大并发为 1。
串行适合有依赖的步骤:登录拿到令牌、用令牌请求数据、用数据渲染页面。顺序固定,任何一步失败都能立刻停下。
五、并行与并发限制
互不依赖的任务可以同时启动,Promise.all 接收一组 Promise,全部成功后按输入顺序返回结果:
async function parallel(tasks) { return Promise.all(tasks.map((task) => task()));}同一组任务实测耗时 510 毫秒,接近最慢的 500 毫秒任务,最大并发为 6。Promise.all 在第一个任务失败时立即拒绝,其余任务仍在后台继续执行,只是结果不再被采用。任务数量不受控制,这是它对下游的主要压力来源。
要限制同时执行的数量,需要一个池子:记录正在跑的任务,凑满上限就等最快的一个完成,再补进下一个。
async function limited(tasks, limit) { const results = []; const active = new Set(); for (const [index, task] of tasks.entries()) { const job = Promise.resolve() .then(() => task()) .then((value) => { results[index] = value; active.delete(job); }); active.add(job); if (active.size >= limit) await Promise.race(active); } await Promise.all(active); return results;}active 保存正在执行的任务,Promise.race(active) 返回最先结束的那一个;它完成后从集合里删除,循环继续补位。结果按 index 写回数组,所以返回顺序与输入一致,不受完成先后影响。
同一组任务以 2 为上限,实测耗时 922 毫秒,最大并发为 2。六个任务分三批,每批约 500 毫秒以内。
三种方式的选择依据:有依赖用串行;互不相干且数量小用并行;下游有承载上限时用限制并发。上限通常来自接口的限流规则或数据库连接数,取值应低于触发限流的阈值。
六、实战示例:三种执行方式对比
把三种跑法收进一个脚本,任务列表与延时完全相同,每种方式分别记录耗时与最大并发。
// run-tasks.mjs —— 串行、并行与限制并发三种执行方式const tasks = [ ["A", 300], ["B", 200], ["C", 400], ["D", 100], ["E", 500], ["F", 250],].map(([name, ms]) => async () => { await new Promise((resolve) => setTimeout(resolve, ms)); return name;});let running = 0;let maxRunning = 0;async function track(task) { running += 1; maxRunning = Math.max(maxRunning, running); try { return await task(); } finally { running -= 1; }}async function serial(tasks) { const results = []; for (const task of tasks) results.push(await track(task)); return results;}async function parallel(tasks) { return Promise.all(tasks.map((task) => track(task)));}async function limited(tasks, limit) { const results = []; const active = new Set(); for (const [index, task] of tasks.entries()) { const job = Promise.resolve() .then(() => track(task)) .then((value) => { results[index] = value; active.delete(job); }); active.add(job); if (active.size >= limit) await Promise.race(active); } await Promise.all(active); return results;}async function measure(label, runner) { running = 0; maxRunning = 0; const started = performance.now(); const results = await runner(); const cost = Math.round(performance.now() - started); console.log(`${label} ${cost}ms 结果 ${results.join(" ")} 最大并发 ${maxRunning}`);}await measure("串行", () => serial(tasks));await measure("并行", () => parallel(tasks));await measure("限制 2", () => limited(tasks, 2));运行结果:
$ node run-tasks.mjs串行 1784ms 结果 A B C D E F 最大并发 1并行 510ms 结果 A B C D E F 最大并发 6限制 2 922ms 结果 A B C D E F 最大并发 2串行耗时等于六段延时之和,并发的峰值始终为 1。并行把六段延时重叠在一起,总耗时由最慢的任务决定,峰值为 6。限制为 2 时,六个任务分三批推进,耗时约为并行的两倍,峰值稳定在上限。
track 在任务开始前加一、结束后减一(放在 finally 里,失败也会减),峰值用 Math.max 记录。三种方式的耗时每次运行会有几十毫秒的浮动,并发峰值与顺序保持不变。
常见问题
Q1:为什么回调的第一个参数必须是错误对象?
这是 Node 的统一约定,调用方不需要记每个函数的参数顺序,先看第一个参数就能判断成败。成功时传 null,失败时传 Error,错误码放在 err.code。
Q2:回调里的 throw 为什么外层接不住?
回调在异步阶段执行,那时调用方的函数栈已经返回,try/catch 的捕获范围也随之结束。抛出的错误变成未捕获异常,默认行为是打印堆栈并以退出码 1 结束进程。
Q3:串行、并行与并发限制如何选择?
后一步依赖前一步的结果时用串行;任务互不依赖、数量不大时用并行;下游有连接数或限流上限时用限制并发。并发上限取下游能稳定承受的值,而不是任务数。
Q4:Promise.all 属于并行还是并发?
它是并行:传入的任务一次性全部启动,没有数量上限。并发指的是同时执行的数量受控,Promise.all 不提供这个控制,所以任务多时要自己加池子。
Q5:并发限制为什么用 Promise.race?
池子凑满上限后,需要等其中一个完成才能补位。Promise.race 返回最先结束的任务,它在完成回调里把自己从集合中删除,循环随即补进下一个。
Q6:限制并发时如何保证结果顺序?
结果按下标写回数组:循环时用 tasks.entries() 拿到 index,任务完成后写入 results[index]。不要按完成顺序 push,否则返回顺序会随延时变化。
知识点总结
回调约定:
错误优先回调的第一个参数是
err,成功为null,失败为Error错误分支:
先判断
err并return,失败时data为undefined,不可继续使用抛错边界:
回调里的
throw变成未捕获异常,外层try/catch无法捕获回调嵌套:
每一步依赖上一步时只能层层套入,层级越多错误判断重复越多
串行:
for循环加await,总耗时等于各任务延时之和,最大并发为 1并行:
Promise.all一次启动全部任务,总耗时接近最慢一个,并发数等于任务数并发上限:
用
Set记录在跑任务,Promise.race等最快完成,再补进下一个结果顺序:
按下标写回数组,不能按完成顺序追加
选择依据:
有依赖用串行,互不相干且量小用并行,有承载上限用限制并发
(本文为学习笔记整理)