本文适合:刚学完 Promise、async/await、Map 的前端同学。
目标:理解“带依赖关系的异步任务调度”是怎么做到的,并能自己写一个简易版本!
一、问题背景:任务也有「前后辈」
你可能遇到过这种情况:
“我有一堆任务(比如请求 A、请求 B、请求 C、请求 D),
但它们之间有依赖,比如 B 要等 A 完成,C 要等 A 完成,D 要等 B 和 C 都完成。”
这其实就像公司流程:
- A 是产品经理出需求;
- B 是前端写代码;
- C 是后端写接口;
- D 是测试上线。
你不能让测试先跑,得一步一步来!
于是我们就需要写一个函数,能帮我们自动搞清楚:
哪些任务能先跑、哪些得等、跑完了再触发下一个。
二、目标:我们要实现一个 runTasks(tasks)
给定任务列表:
const tasks = [
{
id: 'A',
run: () => new Promise(res => setTimeout(() => res('A done'), 5000)),
deps: []
},
{
id: 'B',
run: () => Promise.resolve('B done'),
deps: ['A']
},
{
id: 'C',
run: () => Promise.resolve('C done'),
deps: ['A']
},
{
id: 'D',
run: () => Promise.resolve('D done'),
deps: ['B', 'C']
},
];
这意味着:
A没有依赖,可以直接开跑;B和C都得等A;D要等B和C都完成。
三、思路:一图胜千行代码 💡
任务依赖关系可以想成一个「图」:
A → B → D
↘ C ↗
思路非常类似于「拓扑排序」:
- 先找出没有依赖的任务(
deps.length === 0); - 执行它们;
- 它们执行完后,解除它们对其他任务的“锁”;
- 直到所有任务都跑完。
四、完整实现代码
async function runTasks(tasks) {
// 1️⃣ 创建 Map,方便 O(1) 查找任务
const taskMap = new Map(tasks.map(t => [t.id, t]));
// 2️⃣ 统计每个任务的依赖数量(入度 indegree)
const indegress = new Map(tasks.map(t => [t.id, t.deps.length]));
// 3️⃣ 存储每个任务执行结果
const result = {};
// 4️⃣ 找出可以立即执行的任务(没有依赖)
let ready = tasks.filter(t => t.deps.length === 0).map(t => t.id);
console.log('初始 ready 队列:', ready);
// 🔧 定义执行单个任务的函数
async function run(id) {
const task = taskMap.get(id); // 根据 id 拿任务对象
const output = await task.run(); // 运行任务(可能是异步的)
result[id] = output; // 保存执行结果
console.log(`${id} 完成 ✅ -> ${output}`);
// 遍历所有任务,看谁依赖了我
for (const [tid, t] of taskMap) {
if (t.deps.includes(id)) {
// 把它的入度减 1(说明有一个依赖任务完成了)
indegress.set(tid, indegress.get(tid) - 1);
// 如果入度归零,说明它所有依赖都完成了,加入 ready 队列
if (indegress.get(tid) === 0) {
ready.push(tid);
console.log(` ${tid} 的依赖都完成了,加入 ready`);
}
}
}
}
// 5️⃣ 主循环:不断执行「当前能跑的任务」
while (ready.length > 0) {
// 浅拷贝 ready,防止异步时被修改
const currentBatch = [...ready];
ready = [];
// 并行执行当前批次所有任务
await Promise.all(currentBatch.map(run));
}
console.log(' 所有任务完成!结果:', result);
return result;
}
runTasks(tasks);
五、逐段讲解(重点来了!)
🧩 1. Map 的妙用
const taskMap = new Map(tasks.map(t => [t.id, t]));
- 相当于创建一个“字典”,键是任务 ID(如
'A'),值是任务对象; - 这样我们就能用
taskMap.get('B')直接拿到任务; - 查找速度是 O(1),比用
find()快得多。
📘 类比:像超市收银台的扫码枪,一扫商品条码(id),立刻知道是哪件商品(task)。
🧩 2. indegress(入度表)
const indegress = new Map(tasks.map(t => [t.id, t.deps.length]));
每个任务有多少依赖,就像它“欠”了多少任务。
- 比如 A:
deps=[]→ indegree=0 - 比如 B:
deps=['A']→ indegree=1
当一个依赖任务完成,就把它的 indegree -1。
如果 indegree 变成 0,就说明「所有依赖都搞定了,可以开跑了」。
🧩 3. ready 队列
let ready = tasks.filter(t => t.deps.length === 0).map(t => t.id);
- ready 就像「现在就能执行的任务清单」;
- 一开始只有 A;
- 后面每完成一个任务,就有新的任务加入 ready。
🧩 4. 浅拷贝 [...ready] 的原因
const currentBatch = [...ready];
ready = [];
这一步非常关键!
因为 run() 是异步的,可能在执行过程中又往 ready 里 push() 新任务。
如果我们直接对原数组操作,会造成混乱。
✅ 浅拷贝 = 拍一份快照,保证当前批次的任务列表固定。
等这一批跑完,再去执行新的 ready。
🧩 5. run(id) 的核心逻辑
async function run(id) {
const task = taskMap.get(id);
const output = await task.run(); // 等待任务执行完
result[id] = output;
for (const [tid, t] of taskMap) {
if (t.deps.includes(id)) {
indegress.set(tid, indegress.get(tid) - 1);
if (indegress.get(tid) === 0) ready.push(tid);
}
}
}
逐行拆解:
- 找到任务;
- 执行它(可能是异步,比如网络请求);
- 执行完记录结果;
- 通知其他依赖它的任务:「我搞定了!」;
- 如果某个任务的所有依赖都搞定了,就加入 ready。
类比:
A:我写完需求了!
B/C:好,我的条件满足了,可以开干了!
六、执行结果一览 👇
初始 ready 队列: [ 'A' ]
A 完成 ✅ -> A done
B 的依赖都完成了,加入 ready
C 的依赖都完成了,加入 ready
B 完成 ✅ -> B done
C 完成 ✅ -> C done
D 的依赖都完成了,加入 ready
D 完成 ✅ -> D done
所有任务完成!结果:
{
A: 'A done',
B: 'B done',
C: 'C done',
D: 'D done'
}
七、总结
| 步骤 | 关键词 | 作用 |
|---|---|---|
| 1️⃣ | Map | 快速查找任务 |
| 2️⃣ | indegress | 统计任务依赖数量 |
| 3️⃣ | ready 队列 | 保存当前能执行的任务 |
| 4️⃣ | Promise.all | 并行执行一批任务 |
| 5️⃣ | 浅拷贝 | 防止异步修改 ready 队列 |
| 6️⃣ | 拓扑排序思想 | 保证依赖有序执行 |
八、扩展:这套路还能干啥?
这个思路其实非常通用,比如:
- 🕹️ 构建系统(Webpack)根据依赖图决定编译顺序;
- 🚀 CI/CD 管道任务调度;
- 🧩 React Fiber 调度优先级;
- 🌐 浏览器资源加载依赖。
“图 + 拓扑排序 + Promise = 前端工程调度神器”
学会这一套,能让你在面试时吹到 HR 都笑出声。
🏁 结语
我们今天从 0 搭了一个简易的「任务调度系统」,核心思想就一句话:
谁没依赖谁先跑,谁依赖别人就排队。
而代码里每一行 Map、ready、Promise.all,看似简单,其实都对应了“异步有序执行”的底层逻辑。