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,就像是在问:“喂,根据你现在所依赖的那些最新值,你当前的结果是啥?”

简单总结一下核心区别:

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

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

场景 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); // ? 这又引入了手动状态管理
});

推导与思考:

场景 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 回调)

推导与思考:

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

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

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

Observable 与背压

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 是解决响应式问题的两种不同路径,各有侧重,各有优劣。

理解它们的核心哲学差异——“动态数组” 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);

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