在 JavaScript 的单线程事件循环模型中,异步任务默认是“非阻塞”且“并发”的。如果你直接发起 10 个异步请求(如 fetch 或 setTimeout),它们会几乎同时开始,结束顺序则取决于网络状况或定时器精度。然而,在实际业务场景中(如:先登录获取 Token,再依序请求用户信息、订单列表、个性化配置),我们往往需要严格的串行执行:任务 2 必须等任务 1 完成后才开始,任务 3 必须等任务 2 完成……以此类推。
如何实现这种“排队式”的异步执行?从早期的回调嵌套到现代的 async/await,JavaScript 提供了多种演进方案。本文将对比这些方法的优劣,并提供适用于 2026 年生产环境的最佳实践代码。

核心原理:打破并发,建立依赖链
顺序执行的本质,是将后一个任务的启动时机,绑定在前一个任务的完成状态上。
- 并发(Parallel):
Task1(),Task2(),Task3()同时触发。 - 串行(Serial):
Task1()-> (完成) ->Task2()-> (完成) ->Task3()。
要实现串行,必须利用 Promise 的状态机制(Pending -> Fulfilled/Rejected)或 async 函数的暂停特性,构建一条“承诺链”。
方案一:Async/Await + For 循环(现代首选,强烈推荐)
这是目前最清晰、最易维护的方案。async/await 是 Promise 的语法糖,它允许我们以“同步代码”的写法来处理异步逻辑,天然支持串行控制。
实现代码
// 模拟一个异步任务,耗时随机
function createTask(id) {
return new Promise((resolve, reject) => {
const time = Math.random() * 1000;
console.log(`[开始] 任务 ${id} 执行,预计耗时 ${time.toFixed(0)}ms`);
setTimeout(() => {
console.log(`[完成] 任务 ${id} 完成`);
resolve(`结果-${id}`);
}, time);
});
}
// 主执行函数
async function runTasksSequentially() {
console.log('--- 开始顺序执行 10 个任务 ---');
// 存储所有任务的结果
const results = [];
try {
// 关键点:在 for 循环中使用 await
// 每次循环都会等待当前 promise 解决后,才进入下一次循环
for (let i = 1; i <= 10; i++) {
const result = await createTask(i);
results.push(result);
// 如果某个任务失败,await 会抛出错误,循环终止
// 可以在这里添加逻辑:是否继续执行下一个?
}
} catch (error) {
console.error('任务执行过程中发生错误:', error);
// 处理错误逻辑,如回滚、记录日志
}
console.log('--- 所有任务执行完毕 ---');
console.log('结果集:', results);
return results;
}
runTasksSequentially();
优点
- 可读性极强:代码逻辑与同步代码几乎无异,线性思维,易于理解。
- 错误处理方便:配合
try...catch块,可以统一捕获整个序列中的任何异常。 - 数据传递自然:前一个任务的结果可以直接赋值给变量,供后续逻辑使用。
- 调试友好:在断点调试时,可以清晰地看到每一步的执行状态。
缺点
- 性能瓶颈:由于严格串行,总耗时 = 所有任务耗时之和。如果任务间无依赖,这种方式会比并行慢得多。
方案二:Promise.reduce(函数式编程风格)
如果你偏好函数式编程,或者需要在没有 async/await 的旧环境中运行(虽然 2026 年这种情况极少),Array.prototype.reduce 是构建 Promise 链的优雅方式。
实现代码
function runTasksWithReduce(taskCount) {
const tasks = Array.from({ length: taskCount }, (_, i) => i + 1);
// 初始值是一个已解决的 Promise (Promise.resolve())
// 它作为链条的起点,确保第一个任务能顺利接入
return tasks.reduce((prevPromise, currentId) => {
return prevPromise.then(() => {
console.log(`[Reduce 模式] 准备执行任务 ${currentId}`);
return createTask(currentId); // 返回新的 Promise,供下一次 reduce 使用
});
}, Promise.resolve());
}
runTasksWithReduce(10)
.then(() => console.log('Reduce 模式所有任务完成'))
.catch(err => console.error('Reduce 模式出错', err));
原理剖析
reduce 遍历数组,prevPromise 代表前一个任务完成的承诺。只有当 prevPromise 状态变为 fulfilled,.then() 中的回调才会执行,从而触发 createTask(currentId)。这天然形成了一条串行的 Promise 链:P1 -> P2 -> P3 ...。
优缺点
- 优点:代码紧凑,无需显式的
async函数声明,适合链式调用场景。 - 缺点:对于不熟悉函数式编程的开发者,理解成本稍高;错误处理不如
try...catch直观(需依赖.catch())。
方案三:递归调用(传统方案,不推荐)
在 Promise 普及早期,递归是实现串行的常见手段。通过在一个 Promise 的 .then() 中调用下一个任务来实现循环。
实现代码
function runTasksRecursively(index, max, results = []) {
if (index > max) {
console.log('递归模式所有任务完成');
return Promise.resolve(results);
}
console.log(`[递归模式] 执行任务 ${index}`);
return createTask(index)
.then(result => {
results.push(result);
// 关键:在当前任务完成后,递归调用自身处理下一个
return runTasksRecursively(index + 1, max, results);
})
.catch(err => {
console.error(`任务 ${index} 失败`, err);
throw err; // 抛出错误中断链条
});
}
runTasksRecursively(1, 10);
评价
- 缺点:代码结构相对复杂,存在潜在的调用栈溢出风险(虽然 Promise 微任务机制缓解了这个问题,但逻辑上仍是递归);调试时堆栈信息较长。
- 现状:在现代开发中,此方案基本已被
async/await取代,仅建议在理解 Promise 链式原理时参考。
方案四:自定义迭代器/生成器(高级玩法)
利用 Generator 函数 (function*) 和外部驱动,也可以实现串行控制。这通常是框架底层(如 Koa 中间件机制)的实现原理。
function* taskGenerator() {
for (let i = 1; i <= 10; i++) {
yield createTask(i);
}
}
async function runWithGenerator() {
const gen = taskGenerator();
const results = [];
let next = gen.next();
while (!next.done) {
// 等待 yield 出的 Promise 完成
const result = await next.value;
results.push(result);
next = gen.next();
}
console.log('Generator 模式完成', results);
}
runWithGenerator();
此方案灵活性极高,可以实现暂停、恢复、条件跳过等复杂控制流,但日常业务开发中略显繁琐。
关键场景对比:何时该用串行?
虽然我们要解决的是“如何串行”,但作为工程师,必须反思**“是否真的需要串行”**。
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 强依赖关系 (如:登录->获取Token->请求数据) |
串行 (Async/Await) | 后一步必须依赖前一步的结果,无法并行。 |
| 弱依赖/无依赖 (如:同时请求用户信息、商品列表、广告配置) |
并行 (Promise.all) | 互不干扰,并行可大幅减少总耗时(总时间 ≈ 最慢的那个任务)。 |
| 部分依赖 (如:A,B,C 并行,D 依赖 A,B,C 的结果) |
混合模式 | 先用 Promise.all([A,B,C]) 并行,再用 await 等待结果后执行 D。 |
| 容错要求高 (如:10 个任务中允许部分失败) |
串行 + 错误捕获 或 Promise.allSettled | 串行可在某步失败时立即停止;allSettled 可等待所有完成并收集状态。 |
性能警示
假设每个任务耗时 100ms:
- 并行:总耗时约 100ms。
- 串行:总耗时约 1000ms。
结论:除非有逻辑依赖,否则严禁对无关联任务使用串行执行,这会严重拖慢页面响应速度。
进阶:带并发限制的串行(队列控制)
有时候我们需要一种折中方案:既不想一次性发起 100 个请求压垮服务器,也不想纯串行太慢。这时可以使用并发限制队列(如每次只允许 3 个任务同时进行)。
虽然这超出了纯串行的范畴,但它是串行思想的扩展。简单实现思路如下:
async function runWithConcurrencyLimit(tasks, limit) {
const executing = [];
const results = [];
for (const task of tasks) {
// 执行任务
const p = task().then(res => {
// 任务完成后,从执行队列移除
executing.splice(executing.indexOf(p), 1);
return res;
});
results.push(p);
executing.push(p);
// 如果正在执行的任务数达到上限,等待其中一个完成
if (executing.length >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// 使用:创建 10 个任务,限制同时最多 3 个
const tasks = Array.from({length: 10}, (_, i) => () => createTask(i+1));
runWithConcurrencyLimit(tasks, 3);
总结
在 2026 年的 JavaScript 开发中,顺序执行 10 个异步任务的最佳答案是:使用 async/await 配合 for 循环。
- 首选方案:
async function+for (i...) { await task() }。代码最简洁,逻辑最清晰,调试最方便。 - 备选方案:
Promise.reduce。适合函数式风格或特定库的内部实现。 - 避免方案:回调嵌套(Callback Hell)和复杂的递归链。
- 核心原则:务必确认业务逻辑是否真的需要串行。对于无依赖任务,请毫不犹豫地使用
Promise.all进行并行处理,以获得最佳用户体验。
掌握这些模式,不仅能解决眼前的面试题,更能让你在面对复杂的数据流编排、事务处理和资源加载时,游刃有余地设计出高效、健壮的异步架构。