返回首页

RxJS 深入浅出:从 Promise 用户到响应式编程,你需要补的全部认知

一篇足够长的 RxJS 入门到进阶 —— Observable 是什么、为什么需要操作符、Subject 家族怎么用、热流 vs 冷流、内存泄漏怎么防,以及它跟 Promise / async-await 到底差在哪里。

52 约 13 分钟 · 15668 字 前端

写 JavaScript 这几年,对异步处理的认知大概经历过四个阶段:

  1. 回调地狱:callback(err, data) 嵌套五层,光是缩进就跟金字塔似的
  2. Promise:.then().then().catch(),终于能链式了,但仍然只能管"一次性"的值
  3. async / await:同步写法写异步,90% 场景够用,代码可读性飞升
  4. RxJS:发现前面三种都只能处理"一个值",而真实世界里大部分异步是一个不断产出值的流

第四阶段不是必经的,但一旦你需要处理"事件序列"、"复杂时序"、"取消 / 重试 / 节流防抖 / 组合多个数据源"这类需求,RxJS 会让你眼睛一亮。这篇文章把我自己学 RxJS 时踩过的坑、走过的弯路全部拉直,给一个不绕弯的认知地图。

一句话定义

RxJS 把"事件、Promise、定时器、WebSocket 消息、DOM 事件"统一抽象成一种叫 Observable 的数据流,然后给你一百多个操作符去切片、组合、变形这个流。

这一句话里有三个关键词,稍微展开一下:

  • "统一抽象":意味着你可以用同一套 API 处理所有异步源,不需要为 Promise 写一套、为 EventEmitter 写一套、为 setInterval 写一套
  • "数据流":不是一个值,而是一连串值,有起点、可能有终点(也可能没有,比如鼠标移动事件)
  • "操作符":对流进行变换的工具函数,像数组的 map / filter 那样,但作用对象是时间维度上的数据序列

它跟 Promise 到底差在哪?

这是新手最大的疑问。直接上对比表:

维度 Promise Observable
产出值数量 只能产出 1 个值 可以产出 0、1、多个、甚至无限个值
执行时机 一创建就立即执行 订阅时才开始执行(冷流)
可取消 不可取消(只能假装忽略结果) 可以通过 unsubscribe() 真的取消
操作符 .then 一种 100+ 个,组合无限可能
同步/异步 一定是异步 可同步可异步
重试 自己写 try/catch + 递归 .pipe(retry(3))

注意第三条"可取消" —— 这是个被严重低估的特性。

举个例子:用户在搜索框输入"vue",你发出 /api/search?q=vue 的请求;还没回来用户又输入了"vuejs",你又发出 /api/search?q=vuejs。如果第一个请求晚回来,它会覆盖掉第二个请求的结果,搜索框显示的就是错的。

用 Promise 你只能加个版本号或者时间戳判断 "这是不是最新的请求";用 RxJS 一个 switchMap 就直接把上一个请求的订阅取消掉了:

import { fromEvent } from 'rxjs'
import { debounceTime, map, switchMap } from 'rxjs/operators'
import { ajax } from 'rxjs/ajax'

fromEvent(input, 'input').pipe(
  debounceTime(300),
  map((e: any) => e.target.value),
  switchMap((q) => ajax.getJSON(`/api/search?q=${q}`)),
).subscribe((res) => render(res))

整个搜索防抖 + 自动取消旧请求 + 拿到结果渲染 —— 6 行代码搞定。同样的逻辑用 Promise + 手动 AbortController 至少要写 30 行,而且容易写错

四个核心概念

把这四个概念搞清楚,RxJS 就过了 60% 难关。

1. Observable(可观察对象)

它是数据流本身。一个"未来会陆陆续续产出 N 个值的盒子"。

import { Observable } from 'rxjs'

const obs$ = new Observable<number>((subscriber) => {
  subscriber.next(1)        // 产出值 1
  subscriber.next(2)        // 产出值 2
  setTimeout(() => {
    subscriber.next(3)      // 1 秒后产出值 3
    subscriber.complete()   // 流结束
  }, 1000)
})

约定:Observable 变量名末尾加 $,例如 posts$user$,跟普通变量做区分。

注意一个关键事实:这个 obs$subscribe 之前不会跑。

console.log('before')   // 立即打印
obs$.subscribe((v) => console.log(v))  // 这里才开始 next(1), next(2)...
console.log('after')    // 这条会先于 next(3) 打印

这就是"冷流(Cold Observable)"的概念 —— 每次订阅都从头开始执行一次。后面讲 Subject 时还会聊到"热流"。

2. Observer(观察者)

接收流里值的对象。本质就是一个有 next / error / complete 三个方法的对象:

obs$.subscribe({
  next: (v) => console.log('值:', v),
  error: (e) => console.error('出错:', e),
  complete: () => console.log('结束'),
})

简写形式直接传一个函数,默认就是 next:

obs$.subscribe((v) => console.log(v))

3. Subscription(订阅)

subscribe() 返回一个 Subscription 对象,它的核心作用是取消订阅:

const sub = obs$.subscribe((v) => console.log(v))

// 5 秒后取消订阅,后面 next 出来的值都不会再触发回调
setTimeout(() => sub.unsubscribe(), 5000)

这是 RxJS 内存管理最关键的地方,后面单独讲。

4. Operator(操作符)

对流进行变换的纯函数,通过 pipe 串起来

import { of } from 'rxjs'
import { map, filter } from 'rxjs/operators'

of(1, 2, 3, 4, 5).pipe(
  filter((n) => n % 2 === 0),  // 只保留偶数
  map((n) => n * 10),           // 每个乘以 10
).subscribe((v) => console.log(v))  // 输出 20, 40

跟数组的 .filter().map() 长得几乎一样,但操作对象是时间维度上的值序列,不是空间上的数组。这是 RxJS 最优雅的地方。

创建 Observable 的几种方式

实际项目里你很少手写 new Observable(...),大部分时候是用创建操作符把已有的东西转成 Observable:

import { of, from, interval, timer, fromEvent, EMPTY, throwError } from 'rxjs'

of(1, 2, 3)                              // 同步发出 1, 2, 3 立即 complete
from([1, 2, 3])                          // 跟 of 类似,可以接数组/Promise/Iterable
from(fetch('/api/users'))                // Promise → Observable
interval(1000)                           // 每 1 秒发出 0, 1, 2, 3...(无限流)
timer(2000, 500)                         // 2 秒后开始,每 500ms 发一个
fromEvent(button, 'click')               // DOM 事件流
EMPTY                                     // 立即 complete 的空流
throwError(() => new Error('boom'))      // 立即 error

最常用的几个:of / from / fromEvent / interval / timer

from 还能把 Promise 转成 Observable,这是跟 fetch / axios 集成时的常用桥梁:

from(fetch('/api/posts').then((r) => r.json()))
  .pipe(
    catchError((err) => of({ error: err.message })),
  )
  .subscribe((data) => console.log(data))

操作符:把流玩出花的核心

100 多个操作符,但90% 时候你只用其中 20 个。按用途分组讲。

转换类:把流里的值变成另一种

import { map, scan, pluck } from 'rxjs/operators'

// map:一对一变换
of(1, 2, 3).pipe(map((n) => n * 2))  // 2, 4, 6

// scan:类似数组的 reduce,但每一步都吐出当前累计值(做累加/状态管理用)
of(1, 2, 3, 4).pipe(scan((acc, cur) => acc + cur, 0))  // 1, 3, 6, 10

// pluck(deprecated, 用 map 代替):从对象里取字段
of({ user: { name: 'Tom' } }).pipe(map((x) => x.user.name))  // 'Tom'

scan 是个被低估的操作符,本质上可以拿它实现一个轻量的 Redux:

const action$ = new Subject<Action>()
const state$ = action$.pipe(
  scan((state, action) => reducer(state, action), initialState),
)

过滤类:扔掉一些值

import { filter, take, skip, debounceTime, throttleTime, distinctUntilChanged } from 'rxjs/operators'

filter((n) => n > 10)                  // 只留大于 10 的
take(3)                                 // 只取前 3 个就 complete
skip(2)                                 // 跳过前 2 个
debounceTime(300)                       // 防抖:停顿 300ms 才发(最新的)
throttleTime(300)                       // 节流:每 300ms 最多发 1 个(第一个)
distinctUntilChanged()                  // 跟上一个相同就不发(去连续重复)

debounceTimethrottleTime 的差别很多人搞混:

  • debounce(防抖):用户输入停下来 300ms 才触发 → 适合搜索框
  • throttle(节流):每 300ms 最多触发 1 次 → 适合滚动事件、按钮防连点
fromEvent(window, 'scroll').pipe(
  throttleTime(100),  // 每 100ms 最多处理 1 次
).subscribe(() => updateProgressBar())

高阶映射:这是 RxJS 真正的精华

switchMap / mergeMap / concatMap / exhaustMap 这四兄弟,面试必考、实战常用、新手最迷糊

它们都是把"流里的每个值"变成"一个新的 Observable",但对老订阅的处理方式不一样:

操作符 来新值时,旧的内层 Observable 怎么处理 典型场景
switchMap 立即取消旧的,切换到新的 搜索建议、路由切换取消请求
mergeMap 旧的继续跑,新的一起并行 文件批量上传
concatMap 等旧的完成后再开始新的(排队) 顺序提交、串行任务
exhaustMap 旧的没结束就丢弃新的 防止重复提交表单

举个并排对比:

import { fromEvent, interval } from 'rxjs'
import { switchMap, mergeMap, concatMap, exhaustMap, take } from 'rxjs/operators'

const click$ = fromEvent(button, 'click')

// switchMap:每次点击都取消上次的,只跑最新一次
click$.pipe(switchMap(() => interval(1000).pipe(take(3))))

// mergeMap:每次点击都启动一个新的,所有都并行跑
click$.pipe(mergeMap(() => interval(1000).pipe(take(3))))

// concatMap:每次点击都排队,前一个跑完才轮到下一个
click$.pipe(concatMap(() => interval(1000).pipe(take(3))))

// exhaustMap:正在跑的时候点击没反应,跑完才接受下一次
click$.pipe(exhaustMap(() => interval(1000).pipe(take(3))))

记忆口诀:Switch 取消旧,Merge 全并行,Concat 排队等,Exhaust 忽略新

组合类:合并多个流

实际业务里,经常需要"把多个数据源合到一起":

import { combineLatest, merge, zip, forkJoin, race, withLatestFrom } from 'rxjs'

// combineLatest:任意一个流出新值,合并所有流的"最新值"发出
combineLatest([user$, settings$, theme$])
  .subscribe(([user, settings, theme]) => render(user, settings, theme))

// merge:把多个流的值平铺成一个流,谁来值就发谁
merge(click$, keydown$).subscribe((evt) => handle(evt))

// zip:严格按位置配对,每个流出一个值才合并一次(像拉链)
zip(name$, age$).subscribe(([name, age]) => console.log(name, age))

// forkJoin:像 Promise.all,等所有流都 complete,取每个的最后一个值
forkJoin([fetchUser(), fetchPosts(), fetchSettings()])
  .subscribe(([user, posts, settings]) => initialize(user, posts, settings))

// withLatestFrom:左边流触发时,带上右边流的最新值
click$.pipe(withLatestFrom(user$))
  .subscribe(([clickEvt, currentUser]) => log(clickEvt, currentUser))

最常用的两个:combineLatest 做响应式表单状态合成、forkJoin 做并发请求(替代 Promise.all)。

错误处理类

import { catchError, retry, retryWhen, throwError } from 'rxjs/operators'
import { of, timer } from 'rxjs'

ajax.getJSON('/api/data').pipe(
  retry(3),                                        // 失败重试 3 次
  catchError((err) => of({ error: err.message })), // 兜底返回默认值
)

// 指数退避重试:1s, 2s, 4s
ajax.getJSON('/api/data').pipe(
  retryWhen((errors) =>
    errors.pipe(
      scan((retries, _) => {
        if (retries >= 3) throw new Error('重试 3 次仍失败')
        return retries + 1
      }, 0),
      delayWhen((retries) => timer(2 ** retries * 1000)),
    ),
  ),
)

Subject 家族:可以主动 next 的 Observable

到目前为止,Observable 都是"被动产出值"的 —— 我订阅,它推给我。但有时候我们想自己控制什么时候发值,这就是 Subject。

import { Subject } from 'rxjs'

const subject$ = new Subject<number>()

subject$.subscribe((v) => console.log('订阅者 A:', v))

subject$.next(1)  // 输出:订阅者 A: 1
subject$.next(2)  // 输出:订阅者 A: 2

subject$.subscribe((v) => console.log('订阅者 B:', v))

subject$.next(3)  // 输出:订阅者 A: 3 和 订阅者 B: 3
                  // ⚠️ 订阅者 B 拿不到之前的 1 和 2!

Subject 既是 Observable,又是 Observer(可以 .next() 也可以 .subscribe())。它的几个变体:

类型 行为 用途
Subject 只能收到订阅后的值 事件总线
BehaviorSubject 必须有初始值,新订阅者立即收到当前值 全局状态(类似 Redux store)
ReplaySubject(n) 缓存最近 n 个值,新订阅者会重放 历史回放 / 撤销
AsyncSubject 只在 complete 时发出最后一个值 类似 Promise(用得少)

最常用的是 BehaviorSubject,做状态管理简直完美:

const currentUser$ = new BehaviorSubject<User | null>(null)

// 任何地方
currentUser$.subscribe((user) => console.log('当前用户:', user))

// 登录成功后
currentUser$.next({ id: 1, name: 'Tom' })

// 任何时候想拿当前值
const user = currentUser$.value

冷流 vs 热流:订阅顺序会改变结果吗?

这是 RxJS 最容易翻车的概念之一。

冷流(Cold):每次订阅都从头执行一遍。HTTP 请求 / interval / of / from,这些默认都是冷流

const cold$ = ajax.getJSON('/api/posts')

cold$.subscribe(...)  // 发起第 1 次请求
cold$.subscribe(...)  // 发起第 2 次请求(共 2 次!)

热流(Hot):不管有没有订阅都在跑,订阅者只能收到订阅后产生的值。Subject / fromEvent 是热流。

const click$ = fromEvent(button, 'click')  // 一创建就在监听,不订阅也在跑

把冷流变热的工具叫 share() / shareReplay(n):

const shared$ = ajax.getJSON('/api/posts').pipe(shareReplay(1))

shared$.subscribe(...)  // 发起请求,缓存结果
shared$.subscribe(...)  // 直接拿缓存,不再请求

shareReplay(1)缓存最后一个值并共享给所有订阅者,做"全局只请求一次的数据"特别合适,例如用户信息、配置项。

真实场景:几个绕不开的实战 Pattern

1. 自动补全 / 搜索建议

fromEvent(searchInput, 'input').pipe(
  map((e: any) => e.target.value.trim()),
  debounceTime(300),
  distinctUntilChanged(),        // 输入没变就不发
  filter((q) => q.length >= 2),  // 至少 2 个字才搜
  switchMap((q) =>
    ajax.getJSON(`/api/search?q=${q}`).pipe(
      catchError(() => of({ results: [] })),
    ),
  ),
).subscribe((res) => renderSuggestions(res))

debounceTime + distinctUntilChanged + switchMap 这三件套是搜索建议的黄金组合,记下来。

2. 轮询接口

import { timer } from 'rxjs'
import { switchMap, takeWhile } from 'rxjs/operators'

timer(0, 3000).pipe(                          // 立即开始,然后每 3 秒
  switchMap(() => ajax.getJSON('/api/job-status')),
  takeWhile((res: any) => res.status !== 'done', true),  // done 后停止
).subscribe((res) => updateProgress(res))

3. 文件上传进度 + 取消

const upload$ = new Observable<number>((subscriber) => {
  const xhr = new XMLHttpRequest()
  xhr.upload.onprogress = (e) => subscriber.next((e.loaded / e.total) * 100)
  xhr.onload = () => subscriber.complete()
  xhr.onerror = () => subscriber.error('上传失败')
  xhr.open('POST', '/api/upload')
  xhr.send(file)
  return () => xhr.abort()  // 取消订阅时自动 abort!
})

const sub = upload$.subscribe({
  next: (pct) => console.log(`${pct.toFixed(1)}%`),
  complete: () => console.log('完成'),
})

cancelButton.addEventListener('click', () => sub.unsubscribe())  // 触发 xhr.abort()

注意 Observable 构造函数返回的清理函数 return () => xhr.abort() —— 这是 Observable 的"析构函数",取消订阅时自动调用。这一点 Promise 是做不到的。

4. 并发请求 + 节流

要批量请求 1000 个 URL,但同时最多 5 个并发:

from(urls).pipe(
  mergeMap((url) => ajax.getJSON(url), 5),  // 并发上限 5
).subscribe((res) => collect(res))

mergeMap 的第二个参数是并发数,这一行解决的问题用 Promise 至少要写 30 行

内存泄漏:RxJS 最大的坑

写 React / Vue 组件时如果忘记取消订阅,就会泄漏:

// ❌ 坑爹写法
onMounted(() => {
  interval(1000).subscribe((n) => console.log(n))
  // 组件销毁后这个 interval 还在跑!
})

正确的做法有三种:

方式一:手动 unsubscribe

let sub: Subscription
onMounted(() => {
  sub = interval(1000).subscribe(...)
})
onUnmounted(() => sub?.unsubscribe())

方式二:takeUntil + 销毁信号(推荐)

const destroy$ = new Subject<void>()

interval(1000).pipe(
  takeUntil(destroy$),
).subscribe(...)

ajax.getJSON('/api/x').pipe(
  takeUntil(destroy$),
).subscribe(...)

onUnmounted(() => {
  destroy$.next()
  destroy$.complete()
})

一个 destroy$ 管所有订阅,统一收尾。这是 Angular 圈广为流传的 pattern。

方式三:利用框架机制

Vue 里可以直接用 useSubscription 这种社区 composable,或者自己写一个:

function useSubscription() {
  const subs: Subscription[] = []
  onUnmounted(() => subs.forEach((s) => s.unsubscribe()))
  return {
    add(s: Subscription) { subs.push(s); return s }
  }
}

const { add } = useSubscription()
add(interval(1000).subscribe(...))

跟 async / await 怎么搭

很多人问:"我有 async / await 了,还要 RxJS 做什么?"

我的回答是:单值用 await,流用 RxJS。具体怎么判断:

  • "拿到用户信息" → 单值 → await
  • "用户每次点击的搜索请求,且要防抖 + 取消旧请求" → 流 → RxJS
  • "页面加载时并发请求 3 个接口" → Promise.allforkJoin 都行
  • "WebSocket 不断推消息,组件销毁要断连" → 流 → RxJS

它们可以混用,Observable 转 Promise 用 firstValueFrom / lastValueFrom:

const user = await firstValueFrom(getUser$('123'))

Promise 转 Observable 用 from:

from(fetch('/api/user').then((r) => r.json())).pipe(...)

调试技巧

RxJS 流调试比同步代码痛苦,因为时间维度看不到。常用三招:

import { tap } from 'rxjs/operators'

obs$.pipe(
  tap((v) => console.log('after step1:', v)),
  map(...),
  tap((v) => console.log('after step2:', v)),
  filter(...),
  tap({
    next: (v) => console.log('next:', v),
    error: (e) => console.log('error:', e),
    complete: () => console.log('done'),
  }),
)

tap 是 RxJS 里的 console.log,不会影响流本身,只做副作用打印。

另外强烈推荐 RxJS Marbles 这个在线可视化工具,所有操作符都有动画演示,看一眼就懂。

什么场景值得用,什么场景不要用

值得用:

  • 复杂事件流(拖拽、自动补全、实时聊天、协同编辑)
  • 多个数据源组合(combineLatest 做表单 / 全局状态合成)
  • 取消 / 重试 / 防抖节流是核心需求
  • WebSocket / SSE 等长连接
  • Angular 项目(整个框架都基于 RxJS)

不要用:

  • 简单的 CRUD(一次请求拿数据,渲染),async/await 完全够
  • 团队没人懂(学习成本会拖垮项目)
  • 只有 1-2 处复杂时序(就为了那两处引入整个 RxJS 不划算)
  • 已经在用 SWR / TanStack Query 等数据请求库(它们内部已经做了缓存 / 重试 / 取消,基本覆盖了 RxJS 在请求侧的优势)

学习曲线说实话

RxJS 的学习曲线是这样的:

难度

 │  ┌────── ←── 这里很多人放弃
 │  │
 │  │
 │__│

 └─────────────────→ 时间
   入门  半个月    一个月+

前两周非常陡,因为要同时掌握:函数式思维 + 时间维度的数据结构 + 100+ 操作符的细微差别 + 冷热流概念。

一旦熬过去,会有"打开新世界"的感觉 —— 之前写起来一团乱的复杂时序代码,现在 10 行内能搞定。这就是为什么 Angular、Netflix、各种实时应用都重度依赖它。

我自己学的路径建议:

  1. 先把 Observable / Observer / Subscription / Operator 四个核心概念过一遍(看完这篇就够了)
  2. RxJS Marbles 里所有操作符的动画都点一遍,直觉胜过死记
  3. 找一个实际场景练手:写一个搜索建议组件,从最丑的 Promise 版开始,改到 RxJS 版,体感会很深
  4. 看一下 Learn RxJS 的 recipes(食谱),里面把常见 pattern 都列出来了
  5. 在项目里先从一个小地方引入,别想一口气替换所有异步代码

一句话总结

RxJS 是给"事件序列"和"复杂时序"准备的瑞士军刀,不是给"一次请求拿数据"准备的。

如果你的项目只有简单 CRUD,async/await 就是最佳答案。但如果你做的是搜索、实时、协同、长连接、复杂表单这类场景,RxJS 能让代码从"难以维护的回调网"变成"声明式的数据流",这种心智模型上的升级,值得花两周学一下。


推荐资源:

  • 官方文档:rxjs.dev(7.x 是稳定版,8.x 在改一些 API 命名)
  • 可视化:RxJS Marbles
  • 实战合集:Learn RxJS
  • 一本旧但好的书:"Reactive Programming with RxJS"(O'Reilly)

下次见。

-EOF-