把 JavaScript 生态过去十年的状态管理演进拉一条线看,最清晰的脉络大概就是 Observable 和 Signals 之间的接替。2010 年代前半段,AngularJS 的双向绑定和各家自研的 MVVM 框架占据主流,RxJS 带来的 Observable 开始把“流”的概念带进前端工程;到了 2020 年后,Angular、Vue、Solid 和 Preact 又不约而同地围绕 Signals 重新设计自己的响应式核心。很多人会问:Signals 是不是就是一种简化版的 Observable?这个问题的答案,其实藏着两种编程模型在根本目标上的分歧。

我倾向于把这次变化看成一次“状态更新模型”的回归。Observable 擅长描述事件和数据在时间上的流动,而 Signals 更接近“一个普通变量,但变化时带着可达区域一起更新”。这两种能力在大型前端项目里从来都是并存的,只是过去十年我们总是试图用一种方案覆盖所有场景。
Observable 在 JS 生态里真正解决的问题
RX 最初的价值,是把事件、异步请求、定时器、数组迭代统一成同一种接口:一个可被订阅、可组合、可取消的数据流。在没有 Observable 的年代,前端写异步逻辑通常是回调嵌套,或者用 Promise 串起来,但 Promise 无法表达“用户输入的这个事件在未来可能反复发生”这样的模型。
举个例子,搜索框的联想建议是一个非常典型的流式场景:用户输入一个字符就触发一次请求,但需要防抖、需要丢弃过期响应、需要保证请求返回顺序。用最原始的方式手写这些逻辑非常容易出错,用 RxJS 可以写得很直观:
import { fromEvent, of, switchMap, debounceTime, catchError } from 'rxjs';
const input$ = fromEvent(searchInput, 'input').pipe(
debounceTime(300),
switchMap(event => loadSuggestions(event.target.value)),
catchError(err => of([]))
);
input$.subscribe(results => render(results));
这段代码背后有几个关键能力:惰性(不订阅就不会执行)、组合(操作符可以拼接)、取消(退订会切断整条链),以及对时间的抽象。在事件密集、并发复杂的场景里,Observable 的抽象层次非常合适。这也是它能在 Node.js 流、前端事件、IPC 通信、实时数据通道里落地生根的原因。
当 Observable 被用来管理应用状态,问题开始出现
真正的矛盾出现在把 Observable 当作全局状态载体的时候。很多团队会把每一个业务状态都变成一个 Subject,比如 currentUser、cartItems,然后通过 combineLatest 去派生页面数据。早期确实能跑,但随着业务线增加,事情变得难控制:一个状态被多个流引用,一个流又被多个 effect 订阅,数据流向变得非常间接。排查一个界面为什么多显示了一条记录,要从组件订阅一路反查 combineLatest 的参数,再追到某个 Subject 是在哪次事件里 next 进去的。这种排错体验和“直接赋值,然后界面自动更新”的直觉相差很远。
另一个问题是调用栈。Observable 在异步流中推送数据时,更新逻辑往往发生在订阅回调里,错误堆栈已经跨过了事件循环的多个环节。开发者想定位“是谁触发了这次状态改变”非常困难。相反,如果你只是给普通变量赋值,运行时能很容易给出当前执行位置。
这些不是 RxJS 的缺陷,而是它作为一种“事件流抽象”被错置到了“应用状态快照”的领域。应用状态本质上不是一串事件,而是一个不断被覆盖、需要随时读取的当前值。它需要的是“我读它时它永远是最新的”,而不是“我订阅它之后等回调通知我”。
Signals 为什么在 2020 年后重新登场
Signals 并不是一个新概念。比如 MobX 里的 observable,早在 2015 年就已经实现了依赖追踪;Vue 的响应式系统也一直基于类似的原理。之所以 2020 年后“Signals”这个词被重新叫响,是因为新一代框架重新确认了定位:框架核心应该以细粒度的依赖追踪为基础,而不是以整棵组件树的 Diff 为基础。
一个 Signal 通常就是一个持有值的单元。读取它的时候,如果正处于某个 effect 的上下文中,就会自动建立一个订阅关系;写入它的时候,Signal 会知道哪些 effect 依赖了这个值,然后只去触发那些 effect。开发者不需要手动声明依赖数组,也不需要关心订阅关系,因为依赖关系是在首次运行 effect 时动态收集的。
一个最小化的 Signals 代码大概是这样的:
import { signal, computed, effect } from '@angular/core';
const count = signal(0);
const doubled = computed(() => count() * 2);
effect(() => {
console.log(`double 现在是 ${doubled()}`);
});
count.set(2);
这里的读取看起来就是一次普通函数调用。effect 在首次执行时读取了 double,而 double 又读取了 count,于是运行时就记住了一条依赖链:count 变化时,需要重新运行这个 effect。整个过程是同步、确定、可准确断言的。
需要强调的是,Signals 解决的是同步状态的就地更新问题,它并不是为异步事件而生的。如果有人把网络请求的过程直接塞进 effect 里,那多半会在时序上踩坑。
关键差异:不是替代,而是分工
把 Observable 和 Signals 放在一起对比,最核心的差异不是“谁比谁快”,而是它们对时间和取值方式的假设完全不同。
| 对比维度 | Observable | Signals |
|---|---|---|
| 核心抽象 | 在时间上推送事件的数据流 | 持有当前值并追踪依赖的状态单元 |
| 读取方式 | 通过 subscribe 在回调中被动接收 | 调用函数后同步获取最新值 |
| 更新传播 | 操作符管道,订阅者收到通知 | 自动依赖追踪,精准通知相关 effect |
| 异步能力 | 天然支持异步、延迟、重试、调度 | 本身是同步模型,异步需要配合其他机制 |
| 典型场景 | 事件流、请求竞赛、消息队列、实时推送 | UI 状态、框架渲染、组件内共享状态 |
| 心智成本 | 操作符和调度器较多,曲线陡峭 | 贴近普通变量,但依赖循环和 effect 有代价 |
从这张表能看出来,二者更像不同“时间维度”上的工具。Observable 负责的是“在未来可能多次到达的数据”,Signals 负责的是“在当下可读可改的当前值”。现代框架往往会用 Signals 作为内部状态机制,同时保留 Observable 作为对外异步接口。
框架里 Signals 的实际形态
Angular 从 v16 开始引入 signals,目标是逐步摆脱 zone.js 的全局补丁模式。zone.js 在浏览器里代理了大量 API,每次异步操作后都会从头跑一遍变更检测,很多性能问题来自“不知道该更新谁,所以干脆检查所有组件”。signals 把依赖关系显式化之后,框架只需要通知真正读到了状态的组件。但 Angular 项目里通常已经有大量 rxjs 代码,所以官方也提供了 signal 到 observable 互转的工具,让两条路径同时运行。
Vue 的 ref、computed 和 effect 本质上就是 Signals,它早在 Composition API 里就完成了这套模型。Solid 则把细粒度更新推到极致,组件只运行一次,后续状态变化只更新具体 DOM 节点。Preact Signals 作为一个独立包,也可以接入 React/Vue 等外部渲染器,用 useEffect 和 useSyncExternalStore 绑定。
这里有一个值得聊的真实场景:一个 Angular 团队因为渲染性能问题开始尝试 signals,第一反应是把所有 Observable 成员变量改成 signal。实际改造开始后发现作用域比想象中更大,服务层的数据流、路由守卫、HTTP 拦截器都绑定在 rxjs 上,强行改成 signal 反而让异步逻辑变得别扭。最后他们保留服务层 Observable,只在组件展示层用 signal 做派生值,问题才理顺。这个例子可以说明,引入新模型之前,先明确旧模型在项目里的职责,比选择哪个 API 更加重要。
最容易踩的几个误区
第一类误区,是把 Signals 当作 Observable 的“性能优化版”。Signals 不是 Observables 的薄封装,它缺少操作符、调度器、完成语义和背压控制,强行替代只会把异步问题拉回回调地狱。
第二类误区,是认为使用 Signals 就一定更快。细粒度更新确实可以减少无意义渲染,但 effect 数量过多、依赖链过长,或者每次渲染都创建新的 signal,都可能带来额外内存和调度开销。性能提升的前提是原有方案确实做了大量“过度更新”,否则收益并不明显。
第三类误区,是把“响应式”和“异步”画等号。很多人看到 effect 就觉得它是变相的 subscribe,于是把网络请求、计数上报都写在 effect 里。实际上,signal 的 effect 主要是为了在状态更新后做同步副作用,长时间运行或非确定性操作应该独立管理生命周期。
第四类误区,是忽略 Signals 的生态差异。不同框架的 signal 实现细节并不通用,有的支持 computed 缓存,有的是惰性计算,有的 effect 在微任务里执行,有的同步执行。迁移方案必须基于真实框架行为,而不是“API 看起来差不多”。
什么场景选 Observable,什么场景选 Signals
如果面对的是一连串事件:按钮点击、键盘输入、WebSocket 消息、进度条更新,或者需要响应多个数据源之间的时序竞争,Observable 仍然是最完整的选择。它已经把取消、重试、合并、竞态处理这些高频需求内化成了标准操作符,自己实现这些逻辑很难达到同等可靠性。
如果面对的是“状态当前是什么”这类读取场景:用户信息、权限标识、表单数据、UI 开关,Signals 更直接。它让状态变化路径单一化,组件也更容易推理。你不需要为了获得一个值去订阅一个永不完成的流,也不需要担心忘记退订。
现代大型项目最常见的结构,是用 Signals 做内部状态和视图绑定,用 Observable 做外部数据输入和服务层通信。Angular 的 toObservable、RxJS 的 fromSignal 这类互转 API 就是在为这种共存铺路。
落地建议:先共存,再评估,不要重写
如果团队已经在项目里稳定使用了某一种模型,完全没必要为了跟随趋势而重写。更稳妥的路径是:
- 从组件内部的新功能开始使用 Signals,控制在一个页面或一个模块内熟悉行为。
- 在服务层、数据请求层保留 Observable,因为这些边界原本就以异步流形态存在。
- 把性能优化作为目标时,先用 DevTools 和框架分析工具确认“过度更新”具体发生在哪里,再决定是否把部分状态替换成 Signals。
- 团队内部约定 Signal 适合管理“当前值”,Observable 适合管理“事件流”,避免一半代码里到处混用。
- 如果要替换全局 store,优先替换读取频次高、更新路径短的部分,不要一次性拆掉已有的事件链路。
我在不同团队里见过两种极端:一种是守着旧方案拒绝了解 Signals,另一种是看到新概念就想把全部代码重写一遍。两种做法都会偏离问题本身。响应式编程在 JS 生态里演进了十年,真正沉淀下来的不是某个 API,而是“如何表达状态随时间的变化”这个问题的丰富答案。
Observable 和 Signals 之间的关系,不太像新老交替,更像是一次补全。前者擅长跨越时间的事件,后者擅长当下的状态快照。未来很长一段时间,它们会以互补的方式共存。能识别出场景属于哪一种,再选择合适的模型,比争论“谁取代谁”有价值得多。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/568/