
JavaScript程序设计中的异步处理:Promise与async/await
请求接口、读取文件、等待定时器或执行数据库操作时,程序往往需要在结果尚未准备好时继续运行。JavaScript 用异步机制处理这类工作:发起任务后,代码可以先执行其他事情,等任务完成再处理结果。
Promise 是 JavaScript 表示异步结果的标准对象;async/await 则建立在 Promise 之上,让异步流程更接近顺序代码。理解二者的状态、错误传播和调度方式,能让代码既易读,也不容易在异常情况下失控。
一、异步处理解决什么问题
如果一个操作可能需要等待,直接阻塞整个程序通常并不理想。例如浏览器等待网络响应时,仍需要处理用户输入和页面绘制;Node.js 等待文件或网络操作时,也希望继续处理其他任务。
旧式代码常用回调表达“完成后再做什么”。回调本身并没有错,但多个依赖步骤嵌套后,会形成层层缩进;错误也可能需要在每一层重复传递。Promise 将异步结果统一表示为一个对象,让后续操作和错误处理可以组合起来。
异步并不等于并行,也不意味着 CPU 密集型工作自动变快。Promise 主要表达“未来会有结果”,不会自动把同步计算移到后台线程。大量循环、图像计算等重任务仍会占用当前线程,需要考虑 Web Worker、Node.js Worker Threads 或其他适合的计算方案。
二、Promise 的状态与基本用法
Promise 只有三种状态:
- pending:待定,操作尚未完成。
- fulfilled:已兑现,操作成功并提供结果。
- rejected:已拒绝,操作失败并提供原因。
Promise 一旦从待定状态变为兑现或拒绝,就称为已敲定(settled),之后不能再改变状态。兑现和拒绝都属于已敲定状态。实际业务里通常不需要手动创建 Promise,调用浏览器 API、Node.js API 或第三方库时,往往已经拿到了 Promise。
下面的示例可以直接在浏览器控制台或 Node.js 中运行。Promise 构造函数的执行器会在创建时同步执行;定时器回调稍后运行。
const task = new Promise((resolve, reject) => {
setTimeout(() => {
const succeeded = true;
if (succeeded) {
resolve({ message: "操作成功" });
} else {
reject(new Error("操作失败"));
}
}, 300);
});
task.then((value) => {
console.log(value.message);
}).catch((error) => {
console.error(error.message);
});
resolve 和 reject 负责敲定 Promise。执行器中抛出的同步异常会让这个 Promise 变为拒绝状态,因此下面的写法也能被 catch 捕获:
const task = new Promise(() => {
throw new Error("执行器出错");
});
task.catch((error) => {
console.error(error.message);
});
不过,很多场景并不需要自行包装 Promise。Promise 构造函数适合封装只提供回调接口的 API,或实现确实需要手动控制兑现、拒绝的操作。为已有 Promise 再套一层构造函数,容易造成重复逻辑甚至遗漏错误处理。
三、then、catch 与 finally:把异步步骤连起来
then(onFulfilled, onRejected) 为 Promise 注册处理函数,并且会返回一个新的 Promise。后续步骤关注的是这个新 Promise,而不是原来的 Promise。这一点是链式处理和错误传播的基础。
Promise.resolve(4)
.then((value) => {
return value * 2;
})
.then((value) => {
console.log(value); // 8
});
每个 then 回调返回的值,会成为下一步的兑现值。如果返回的是另一个 Promise,后续步骤会等待它完成,并采用它的结果。如果回调抛出异常,当前链条会转为拒绝状态。
Promise.resolve("输入")
.then((value) => {
throw new Error(`处理 ${value} 时发生错误`);
})
.then((value) => {
console.log("这一步不会执行", value);
})
.catch((error) => {
console.error(error.message);
});
catch(handler) 本质上相当于 then(undefined, handler)。它会处理前面链条中尚未处理的拒绝。若错误处理函数正常返回一个值,链条会恢复为兑现状态;若它再次抛错或返回拒绝的 Promise,错误会继续向后传播。因此,catch 不是简单的“打印错误”,它也会改变链条结果。
下面的写法容易让人误以为第二步会等第一步完成,但实际上没有建立依赖关系:
loadUser().then((user) => {
loadOrders(user.id); // 忘记 return
}).then((orders) => {
console.log(orders); // orders 通常是 undefined
});
应返回内部的 Promise,使外层链条等待它:
loadUser()
.then((user) => {
return loadOrders(user.id);
})
.then((orders) => {
console.log(orders);
});
finally 适合清理不依赖成功或失败结果的状态,例如关闭加载提示。它通常不会改变原来的兑现值或拒绝原因,但如果 finally 回调本身抛出异常,或返回一个拒绝的 Promise,整个链条会以新的错误拒绝。它也不会接收原结果参数。
showLoading();
fetchData()
.then((data) => {
render(data);
})
.catch((error) => {
showError(error.message);
})
.finally(() => {
hideLoading();
});
四、async/await:用同步风格书写异步流程
在函数声明前加上 async,函数就会返回 Promise。函数返回普通值时,结果会被包装为兑现的 Promise;函数抛出异常时,返回的 Promise 会被拒绝。
async function getNumber() {
return 7;
}
getNumber().then((value) => {
console.log(value); // 7
});
await 通常用于 async 函数内部。它等待一个 Promise 的结果:兑现时得到其值,拒绝时在当前函数中抛出相应错误。非 Promise 值也可以被 await,会按已兑现结果处理。
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function main() {
console.log("开始");
await wait(300);
console.log("等待结束");
}
main().catch((error) => {
console.error(error);
});
await 会暂停当前 async 函数后续代码的执行,但不会阻塞整个 JavaScript 线程。等待期间,其他任务仍有机会运行。需要注意的是,await 后面的表达式在执行到该行时会先被求值;若其中包含同步耗时计算,仍会阻塞当前线程。
五、错误处理:拒绝最终必须有人负责
在 async 函数中,Promise 拒绝会表现为 await 处抛出异常,因此可用 try/catch 处理。若错误无法在当前函数中恢复,应继续抛出,让调用方决定如何处理。
async function loadProfile() {
try {
const response = await fetch("/api/profile");
if (!response.ok) {
throw new Error(`HTTP 状态异常:${response.status}`);
}
return await response.json();
} catch (error) {
console.error("读取个人资料失败:", error);
throw error;
}
}
loadProfile().catch((error) => {
showError(error.message);
});
这里对 HTTP 状态码作了显式检查。浏览器的 fetch 在网络错误等情况下通常会拒绝,但收到 404 或 500 响应时,Promise 通常仍会兑现为一个 Response。因此,不能只依赖 catch 判断 HTTP 请求是否成功。
如果某个步骤允许失败并有默认值,可以在合适的位置恢复:
async function loadSettings() {
try {
const response = await fetch("/api/settings");
if (!response.ok) {
throw new Error("设置读取失败");
}
return await response.json();
} catch (error) {
console.warn("使用默认设置:", error.message);
return { theme: "light" };
}
}
恢复错误意味着调用方拿到的是正常兑现的 Promise,不能再依赖上层 catch 得知原操作曾经失败。若需要记录失败状态,可返回明确的数据结构,或在恢复前进行适当记录。
常见错误之一是在异步函数中启动操作,却没有返回或等待它:
async function saveAndNotify(data) {
saveData(data); // 没有 await 或 return
return "已提交";
}
这个函数只保证自己返回的 Promise 在返回“已提交”后兑现,并不保证 saveData 已经完成。若后续流程依赖保存结果,应写成 await saveData(data);若要把保存操作交给调用方等待,也可以直接 return saveData(data)。
六、顺序执行与并发:不要无意中等待两次
当第二个任务依赖第一个任务的结果时,应顺序等待。例如先取用户信息,再用用户 ID 获取订单:
async function loadUserAndOrders() {
const user = await fetchUser();
const orders = await fetchOrders(user.id);
return { user, orders };
}
如果两个任务互不依赖,却分别写成连续的 await,它们会先后开始,增加总等待时间:
async function loadDashboardSequentially() {
const profile = await fetchProfile();
const notices = await fetchNotices();
return { profile, notices };
}
可以先启动两个任务,再用 Promise.all 等待它们:
async function loadDashboard() {
const profilePromise = fetchProfile();
const noticesPromise = fetchNotices();
const [profile, notices] = await Promise.all([
profilePromise,
noticesPromise
]);
return { profile, notices };
}
Promise.all(iterable) 接受可迭代对象,常见参数是数组。所有成员兑现后,它兑现为按输入顺序排列的结果数组;任一成员拒绝时,它会立即以该原因拒绝。它不会自动取消其他已启动的任务,所以其余操作仍可能在后台继续运行。
其他组合方法适用于不同需求:
Promise.allSettled(iterable)等待全部成员敲定,不因单个拒绝而提前结束,结果数组中包含每项的状态和结果或原因。适合批量操作后逐项汇总。Promise.race(iterable)返回最先敲定的成员结果,无论兑现还是拒绝。它不会取消落后的操作,适合竞速选择结果,但不能单独实现真正的超时取消。Promise.any(iterable)在第一个成员兑现时兑现;如果全部拒绝,则以AggregateError拒绝。它适合尝试多个候选源、任一成功即可的场景。旧运行环境可能不支持此方法。
这些方法接收的是可迭代对象,不是一个必须为数组的特定参数类型。空输入也有明确差异:Promise.all([]) 和 Promise.allSettled([]) 会兑现为空数组;Promise.race([]) 会保持待定;Promise.any([]) 会以空的 AggregateError 拒绝。使用前应确认目标运行环境支持所需方法。
Promise.all 适合并发等待,但不能不加限制地同时启动成千上万个任务。大量并发请求可能触发服务端限流、耗尽连接或占用过多资源。任务量大时,应分批处理或使用并发数受控的队列;并发数应依据服务限制、资源预算和实际测量确定,而不是假定存在通用的固定值。
七、超时与取消:Promise 本身不会取消任务
Promise 是结果的表示,不是通用的取消机制。调用 Promise.race 与定时器竞速后,即使计时器先赢,原请求也可能继续占用网络或其他资源。若 API 支持取消,应使用该 API 提供的机制。
例如浏览器的 fetch 可以接收 AbortSignal。以下函数在达到指定时长后调用 abort,使支持该信号的请求被中止。超时时间应是有限、非负的合理数值;不同运行环境对异常类型和选项的支持可能有差异。
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
return await fetch(url, {
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}
fetchWithTimeout("/api/data", 5000)
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP 状态异常:${response.status}`);
}
return response.json();
})
.then((data) => console.log(data))
.catch((error) => console.error(error));
AbortController 能否中止任务,取决于对应 API 是否支持并遵守传入的信号。调用 abort() 后,fetch 通常会以拒绝结束;它不会自动撤销服务端已经完成的操作,也不会取消任意一个不识别 AbortSignal 的 Promise。
八、调度细节:为什么回调不是立即执行
对已兑现的 Promise 调用 then,处理函数也不会在当前同步代码中立即执行,而会进入微任务队列。常见情况下,微任务会在当前同步代码结束后、下一轮计时器任务处理前运行。await 后续代码也遵循 Promise 的异步调度语义。
console.log("A");
Promise.resolve().then(() => {
console.log("C");
});
console.log("B");
// 输出顺序:A、B、C
依赖“某个回调恰好比另一个回调早执行”的代码容易出现竞态问题。应通过显式的 Promise 依赖、状态管理或同步机制表达先后关系,而不是依靠偶然的时间安排。
九、常见错误与排查方法
1. 忘记 await 或 return
这是异步流程最常见的问题之一。函数可能在子任务完成前就返回,异常也可能脱离预期的处理链。检查每个异步调用:调用方是否需要等待结果?若不等待,是否明确接受它在后台继续运行,并安排了错误处理?
2. 把循环中的任务误写成串行或无限并发
在普通 for...of 循环中使用 await 会逐项等待,适合任务之间有依赖或需要控制节奏的情况。如果各项相互独立,可使用 Promise.all(items.map(...)) 并发等待;但任务量很大时应限制并发,不要直接启动全部任务。
async function processInOrder(items) {
const results = [];
for (const item of items) {
results.push(await processItem(item));
}
return results;
}
async function processTogether(items) {
return Promise.all(items.map((item) => processItem(item)));
}
不要把 forEach 当作可等待的异步循环:forEach 不会等待回调返回的 Promise。需要顺序执行时使用 for...of;需要并发时使用 map 收集 Promise,再决定采用 all、allSettled 或其他策略。
3. 以为 catch 会自动处理所有错误
只有接入当前 Promise 链、并且最终被观察到的拒绝,才会由相应的 catch 处理。独立启动但未返回的 Promise 不会因为外层链条有 catch 就自动被接管。对于“刻意不等待”的后台任务,也要用明确的方式记录或处理失败。
4. 在 Promise 构造函数执行器中写异步等待
执行器参数中的 resolve、reject 用于控制新 Promise,但执行器本身不是一个需要 await 的异步函数。若确实需要异步步骤,通常直接写 async 函数即可,不要为了使用 await 而再套 Promise 构造函数。
5. 把网络响应对象当作业务数据
fetch 返回的是 Response,还需要根据响应状态判断是否成功,再调用 json()、text() 等读取响应体的方法。这些方法本身也会返回 Promise。读取响应体通常是一次性操作,不应对同一响应体重复消费。
6. 只在终端打印错误,却没有定义恢复策略
日志有助于排查,但不能代替业务决策。用户提示、重试、默认值、回滚或向上抛出,取决于操作的重要性和调用方责任。重试也不应对所有错误一概而论:认证失败、参数错误通常需要先修正原因;短暂网络故障是否重试,需结合服务约定,并考虑次数限制与退避策略。
十、可靠性与安全边界
Promise 和 async/await 只负责异步流程表达,不提供输入校验、访问控制、数据加密或安全隔离。参数是否安全,取决于接收它们的 API 及其规范。例如,超时时长应进行合理范围校验;用户提供的 URL 不应因为放进 Promise 或传给 fetch 就自动变得安全。
处理不可信 URL 时,应按业务需要限制协议、主机和端口,避免服务端请求伪造等风险;具体规则应结合运行环境与网络边界设计。服务端尤其不能把用户输入的地址直接用于任意出站请求。传给数据库、命令行或 HTML 渲染接口的数据,也应使用相应场景的安全 API、参数化查询或输出编码,而不是依赖异步封装。
还要谨慎处理拒绝原因:不要将密钥、令牌、完整敏感响应内容写入日志或直接返回给用户。可以向用户提供稳定、有限的错误信息,同时在受控日志中记录必要的诊断上下文。不要假定第三方 Promise 一定会以 Error 对象拒绝;编写通用边界代码时,应考虑拒绝值可能不是标准错误对象,并避免直接读取不存在的属性。
十一、何时选哪种写法
- 单个异步操作,或多个具有先后依赖的步骤:优先用
async/await,配合明确的try/catch。 - 多个独立操作需要同时完成:先启动任务,再用
Promise.all;如果要收集每项成败,用Promise.allSettled。 - 多个候选任务中任意一个成功即可:考虑
Promise.any,同时评估运行环境兼容性。 - 需要适配回调 API:可以用 Promise 封装,但要确保成功、失败、同步异常都能正确处理。
- 需要取消或超时:优先使用底层 API 支持的取消信号;不要误把
Promise.race当作取消工具。 - 任务数量巨大或耗时计算很重:进一步考虑并发限流、任务队列或工作线程,而不只是改写语法。
掌握 Promise 的关键,不是记住多少个方法,而是明确每一步依赖什么结果、拒绝由谁处理、并发任务是否需要等待,以及取消是否真的能作用于底层操作。async/await 能让流程更容易阅读,但它不会改变这些边界。把这些问题纳入设计,异步代码才会在成功、失败和资源受限时都保持可预测。




