
JS 异步编程入门:理解 Promise 和 async/await
读取文件、请求接口、等待定时器,都是程序要等待某件事完成后才能继续的操作。如果等待期间整个 JavaScript 程序都停下来,页面会卡住,服务器也无法及时处理其他请求。JavaScript 的异步编程提供了一种处理等待的方式:先发起任务,继续做其他工作,任务完成后再处理结果。
Promise 是表达异步结果的对象,async/await 则提供了更接近同步代码的写法。两者并非两套互不相关的机制:async/await 建立在 Promise 之上。理解 Promise 的状态、错误传播和并发行为,才能在使用 await 时避免误判。
一、什么情况下需要异步编程
异步编程常用于等待外部操作完成,例如:
- 向服务器发送 HTTP 请求,等待响应;
- 在浏览器中读取文件或使用设备能力;
- 在 Node.js 中读取文件、访问数据库或调用网络服务;
- 延迟执行任务,或等待其他异步操作完成。
异步不等于“自动开一个线程”。浏览器和 Node.js 通常会把网络、定时器等操作交给运行环境处理;操作完成后,JavaScript 才会在合适的时机执行对应回调。JavaScript 主线程上的同步代码仍然一次执行一段,长时间计算仍可能阻塞页面或事件循环。
下面是一个最简单的等待示例。在浏览器控制台或 Node.js 中都可以运行:
console.log("开始");
setTimeout(() => {
console.log("定时器回调");
}, 0);
console.log("结束");
通常输出顺序是“开始”“结束”“定时器回调”。即使延迟设为 0,回调也不会插入当前同步代码中间;它要等当前代码执行完成后才有机会运行。定时器的延迟表示最早可执行的时间,不保证回调恰好在指定毫秒执行。
二、Promise 表示一个未来的结果
Promise 可以理解为一个代表异步操作最终结果的对象。它有三种状态:
- pending:等待中;
- fulfilled:已成功,携带一个结果值;
- rejected:已失败,携带一个拒绝原因,通常是错误对象。
Promise 一旦从等待状态变为成功或失败,就不会再改变状态。调用 resolve 或 reject 多次,只有第一次有效。需要注意的是,Promise 的状态不等于任务已经取消或停止;取消通常需要底层 API 自己提供机制。
下面用定时器构造一个 Promise。保存为 JavaScript 文件后,可在 Node.js 中运行:
function wait(ms) {
return new Promise((resolve) => {
setTimeout(() => resolve("等待完成"), ms);
});
}
wait(300).then((message) => {
console.log(message);
});
new Promise 的执行器函数会立即同步执行,并不是等到 then 时才执行。Promise 通常用来包装一个正在进行或稍后完成的操作,不适合把同步计算仅仅包一层就称为异步计算。
console.log("A");
const promise = new Promise((resolve) => {
console.log("执行器");
resolve(1);
});
promise.then((value) => {
console.log("then:", value);
});
console.log("B");
输出顺序是“A”“执行器”“B”“then: 1”。即使 Promise 已经成功,then 注册的处理函数也会异步执行,不会在当前调用栈中立即运行。Promise 的处理函数属于微任务;微任务通常会在当前同步代码结束后、下一轮定时器等任务执行前处理。
三、用 then、catch 和 finally 处理结果
then 为成功结果注册处理函数,也可以接收第二个参数处理拒绝;catch 是处理拒绝的常用写法;finally 用于清理工作,例如结束加载提示。它们都会返回新的 Promise,因此可以串成链。
function divideAsync(a, b) {
return new Promise((resolve, reject) => {
if (b === 0) {
reject(new Error("除数不能为 0"));
return;
}
resolve(a / b);
});
}
divideAsync(10, 2)
.then((result) => {
console.log("结果:", result);
return result * 2;
})
.then((value) => {
console.log("加倍:", value);
})
.catch((error) => {
console.error("操作失败:", error.message);
})
.finally(() => {
console.log("流程结束");
});
在 then 中返回普通值,下一个 then 会收到这个值;返回 Promise,下一个处理函数会等待它完成;抛出异常或返回拒绝的 Promise,错误会沿链向下传递,直到遇到能够处理它的 catch。
catch 处理错误后,如果正常返回一个值,链条会转为成功状态。这在“记录错误后使用备用值”时很有用,但如果只是打印错误然后继续,可能会让调用方误以为操作成功。确实需要让上层知道失败时,应在处理后重新抛出错误。
loadData()
.catch((error) => {
console.error("加载数据失败", error);
throw error;
})
.then((data) => {
// 只有成功时才会执行
render(data);
});
finally 通常不接收成功值或拒绝原因,其返回值也不会替换原结果。不过,如果 finally 内部抛出异常或返回拒绝的 Promise,最终结果会变成拒绝。因此,不要在清理代码中无意抛错,否则可能遮盖原来的错误。
四、async/await:用顺序写法组织异步流程
在函数前加上 async,函数就会始终返回 Promise。函数中 return value 等价于让返回的 Promise 成功得到这个值;函数内未捕获的异常会让返回的 Promise 失败。
async function getNumber() {
return 42;
}
getNumber().then((value) => {
console.log(value); // 42
});
await 通常写在 async 函数内部。它会等待一个 Promise 成功,并取出结果;如果 Promise 拒绝,就会在该位置抛出异常。await 也可以接收普通值,此时效果相当于等待一个已成功的 Promise。
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 不会阻塞整个 JavaScript 线程。它暂停的是当前 async 函数后续部分的执行;运行环境仍可处理其他事件。等待完成后,函数从暂停处继续。调用 async 函数本身也不会自动等待它结束,调用方仍需使用 await、then,或明确处理其拒绝。
可以用 try/catch/finally 组织错误处理和清理逻辑:
async function loadProfile() {
showLoading();
try {
const profile = await fetchProfile();
renderProfile(profile);
return profile;
} catch (error) {
console.error("读取个人资料失败", error);
showError("暂时无法加载");
throw error;
} finally {
hideLoading();
}
}
loadProfile().catch(() => {
// 在调用边界处理或上报错误
});
如果 catch 中不重新抛出异常,loadProfile 返回的 Promise 会以成功状态结束,即使请求实际上失败了。是否重新抛出,取决于调用方是否还需要知道失败,以及错误是否已经被完整处理。
五、串行还是并行:别让 await 意外降低效率
当第二个操作必须使用第一个操作的结果时,应当串行等待。例如先获取用户,再根据用户编号读取订单:
async function loadUserAndOrders(userId) {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
return { user, orders };
}
如果两个操作彼此独立,逐个 await 会让它们依次开始,整体耗时通常接近两者耗时之和。可以先启动两项工作,再用 Promise.all 等待:
async function loadDashboard() {
const profilePromise = fetchProfile();
const noticesPromise = fetchNotices();
const [profile, notices] = await Promise.all([
profilePromise,
noticesPromise
]);
return { profile, notices };
}
Promise.all 接收可迭代对象,例如数组,结果按输入顺序排列,而不是按完成先后排列。所有输入成功时,它成功返回结果数组;任意一个输入拒绝时,返回的 Promise 就会拒绝。它不会因此自动取消其他仍在进行的操作。传入空数组时,它会成功得到空数组。写错参数类型时,可能抛出同步异常,因此仍应使用正确的可迭代参数。
当需要收集每项任务的成功或失败,而不是遇到第一个失败就整体拒绝,可以使用 Promise.allSettled:
const outcomes = await Promise.allSettled([
fetchProfile(),
fetchNotices()
]);
for (const outcome of outcomes) {
if (outcome.status === "fulfilled") {
console.log("成功:", outcome.value);
} else {
console.log("失败:", outcome.reason);
}
}
Promise.race 会随最先完成(无论成功或失败)的输入确定结果;Promise.any 会在任意一个输入成功时成功,只有全部拒绝时才拒绝。这些方法解决的是“结果如何组合”,不是“如何限制同时运行的任务数”。如果要批量处理大量任务,应设计并发上限,避免同时发起过多请求或占用过多资源。
六、用 fetch 请求数据时的边界
浏览器中的 fetch 返回 Promise,常见写法如下:
async function loadJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP 错误:${response.status}`);
}
return response.json();
}
loadJson("/api/items")
.then((data) => console.log(data))
.catch((error) => console.error("请求或解析失败:", error));
一个常见误解是:服务器返回 404 或 500,fetch 就会自动拒绝。通常不会。只要请求顺利得到 HTTP 响应,fetch 就会兑现为 Response;应检查 response.ok 或状态码。网络错误、请求被中止等情况则可能导致 Promise 拒绝。调用 response.json() 也是异步操作,如果响应内容不是有效 JSON,它会失败。
请求失败时是否重试、重试哪些状态码、是否展示响应正文,都需要由应用根据接口约定决定。不要无条件无限重试:服务端可能仍不可用,请求也可能产生重复副作用。对于会创建订单、提交付款等有副作用的操作,重试策略尤其需要与服务端的幂等设计配合。
七、超时和取消不是一回事
让 Promise 在规定时间后拒绝,可以让调用方停止等待;但这本身不会取消底层工作。对于支持 AbortSignal 的 API,例如浏览器 fetch,可以使用 AbortController 请求中止:
async function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
fetchWithTimeout("/api/items", 5000)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP 错误:${response.status}`);
}
return response.json();
})
.then((data) => console.log(data))
.catch((error) => console.error("请求失败或被中止:", error));
这里的计时器负责调用 abort(),真正的中止依赖目标 API 是否支持传入的信号。即便本地请求被中止,也不能据此推断服务器没有收到请求或没有开始处理;它只表示客户端请求中止,不保证远端操作回滚。其他异步 API 若不支持取消机制,就无法仅靠 Promise 强行终止。
八、常见错误及其原因
1. 忘记返回 Promise
在 Promise 链中,如果启动异步操作却不返回它,外层链条就无法等待其完成,也无法接住它的拒绝:
// 不推荐:外层链条不会等待 saveData()
function saveAndRefresh(data) {
return validate(data)
.then((valid) => {
saveData(valid);
})
.then(() => {
refreshView();
});
}
// 推荐:返回异步操作,让链条继续等待
function saveAndRefresh(data) {
return validate(data)
.then((valid) => {
return saveData(valid);
})
.then(() => {
refreshView();
});
}
2. 误以为 forEach 会等待 async 回调
Array.prototype.forEach 不会等待回调返回的 Promise。需要逐项串行处理时,可使用 for...of;需要同时启动并等待时,可使用 Promise.all。
async function processInOrder(items) {
for (const item of items) {
await processItem(item);
}
}
async function processTogether(items) {
await Promise.all(items.map((item) => processItem(item)));
}
第二种写法会同时启动所有任务,不适合没有并发限制的大型列表。若任务之间依赖顺序,或接口有频率限制,应采用串行或有上限的并发策略。
3. 忘记处理拒绝
调用异步函数却既不 await,也不 catch,失败时可能形成未处理的 Promise 拒绝。应在合适的调用边界统一处理错误,而不是到处吞掉异常。浏览器和 Node.js 对未处理拒绝的报告方式可能不同,但都不应把它当作正常控制流程。
4. 把 await 写在不支持的位置
传统脚本中,await 需要位于 async 函数内。部分 JavaScript 环境支持模块顶层 await,但是否可用取决于运行环境与模块类型;为便于复用,通常可以把流程放入 async function main() 并显式调用。
5. 用 Promise 构造器包装已有 Promise
已有的 Promise 通常直接返回或组合即可。额外包一层执行器容易遗漏错误处理,也让代码更难读。只有在把回调式 API 转换成 Promise,或确实要自行控制 resolve/reject 时,才需要使用 Promise 构造器。
九、异步编程与安全的关系
Promise 和 async/await 只改变任务的组织与等待方式,不会自动验证输入、消毒 HTML、加密数据或提供权限控制。输入校验、输出编码、身份认证和授权,仍需按照具体应用的安全要求分别实现。不要因为一段代码使用了 await,就认为它天然安全。
同样,浏览器前端代码中的密钥、权限判断或隐藏的请求参数,都不能作为可靠的安全边界:用户可以查看和修改客户端代码,也可以自行构造请求。敏感凭据和关键授权判断应放在可信的服务端,并由服务端验证请求权限。通过 Promise 捕获异常也不等于安全地处理了错误;面向用户的错误信息应避免泄露不必要的内部细节,诊断信息则应通过适当的日志机制记录。
十、如何选择写法
需要表达连续的异步步骤时,async/await 通常更容易阅读,适合分支、循环和 try/catch。需要组合一组 Promise 时,Promise.all、Promise.race 等方法更直接。两种写法可以混用,但应保持错误处理清楚:每一个拒绝最终都要在合适的层级处理或继续抛出。
编写异步代码时,可以逐项检查:任务是否真的会返回 Promise;后续步骤是否需要前一步的结果;独立任务是否适合并行;失败是否会传递到调用方;超时是否只停止等待,还是底层 API 确实支持取消;批量任务是否需要限制并发。把这些边界想清楚,Promise 和 async/await 就不只是语法上的简化,而是可预测、可维护的异步流程工具。



