告别 Promise.all 的依赖困境:better-all 如何优雅管理异步任务

0 阅读7分钟

在现代JavaScript异步编程中,Promise.all无疑是处理并发任务的利器。然而,当任务之间存在复杂的依赖关系时,Promise.all的局限性便会显现,开发者往往陷入手动优化和维护"依赖地狱"的困境。今天,我们将深入探讨一个名为better-all的库,它如何通过巧妙的设计,优雅地解决了这一痛点,并带来了更高效、更可读的异步任务管理方式。

Promise.all 的困境:当依赖遇上并发

Promise.all的设计初衷是并行执行一组独立的Promise,并在所有Promise都解决后返回结果。但当任务存在依赖时,事情就变得棘手了。让我们通过几个场景来理解Promise.all在处理依赖时的不足。

场景一:朴素的串行执行(低效)

假设我们有三个异步函数 getA(), getB(), getC(a),其中 getC 依赖于 getA 的结果。如果按照最直观的方式编写代码,可能会是这样:

// 假设 getA 耗时 1s, getB 耗时 10s, getC 耗时 10s
const [a, b] = await Promise.all([getA(), getB()]) // a: 1s, b: 10s → 此步耗时 10s
const c = await getC(a) // c: 10s → 此步耗时 10s
// 总耗时约 20 秒

在这个例子中,getA()getB() 并行执行,耗时取决于较慢的 getB() (10s)。然后 getC(a) 串行执行,又耗时 10s。总耗时高达 20 秒。

Gemini_Generated_Image_ih0vmrih0vmrih0v (1).png

场景二:手动优化(复杂且脆弱)

为了优化上述情况,我们可能会尝试手动调整 Promise 的组合方式,让 getB()getC(a) 并行:

// 假设 getA 耗时 1s, getB 耗时 10s, getC 耗时 10s
const a = await getA() // a: 1s -> 此步耗时 1s
const [b, c] = await Promise.all([
  // b: 10s, c: 10s -> 此步耗时 10s
  getB(),
  getC(a),
])
// 总耗时约 11 秒

Gemini_Generated_Image_r4mjomr4mjomr4mj (1).png

这次优化后,总耗时降到了 11 秒,因为 getA() 完成后,getB()getC(a) 可以并行。看起来不错,但问题来了:如果任务的耗时发生变化呢?

场景三:手动优化的反噬(难以维护)

假设现在 getA() 耗时 10 秒,而 getC() 耗时 1 秒。我们再来看看手动优化后的代码表现:

// 假设 getA 耗时 10s, getB 耗时 10s, getC 耗时 1s
const a = await getA() // a: 10s -> 此步耗时 10s
const [b, c] = await Promise.all([
  // b: 10s, c: 1s -> 此步耗时 10s
  getB(),
  getC(a),
])
// 总耗时约 20 秒

此时,总耗时又回到了 20 秒!而最初的朴素写法(场景一)在同样的情况下,总耗时却是 11 秒:

// 朴素写法在相同耗时下的表现:
// 假设 getA 耗时 10s, getB 耗时 10s, getC 耗时 1s
const [a, b] = await Promise.all([getA(), getB()]) // a: 10s, b: 10s → 此步耗时 10s
const c = await getC(a) // c: 1s → 此步耗时 1s
// 总耗时约 11 秒

Gemini_Generated_Image_g2lawjg2lawjg2la (1).png

这说明手动优化不仅复杂,而且非常脆弱,难以适应任务耗时的动态变化。在真实世界中,任务的耗时往往受网络、服务器负载等多种因素影响,是不可预测的。为了正确优化,你甚至需要手动分析并声明复杂的依赖图,这会迅速导致代码难以管理和阅读:

const [[a, c], b] = await Promise.all([getA().then((a) => getC(a).then((c) => [a, c])), getB()])

better-all 的解决方案:自动依赖优化与类型推断

better-all库的核心思想是提供一个增强版的all函数,它允许开发者以声明式的方式定义异步任务及其依赖,而无需手动管理Promise链或复杂的嵌套。它会自动分析任务间的依赖关系,并尽可能地并行执行任务,从而实现最大化的并发优化。

让我们看看better-all如何优雅地解决上述问题:

import { all } from 'better-all'

const { a, b, c } = await all({
  async a() {
    return getA()
  }, // 假设耗时 1s
  async b() {
    return getB()
  }, // 假设耗时 10s
  async c() {
    return getC(await this.$.a)
  }, // 假设耗时 10s,依赖任务 a
})
// 总耗时约 11 秒 - 实现了最优并行化!

在这个例子中,better-all会自动识别出任务c依赖于任务a。它会立即启动所有任务,当任务c执行到await this.$.a时,如果任务a尚未完成,它会等待a完成;同时,任务b会独立并行执行。这样,better-all在保证依赖顺序的同时,最大限度地提升了并行度,无论任务耗时如何变化,都能保持最优或接近最优的执行效率

better-all的"魔法"在于其this.$对象,它让你能够以自然的方式访问其他任务的结果(作为Promise),从而清晰地表达依赖关系。库会确保所有任务尽早启动,并在遇到依赖时智能等待。

更多应用场景

带有依赖的任务:

const { user, profile, settings } = await all({
  async user() {
    return fetchUser(1)
  },
  async profile() {
    return fetchProfile((await this.$.user).id)
  },
  async settings() {
    return fetchSettings((await this.$.user).id)
  },
})

// user 任务首先运行,然后 profile 和 settings 任务并行运行

复杂的依赖图:

 const { a, b, c, d, e } = await all({
  async a() {
    return 1
  },
  async b() {
    return 2
  },
  async c() {
    return (await this.$.a) + 10
  },
  async d() {
    return (await this.$.b) + 20
  },
  async e() {
    return (await this.$.c) + (await this.$.d)
  },
})

// a 和 b 并行运行
// c 等待 a,d 等待 b (c 和 d 可以重叠并行)
// e 等待 c 和 d 都完成

// 结果: { a: 1, b: 2, c: 11, d: 22, e: 33 }
console.log({ a, b, c, d, e })

核心实现机制:Proxy与智能调度

better-all之所以能实现如此优雅的依赖管理,其内部机制主要依赖于JavaScript的Proxy对象和一套精巧的Promise延迟解决(Deferred Resolution)逻辑。

1. this.$的Proxy代理

better-all为每个任务函数提供了一个特殊的this.$上下文。这个$对象实际上是一个Proxy。当你在任务函数中通过await this.$.dependencyName访问某个依赖时,Proxy会拦截这个访问。

2. 智能等待机制

Proxy拦截到对this.$.dependencyName的访问时,它会检查dependencyName对应的任务是否已经完成。如果已完成,它会立即返回结果;如果尚未完成,它会创建一个新的Promise,并将当前任务的resolvereject函数注册到dependencyName任务的等待队列中。

3. 任务的并行启动与结果管理

better-all在开始时会立即启动所有定义的异步任务。每个任务的结果(无论是成功解决还是失败拒绝)都会被存储起来。当一个任务完成时,它会遍历所有等待它的依赖任务,并触发它们的resolvereject函数,从而解除这些依赖任务的阻塞。

4. 强大的类型推断

得益于TypeScript的强大能力,better-all在设计时充分利用了泛型和Awaited<R>等高级类型特性。这意味着,无论你定义了多么复杂的任务和依赖关系,this.$.dependencyName都能提供精确的类型信息,并且最终返回的结果对象也是完全类型安全的,极大地提升了开发体验和代码健壮性。

better-all 的优势总结

  • 自动最大化并行度:无需手动分析和优化依赖图,库会自动处理,确保任务在满足依赖的前提下尽可能并行执行。
  • 极佳的可读性:通过await this.$.dependency的语法,以最直观的方式表达任务依赖,代码逻辑清晰明了。
  • 全方位类型安全:借助TypeScript,从任务定义到结果获取,全程提供精确的类型推断,有效避免潜在的类型错误。
  • 轻量级:库本身依赖少,打包体积小,对项目性能影响微乎其微。
  • 错误处理:错误传播机制与Promise.all类似,一个任务失败会导致依赖它的任务也失败,并通过try...catch捕获。

适用场景与使用建议

✅ 适合使用的场景

better-all特别适用于以下场景:

  • 数据聚合:需要从多个API或数据库中获取数据,且部分数据获取存在前后依赖。
  • 复杂计算流:一系列计算步骤,其中某些步骤的结果是后续步骤的输入。
  • 构建流程:例如在构建系统中,某些模块的编译依赖于其他模块的输出。

⚠️ 不适合使用的场景

  • 任务完全独立:如果所有任务都是独立的,直接使用 Promise.all 更简单。
  • 动态依赖:如果依赖关系只能在运行时确定,better-all 的静态依赖声明方式可能不适用。
  • 需要精细控制并发数:如果需要限制同时执行的任务数量,可能需要配合其他库如 p-limit

💡 最佳实践

// ✅ 推荐:清晰的任务命名
const result = await all({
  async userData() {
    return fetchUser(userId)
  },
  async userProfile() {
    const user = await this.$.userData
    return fetchProfile(user.id)
  }
})

// ❌ 避免:过度复杂的依赖图(超过3层)
// 如果依赖层级太深,考虑拆分或重构

结语

better-all提供了一种更高级、更智能的异步任务编排方式,它将开发者从繁琐的依赖管理中解放出来,让我们能够更专注于业务逻辑本身。在追求高性能和高可维护性的现代前端开发中,better-all无疑是一个值得关注和尝试的优秀工具。它不仅提升了开发效率,也让我们的异步代码更加健壮和优雅。

记住:好的工具不是让简单的事情变复杂,而是让复杂的事情变简单。


💡 React Hooks 工具库推荐 > @reactuses/core - 100+ 个生产级 Hooks,覆盖状态、副作用、浏览器 API 等场景 > 📖 reactuse.com | 📦 npm i @reactuses/core