写 JavaScript 这几年,对异步处理的认知大概经历过四个阶段:
- 回调地狱:
callback(err, data)嵌套五层,光是缩进就跟金字塔似的 - Promise:
.then().then().catch(),终于能链式了,但仍然只能管"一次性"的值 - async / await:同步写法写异步,90% 场景够用,代码可读性飞升
- 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() // 跟上一个相同就不发(去连续重复)debounceTime 跟 throttleTime 的差别很多人搞混:
- 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.all或forkJoin都行 - "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、各种实时应用都重度依赖它。
我自己学的路径建议:
- 先把
Observable / Observer / Subscription / Operator四个核心概念过一遍(看完这篇就够了) - 把 RxJS Marbles 里所有操作符的动画都点一遍,直觉胜过死记
- 找一个实际场景练手:写一个搜索建议组件,从最丑的 Promise 版开始,改到 RxJS 版,体感会很深
- 看一下 Learn RxJS 的 recipes(食谱),里面把常见 pattern 都列出来了
- 在项目里先从一个小地方引入,别想一口气替换所有异步代码
一句话总结
RxJS 是给"事件序列"和"复杂时序"准备的瑞士军刀,不是给"一次请求拿数据"准备的。
如果你的项目只有简单 CRUD,async/await 就是最佳答案。但如果你做的是搜索、实时、协同、长连接、复杂表单这类场景,RxJS 能让代码从"难以维护的回调网"变成"声明式的数据流",这种心智模型上的升级,值得花两周学一下。
推荐资源:
- 官方文档:rxjs.dev(7.x 是稳定版,8.x 在改一些 API 命名)
- 可视化:RxJS Marbles
- 实战合集:Learn RxJS
- 一本旧但好的书:"Reactive Programming with RxJS"(O'Reilly)
下次见。
-EOF-