前端必修炼之拓扑排序!如何让一堆「有依赖关系」的异步任务自动按顺序执行

270 阅读5分钟

本文适合:刚学完 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 没有依赖,可以直接开跑;
  • BC 都得等 A
  • D 要等 BC 都完成。

三、思路:一图胜千行代码 💡

任务依赖关系可以想成一个「图」:

AB → D
  ↘ C ↗

思路非常类似于「拓扑排序」:

  1. 先找出没有依赖的任务(deps.length === 0);
  2. 执行它们;
  3. 它们执行完后,解除它们对其他任务的“锁”;
  4. 直到所有任务都跑完。

四、完整实现代码

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() 是异步的,可能在执行过程中又往 readypush() 新任务。
如果我们直接对原数组操作,会造成混乱。

✅ 浅拷贝 = 拍一份快照,保证当前批次的任务列表固定。
等这一批跑完,再去执行新的 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);
    }
  }
}

逐行拆解:

  1. 找到任务;
  2. 执行它(可能是异步,比如网络请求);
  3. 执行完记录结果;
  4. 通知其他依赖它的任务:「我搞定了!」;
  5. 如果某个任务的所有依赖都搞定了,就加入 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,看似简单,其实都对应了“异步有序执行”的底层逻辑。