Observable vs Signals:响应式江湖的两大流派深度对决

在探讨了 Observable 和 Signals 各自的理念与实现后,你可能会有些疑问:这两个家伙,都号称搞定“响应式”,它们到底有啥不一样?我该用哪个?

别急,这一篇,咱就来掰扯掰扯 Observable 和 Signals 这对“响应式双雄”,通过对比,帮你建立更直观的认知。

一、核心哲学:动态数组 vs 动态函数

要快速抓住两者的神髓,不妨来看一个有点“玄学”但颇为形象的比喻:

Observable ≈ 动态数组 (Array + 时间) Signals ≈ 动态函数 (Function + 动态参数)

这话怎么理解呢?

Observable:时间轴上的珍珠项链

想象一个数组 Array,它是一系列静态的值的集合。现在,给这个数组加上时间维度——这些值不是同时存在的,而是随着时间推移,一个接一个地“推送”给你。这就构成了 Observable 的核心意象:一个随时间发生的事件序列(Stream)

Observable 关注的是整个序列的处理。你像处理数组一样,可以对这个事件流进行 map(转换每个事件)、filter(过滤掉某些事件)、reduce(聚合整个流的结果)、take(只取前几个)、debounce(防抖动)等等操作。它的核心在于处理流经的数据,以及这些数据在时间维度上的模式和关系。你订阅一个 Observable,就像是在说:“嘿,这条项链上的每一颗珍珠(事件)来了,都告诉我一声,我好对它(们)做点什么。”

Signals:自动响应变化的计算单元

再来看函数 Function。一个纯函数,给定相同的输入,总是返回相同的输出。它描述了一种计算关系。现在,让这个函数的“输入参数”变成动态的、可变的,并且当这些输入参数(也就是依赖的状态)变化时,函数的“输出”(计算结果)也能自动、高效地更新。这就是 Signals 的核心意象:一个响应依赖变化的计算单元

Signals 关注的是状态以及状态之间的依赖关系,并确保当读取一个 Signal 时,总是能得到基于当前最新且一致的依赖状态计算出的值。它内部维护着一张“依赖图”,像蜘蛛网一样,当源头状态(Signal.State)变化时,它会精确地通知可能受影响的计算节点(Signal.Computed),但计算本身是惰性的。你读取一个 Signal,就像是在问:“喂,根据你现在所依赖的那些最新值,你当前的结果是啥?”

简单总结一下核心区别:

  • Observable它是对某一种数据的线性的过滤、处理、转化

  • Signals它是对某一种数据的线性的过滤、处理、转化

  • Observable: 它是对某一种数据的持续的线性的过滤、处理、转化

    处理异步事件流,关注序列时间流转换。它是**推(Push)**模型的延伸(事件源主动推送)。

  • Signals: 它是对多个数据进行交织

    管理响应式状态,关注依赖关系自动更新一致性。它是**拉(Pull)**模型的优化(读取时才计算),但也包含推送通知(Watcher)。

二、互相“扮演”:边界与不可替代性

虽然哲学不同,但在某些场景下,它们似乎可以互相模拟对方的功能。这种模拟尝试,恰恰能帮助我们看清各自的“舒适区”和“短板”。

场景 1:用 Signals 模拟 Observable (处理事件流)

Observable 的经典场景是处理 DOM 事件流,比如监听按钮点击。我们尝试用 Signals 来模拟:

// --- 使用 Observable ---
// import { Observable } from 'rxjs'; // 或原生 Observable (如果支持)
// import { fromEvent, map, filter } from 'rxjs/operators'; // RxJS operators

// const clicks$ = fromEvent(buttonElement, 'click').pipe(
//   map(event => ({ x: event.clientX, y: event.clientY })),
//   filter(coords => coords.x > 100)
// );
// clicks$.subscribe(coords => console.log('Observable Click (x > 100):', coords));

// --- 尝试用 Signals 模拟 ---
import { Signal } from "signal-polyfill";
import { effect } from "signal-utils/subtle/microtask-effect";

const latestClickEvent = new Signal.State<MouseEvent | null>(null);

// 1. 手动将事件源连接到 Signal State
buttonElement.addEventListener("click", (event) => {
  // Signals 通常关心“状态”,所以我们只记录“最新”的事件状态
  latestClickEvent.set(event);
});

// 2. 使用 effect 响应状态变化
effect(() => {
  const event = latestClickEvent.get(); // 读取 Signal,建立依赖
  if (event === null) return; // 初始状态或清除状态时忽略

  // 在 effect 内部处理事件
  console.log("Signals Effect triggered by click state change");
  const coords = { x: event.clientX, y: event.clientY };

  // 模拟 filter
  if (coords.x > 100) {
    console.log("Signals Handling Click (x > 100):", coords);
    // 在这里执行基于事件的逻辑...
  }
  // 注意:这个 effect 会在每次点击时触发(因为 event 对象总是新的)
  // 如果想模拟 Observable 的 filter 效果(只在满足条件时触发后续逻辑),
  // 可能需要更复杂的 Signals 组合或在 effect 内部判断。

  // 问题:如果需要在事件处理后“消费”掉它(避免重复处理),可能需要手动 set(null)?
  // latestClickEvent.set(null); // ? 这又引入了手动状态管理
});

推导与思考:

  • 可以模拟,但“内味儿”不对: Signals 确实可以通过 State + effect 来响应事件的发生。但这种模式更像是“状态变更驱动的副作用”,而不是“流处理”。
  • 丢失序列信息: Signal.State 通常只关心当前(或最新)的状态。它天然不适合保存和处理事件的历史序列。如果你想实现 bufferCount (缓冲 N 个事件) 或 pairwise (前后两个事件配对) 这类需要访问历史事件的操作,用 Signals 会非常别扭,需要手动维护额外的状态来存储历史。
  • 流转换能力缺失: Observable 强大的操作符(map, filter, debounceTime, throttleTime, switchMap 等)是其核心优势,专门用于处理和转换事件流。用 Signals 模拟这些操作通常需要编写更多的命令式逻辑或组合多个 Computed Signals,远不如 Observable 操作符简洁直观。
  • 核心差异凸显: 这再次印证了 Observable 为处理事件序列而生。它提供了丰富的工具来操纵、组合和响应随时间发生的离散事件流。而 Signals 则更关注状态快照及其依赖

场景 2:用 Observable 模拟 Signals (处理派生状态)

Signals 的核心优势在于管理派生状态,如 fullName = computed(() => firstName + lastName)。我们尝试用 Observable 来模拟:

// --- 使用 Signals ---
// import { Signal } from "signal-polyfill";
// const firstName = new Signal.State("Zhang");
// const lastName = new Signal.State("San");
// const fullName = new Signal.Computed(() => `${firstName.get()} ${lastName.get()}`);
// console.log(fullName.get()); // "Zhang San"
// lastName.set("Si");
// console.log(fullName.get()); // "Zhang Si" (自动、懒惰、缓存)

// --- 尝试用 Observable 模拟 ---
import {
  BehaviorSubject,
  combineLatest,
  map,
  distinctUntilChanged,
  tap,
} from "rxjs";

// 1. 用 BehaviorSubject (或类似) 模拟 State Signal (需要初始值,并能记住最新值)
const firstName$ = new BehaviorSubject("Zhang");
const lastName$ = new BehaviorSubject("San");

// 模拟 Computed Signal
const fullName$ = combineLatest([firstName$, lastName$]).pipe(
  // 2. combineLatest 合并依赖源的最新值
  tap(([f, l]) => console.log(`Observable: combining ${f} and ${l}`)), // 观察组合时机
  // 3. map 执行计算
  map(([firstName, lastName]) => {
    console.log("Observable: Computing fullName..."); // 观察计算时机
    return `${firstName} ${lastName}`;
  }),
  // 4. distinctUntilChanged 实现缓存/记忆化 (只在值变化时发出)
  distinctUntilChanged(),
);

// 模拟读取 (需要订阅来获取值)
console.log("Subscribing to fullName$...");
fullName$.subscribe((value) => console.log("Observable fullName:", value));
// BehaviorSubject 会立即发出初始值触发计算和订阅回调
// 输出: Observable: combining Zhang and San \n Observable: Computing fullName... \n Subscribing to fullName$... \n Observable fullName: Zhang San

console.log("\nUpdating lastName$...");
lastName$.next("Si"); // 发出新值
// 输出: Observable: combining Zhang and Si \n Observable: Computing fullName... \n Observable fullName: Zhang Si

console.log("\nUpdating firstName$...");
firstName$.next("Li"); // 发出新值
// 输出: Observable: combining Li and Si \n Observable: Computing fullName... \n Observable fullName: Li Si

console.log("\nUpdating lastName$ to Si again (no change)...");
lastName$.next("Si"); // 值未变
// 输出: Observable: combining Li and Si \n Observable: Computing fullName... (但 distinctUntilChanged 阻止了下游的 subscribe 回调)

推导与思考:

  • 可以模拟,但更“重”: Observable 通过 BehaviorSubject (或其他能存储最新值的 Subject)、combineLatestmapdistinctUntilChanged 的组合,可以模拟出 Computed Signal 的效果。
  • 手动依赖与组合: 你需要手动选择合适的组合操作符(如 combineLatest)来声明依赖关系,这不像 Signals 那样在计算函数中读取时自动完成。
  • 惰性与缓存需显式处理: Observable 流默认是“热”的(一旦有源发出就可能触发计算和推送),你需要 distinctUntilChanged 来模拟 Signals 的缓存/记忆化行为。实现 Signals 那种精细化的、只有在被读取时才计算的完全惰性可能需要更复杂的 Observable 组合(如使用 defer 或自定义操作符)。
  • 核心差异凸显: 这再次印证了 Signals 为管理状态依赖和派生计算而生。它的自动依赖追踪、内置的惰性求值和精细化缓存机制,使其在处理这类问题时更自然、更高效、更符合直觉。而 Observable 则需要更多的“手动挡”操作来达到类似效果。

结论:可以互扮,但气质不同,各有专长。 你可以用锤子拧螺丝,也可以用扳手敲钉子,但效果和效率显然不如用合适的工具。

三、背压之辩:推拉之间的流量控制

背压(Backpressure)是指在数据流系统中,当生产者产生数据的速率超过消费者处理数据的速率时,需要有一种机制来协调两者,防止数据丢失或资源耗尽(如内存溢出)。

Observable 与背压

  • RxJS 中的背压: 成熟的响应式库如 RxJS 提供了丰富的背压处理策略。操作符可以指定如何处理过载的数据,例如:
    • buffer: 缓存数据,等待消费者处理。
    • throttleTime / debounceTime: 在时间维度上减少事件频率。
    • sample: 定期采样最新值。
    • auditTime: 在静默期后发出最新值。
    • window: 将数据流分片成窗口。
    • …还有更高级的基于消费者请求量的协议。
  • Observable 提案的现状: 目前(截至本文写作时),Observable 提案本身并没有明确包含背压处理机制或相关操作符。 这是一个重要的兼容性考量点,意味着原生 Observable 可能无法直接提供(准确来说应该是不愿意提供) RxJS 中强大的背压控制能力。
  • Implications: 如果原生 Observable 不处理背压,当遇到高速事件源(比如快速的鼠标移动、高频的 WebSocket 消息)而消费者处理较慢时,可能会导致事件在内部(或由操作符)无限制地累积,增加内存压力,甚至丢失事件(取决于具体实现和操作符行为)。开发者可能需要自己通过组合 takeUntilfilter 等基础操作符,或者依赖尚未标准化的高级操作符(如果未来通过垫片实现),或在 subscribenext 回调中实现节流/缓冲逻辑来手动管理。这无疑增加了复杂性,也是相比 RxJS 的一个潜在弱点。

Signals 与背压

  • 天然规避传统背压: Signals 的核心是状态快照拉取式(Pull-based)惰性计算。当一个 State Signal 被高频 set 时,如果没有 ComputedWatcher 在中间读取它,这些中间状态实际上就被隐式地丢弃了。系统只关心在下一次被读取时,提供基于 最新 依赖状态的 最终 结果。
  • 单一策略:丢弃中间值: 这种机制可以看作是一种天然的、单一的背压策略——丢弃(Drop)/只取最新(Latest)。它不会累积历史值,因此不会有传统流处理中的内存爆炸问题。
  • 优势与局限: 这种简单性是 Signals 高效和易于理解的原因之一。但它也意味着 Signals 本身不提供其他背压策略的选择。如果你需要缓冲所有事件、或保证每个事件都至少被处理一次,Signals 的核心模型并不直接支持,你可能需要结合其他机制(如队列、或在 effect 中实现缓冲逻辑)来完成。

总结:

  • Observable(特指提案)在背压处理上目前留有空白,这是相比成熟库(如 RxJS)的显著差距,需要关注未来发展或依赖用户端策略。
  • Signals 通过其“状态快照”和“惰性拉取”模型,天然地以“丢弃中间值”的方式规避了传统背压问题,简单高效,但也失去了策略选择的灵活性。

两者在处理生产者-消费者速率不匹配问题上,体现了其核心哲学的不同影响。

四、殊途同归?总结与思考

Observable 和 Signals,这对响应式编程领域的“双子星”,虽然都旨在解决状态与变化的难题,但它们的出发点、核心机制和最佳应用场景存在显著差异。

  1. 指令集类比:复杂(Observable) vs 精简(Signals)

    • Observable: 更像是复杂指令集(CISC)。它提供了强大的、专门化的流处理操作符(尤其是在 RxJS 中),允许你对事件序列进行复杂的时间维度操作、转换和组合。但目前的提案相较于 RxJS 是“精简”的,许多高级操作符(如 debounce, throttle)并未包含。这些缺失的操作符能否通过现有基础操作符组合“垫片”实现?理论上部分可以,但可能会很复杂,且性能和行为可能与原生实现有差异。原生 Observable 的潜力很大程度上取决于未来操作符集的丰富程度。
    • Signals: 更像是精简指令集(RISC)。它提供了极简的核心原语(State, Computed, Watcher),专注于高效、自动化的状态依赖管理和计算缓存。它简单、正交、易于理解和组合。
  2. 生态与心智模型

    • Observable: 天然契合异步流的心智模型。当你需要处理一系列随时间发生的、离散的、可能需要复杂时间逻辑(节流、防抖、窗口、合并等)的事件时,Observable 是非常自然的抽象。但它的学习曲线(尤其是 RxJS 的众多操作符)相对陡峭。
    • Signals: 更倾向于函数式编程声明式状态管理。它鼓励你将 UI 或系统状态看作是基础状态经过一系列纯函数计算派生出来的结果。这种模型易于推理,与许多现代 UI 框架(尤其受函数式影响的)的理念非常契合,可能更容易围绕其构建社区和工具库。
  3. 应对复杂异步流

    • Observable: 处理复杂的异步序列(如拖拽操作、自动完成建议、多阶段动画)是其强项。操作符提供了强大的“武器库”来编排这些流程。
    • Signals: 处理简单副作用(如 effect(() => console.log(user.get())))非常优雅。但当涉及到需要管理异步过程本身的状态(如请求的加载/错误状态、节流/防抖的定时器状态)时,Signals 需要将这些过程状态也建模为 Signals,并通过 Computedeffect 来编排它们之间的依赖和转换。这可能会导致状态管理的负担增加,代码显得比用 Observable 操作符更繁琐。虽然社区可以通过库来封装这些模式(比如创建一个 createThrottledSignal),但 Signals 的核心机制并非为直接处理复杂异步时间逻辑而优化。比方说上文提到的 debounce需求,使用 Signal.State<Event|null> 的“消费”模式可以巧妙地处理某些场景(如按钮禁用),自洽且避免直接实现 debounce 的复杂性,但这体现了 Signals 处理问题的不同角度——状态驱动而非流处理
  4. 互补而非替代 看到这里,你也应该有了自己的答案,Observable 和 Signals 它们可以协同工作。例如,一个 Observable 可以作为数据源,将其最新的值 set 到一个 Signal.State 中,然后利用 Signals 的派生计算和自动更新能力。反之,一个 Signal 的变化也可以触发一个 Observable 的启动或发出值。

最终结论:

Observable 和 Signals 是解决响应式问题的两种不同路径,各有侧重,各有优劣。

  • Observable 是处理异步事件流的大师,强于序列处理、时间控制和复杂的流转换,但目前提案内容有限,且背压处理不明。
  • Signals 是精细化状态管理的专家,强于依赖追踪、惰性计算、自动缓存和确保一致性,模型简单直观,但处理复杂异步时序逻辑相对笨拙。

理解它们的核心哲学差异——“动态数组” vs “动态函数”——是做出正确技术选型的关键。未来,我们或许会看到一个两者共存、甚至通过标准接口来进行互相转换!比如说:

const state1 = new Signal.State(0);
const state2 = state1
  .toObservable()
  .filter((v) => v > 10)
  .toState();

// 大胆点,进一步简化:
const state1 = new Signal.ObservableState(0);
const state2 = state1.filter((v) => v > 10);

到时候也就没什么好纠结了,喜欢哪个就用哪个 😂😂