HOWTO · Angular

Angular 中的 Observable vs Promise

在本文中,我们将解释什么是 Angular 中的 Observable 和 Promise。此外,我们将解释 observable 和 Promise 之间的根本区别。

本页内容

如果任务只需要一个最终结果,选择 Promise;如果任务自然地表现为随时间产生的值流,则选择 Observable。在 Angular 中,这两种抽象并不能互换:应根据代码所需的行为来选择。

Angular 中的 Promise

Promise 是一个 JavaScript 对象,表示一个最终结果:要么以某个值兑现,要么因某个原因被拒绝。它会从待定状态转为已兑现或已拒绝状态;其 then、catch 和 finally 方法可用于组合任务并处理结果。一个 Promise 只会敲定一次;兑现后的 Promise 不会再发出其他值。

创建 Promise 时,其执行器就会开始运行。Promise API 本身没有内置的取消或重试操作,但应用代码可以围绕 Promise 实现支持取消的任务或重试逻辑。随着工作流程变得复杂,长链式调用也可能更难理解。

Angular 中的 Observable

Observable 表示一个值流:它可以发出零个或多个值,之后完成或报错;也可以一直保持开放状态,不发送任何一种终止通知。在 Angular 中,HttpClient 返回 RxJS Observable。Observable 通常在订阅时开始执行,但具体时机取决于它的创建方式。RxJS 运算符可以转换和组合数据流;取消订阅会停止接收通知,并且当数据源支持取消时,可能也会取消底层任务。

Observable 并不会在所有情况下自动提供取消、重试或更好的性能;这些行为取决于数据源及所用的运算符。RxJS 提供了实用工具,但不熟悉数据流的开发者需要投入时间学习。

Promise 与 Observable 在 Angular 中的主要区别

对比项 Promise Observable
值 一个兑现值或一个拒绝原因 零个或多个 next 值;数据流可能完成或报错,也可能保持开放而不发送任何一种通知
开始时机 创建时执行执行器 通常在订阅时开始;具体时机由创建方式决定
错误 使用 catch 或拒绝处理函数处理 在订阅中处理,或使用 catchError 等运算符处理
取消 没有内置的取消方法 取消订阅会停止接收通知,并可取消受支持的任务
适用场景 单个结果,例如简单计算 事件、不断变化的值,或适合用 RxJS 组合处理的任务
重试 没有内置重试;需要自己再次调用生成函数 retry 等运算符会在失败后重新订阅数据源
共享 所有处理函数看到的都是同一个已敲定的结果 冷 Observable 会为每个订阅者重新运行其数据源;如果一次运行需要服务多个订阅者,应显式共享热数据源

对于 Angular HTTP 请求,如果需要 RxJS 组合能力或取消工作流,可以使用 HttpClient 返回的 Observable。响应内容可以是数组,但数组包含多个项目并不意味着必须使用 Observable:一次请求仍然只产生一个响应值。Promise 也可以表示该响应。应选择与任务需求及周边 API 相匹配的抽象。

取消、懒执行与重试详解

取消最能体现两者在实际中的差别。在边输入边搜索的输入框中,每次新的按键都应放弃针对上一个词条的请求。使用 HttpClient 的 Observable 时,通过 switchMap 切换到新请求会取消对旧请求的订阅,HttpClient 随即取消那个进行中的请求。Promise 没有等效的切换机制:一旦开始,底层任务会继续运行,除非你调用的 API 提供了自己的取消句柄,例如 fetch 的 AbortController——它独立于 Promise 本身。

执行时机也不同。Promise 在创建时就开始执行其工作,无论是否有代码为它附加处理函数,因此创建 Promise 本身已经是一个副作用。相比之下,冷 Observable 是懒执行的:创建它只是描述了这项工作,每次调用 subscribe 都会重新运行数据源,因此两个订阅者可能触发两次独立的执行。期望只运行一次并共享结果的代码必须显式说明这一点,例如使用共享运算符,或者保存一个 Promise。

重试与组合也遵循同样的规律。被拒绝的 Promise 已经结束;要恢复,只能在自己的循环中再次调用生成函数。而 Observable 可以用 retry 运算符重新订阅其数据源;那些围绕 Promise 需要手写计时器和标志的时序模式,例如用 debounceTime 等待按键间歇、用 distinctUntilChanged 忽略未变化的输入,在 RxJS 中都只是一个运算符。

实践中如何选择

当结果确实是单次且用完即弃时,选择 Promise:一次性的计算、本身就返回 Promise 的第三方 SDK,或者用 async 和 await 读起来最顺、且永远不需要取消的简短线性流程。当数据源本质上是流时,选择 Observable,例如需要组合或取消的 HttpClient 响应、路由参数、表单值变化、DOM 事件或 WebSocket;当迟到的订阅者、重复的值或清理工作很重要时也是如此。拿不准时,保留生成 API 本来返回的类型,而不是在每个边界上都做转换。

在 Promise 与 Observable 之间转换

两种模型可以互通,但转换并非没有代价。RxJS 的 from 把 Promise 包装成一个 Observable,在其敲定后发出该值一次;而 firstValueFrom 和 lastValueFrom 则把 Observable 转回 Promise,供 async 和 await 代码使用。用这种方式转换 HttpClient 的 Observable 会保留那一个响应,但会放弃取消订阅所提供的取消能力,并在该点终结后续的运算符组合。应在明确的边界处转换,例如服务的边缘,而不是在可复用的数据流逻辑内部转换。

常见错误

  • 把单个 HTTP 响应中的数组当作多次发出。请求仍然只发出一次;只有真正重复的数据源才会重复发出。
  • 把请求转换成 Promise 后,却期望离开页面时它会被取消。没有可以取消的订阅,就没有任何东西通知数据源停下来。
  • 在长生命周期的组件中订阅时不制定清理策略,例如不使用 async 管道或 takeUntilDestroyed,导致视图消失后过期的订阅仍在接收值。
  • 期望已敲定的 Promise 之后会接收到更新的值。Promise 会记住它唯一的结果;重新读取当前状态需要新的调用或数据流。

Angular 中 Promise 与 Observable 的示例

下面这个独立的 JavaScript 示例使用一个简易的教学用 Observable 来展示订阅和多次发出值的行为。它并非 Angular 使用的 RxJS 实现。订阅时,Observable 会发送 queued 和 saved;Promise 的回调则会在之后的微任务中运行。代码块中的 queued 和 saved 是英文标签,示例保持其原样。

运行这个独立示例:

class Observable {
  constructor(producer) {
    this.producer = producer;
  }

  subscribe(next) {
    const teardown = this.producer(next);
    return { unsubscribe: teardown };
  }
}

const promise = Promise.resolve("saved");
const updates = new Observable((next) => {
  next("queued");
  next("saved");
  return () => {};
});

console.log("before subscription");
updates.subscribe((value) => console.log(`Observable: ${value}`));
promise.then((value) => console.log(`Promise: ${value}`));
console.log("after scheduling");
before subscription
Observable: queued
Observable: saved
after scheduling
Promise: saved

这个教学用 Observable 会在订阅时调用其数据源并发送两个值;Promise 只会敲定一次,其回调会在之后的微任务中运行。在应用代码中,应使用 RxJS 的 Observable 及其订阅 API。只需要一个结果时选择 Promise;当数据流组合、重复发出值或订阅管理更符合任务需求时,选择 Observable。

示例:急切的 Promise、懒执行且可取消的 Observable

下面这个第二个独立示例使用同一个教学用 Observable,展示对比表中执行时机与取消这两行。Promise 的执行器在任何人附加处理函数之前就已运行,而 Observable 的数据源会等到 subscribe 才启动,并在订阅者在第二次 tick 后取消订阅时运行其清理函数。Promise 的回调同样作为微任务运行,早于第一个计时器 tick。代码块保持原样,其中显示的标签仍为英文。

class Observable {
  constructor(producer) {
    this.producer = producer;
  }

  subscribe(next) {
    const teardown = this.producer(next);
    return { unsubscribe: teardown };
  }
}

const promise = new Promise((resolve) => {
  console.log("Promise executor: runs at construction");
  resolve("done");
});

const ticks = new Observable((next) => {
  console.log("Observable producer: runs at subscription");
  let count = 0;
  const id = setInterval(() => next(++count), 10);
  return () => {
    clearInterval(id);
    console.log("teardown: interval cleared");
  };
});

console.log("before subscription");
const sub = ticks.subscribe((value) => {
  console.log(`tick ${value}`);
  if (value === 2) sub.unsubscribe();
});
promise.then((value) => console.log(`Promise: ${value}`));
Promise executor: runs at construction
before subscription
Observable producer: runs at subscription
Promise: done
tick 1
tick 2
teardown: interval cleared

运行结果按顺序证实了这三种行为:执行器在创建时触发,数据源在订阅前保持空闲,取消订阅运行了清除 interval 的清理函数,因此不会再有后续 tick 到达。