从观察者模式到 RxJS:让复杂的异步逻辑变得优雅又舒服

0 阅读4分钟

深度剖析:从原生观察者模式到 RxJS,彻底降伏前端异步洪荒

在我们日常的前端开发中,尤其是面对极其复杂的业务中台、微前端架构或是高度动态的交互页面时,Promiseasync/await 往往显得力不从心。为什么?因为它们天生只能处理单次的异步结果。

今天,我们将从最基础的观察者模式(Observer Pattern)出发,一步步推演出为何我们需要 RxJS,并深入探讨它在真实业务场景中的杀手级应用。

本文代码侧重于原生 JS 与 RxJS 的核心逻辑结合。在 Vue3 框架中,我们通常会在 setup 阶段构建流,并在 onUnmounted 中统一执行 unsubscribe 以确保内存安全。享受 Vibe Coding 带来业务提效的同时,别忘了偶尔回归底层,可以过一遍,在聪明的小脑瓜里面留下索引哦!😉

一、 起点:原生观察者模式的实现

前端无处不在的 addEventListener 就是观察者模式的变体。它的核心理念非常简单:发布者(Publisher)维护一个状态,当状态变更时,主动通知所有订阅者(Subscriber)。

我们先用原生 JS 手写一个标准的观察者:

// 1. 定义发布者 (Subject)
class Subject {
  constructor() {
    this.observers = []; // 维护订阅者名单
  }

  subscribe(observer) {
    this.observers.push(observer);
    // 返回一个取消订阅的函数,防止内存泄漏
    return () => {
      this.observers = this.observers.filter(obs => obs !== observer);
    };
  }

  next(data) {
    // 广播:通知所有订阅者
    this.observers.forEach(observer => observer(data));
  }
}

// 2. 业务使用场景:简单的状态同步
const userStatus$ = new Subject();

// A 模块订阅
const unsubscribeA = userStatus$.subscribe((status) => {
  console.log(`[模块A] 收到用户状态更新: ${status}`);
});

// B 模块订阅
userStatus$.subscribe((status) => {
  console.log(`[模块B] 调整 UI 适配状态: ${status}`);
});

// 状态变更,触发广播
userStatus$.next('ONLINE');
userStatus$.next('OFFLINE');

// 模块A销毁时取消订阅
unsubscribeA();

观察者模式的痛点在哪?

虽然上面的代码实现了解耦,但在真实的复杂业务中,它很快就会遇到瓶颈:

  1. 无法对数据流进行“中途加工”: 每次 next 推送的数据,订阅者只能原封不动地接收。如果模块 A 需要过滤掉 OFFLINE 状态,只能在 subscribe 的回调里写 if 判断。
  2. 异步竞态处理极难: 如果每次状态变更都需要发一次网络请求,用户连续触发 3 次变更,如何保证最后一次请求的结果不会被前两次的慢请求覆盖?
  3. 缺乏生命周期管理: 原生观察者只有 next(推送数据),缺少 error(报错)和 complete(流结束)的标准机制。

二、 进化:RxJS 的降维打击

为了解决上述痛点,RxJS 在观察者模式的基础上,引入了迭代器模式函数式编程的理念。

在 RxJS 的世界里,一切皆为流(Observable) 。它不仅能发射数据,更重要的是,它提供了一条流水线(Pipe)和极其丰富的操作符(Operators) ,允许你在数据到达订阅者之前,对其进行过滤、转换、合并、防抖、截断等一系列极其优雅的操作。


三、 实战演练:RxJS 解决复杂业务痛点的 4 大核心场景

纸上得来终觉浅。接下来,我们把 RxJS 放到真实的复杂前端场景中,看看它是如何摧枯拉朽般解决问题的。

场景一:招聘管理系统的“高频复杂表单搜索与联动”

痛点描述: 在招聘后台,HR 需要通过一个输入框实时搜索候选人。要求:

  1. 必须防抖(不能每敲一个字母就发请求)。
  2. 不能发送重复的请求(比如输入 A -> 退格 -> 重新输入 A)。
  3. 最致命的竞态问题: 请求 A 耗时 2 秒,请求 B 耗时 0.5 秒。B 先返回,A 后返回,导致 UI 最终显示的是过期的 A 搜索结果。

RxJS 破局:使用 switchMap

import { fromEvent, from } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, filter, map } from 'rxjs/operators';

const searchInput = document.getElementById('candidate-search');

// 将原生 DOM 事件转换为流
const searchFlow$ = fromEvent(searchInput, 'input').pipe(
  // 1. 提取输入框的值
  map(e => e.target.value.trim()),
  // 2. 过滤掉空字符串
  filter(keyword => keyword.length > 0),
  // 3. 防抖:用户停顿 400ms 后才继续向下流转
  debounceTime(400),
  // 4. 剔除重复值:如果当前值和上一次触发流转的值一样,则拦截
  distinctUntilChanged(),
  // 5. 核心杀招 switchMap:自动取消上一轮未完成的 Promise/Observable
  // 彻底告别请求 A 覆盖 请求 B 的竞态 Bug
  switchMap(keyword => from(mockApiSearch(keyword))) 
);

// 最终订阅渲染
searchFlow$.subscribe({
  next: (candidates) => renderList(candidates),
  error: (err) => console.error('搜索异常', err)
});

// 模拟异步搜索请求
async function mockApiSearch(query) {
  console.log(`[发送网络请求]: ${query}`);
  const res = await fetch(`/api/candidates?q=${query}`);
  return res.json();
}

场景二:Wujie (无界) 微前端架构下的跨应用“事件总线”

痛点描述: 在采用 Wujie 进行老系统重构改造时,主应用和多个子应用之间经常需要频繁通信(例如:子应用完成了一次人员录用,需要通知主应用更新顶部的通知数量,并触发另一个工资条子应用的刷新)。传统的 window.postMessage 难以管理,极易导致事件风暴。

RxJS 破局:构建基于 Subject 的过滤型总线

import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';

// --- 主应用中定义的全局总线 (挂载在全局共享作用域) ---
export class GlobalEventBus {
  constructor() {
    this.bus$ = new Subject();
  }

  // 发射事件
  emit(eventName, payload) {
    this.bus$.next({ eventName, payload });
  }

  // 按需监听特定事件
  on(targetEventName) {
    return this.bus$.pipe(
      // 核心:直接在管道层过滤,订阅者只会收到自己关心的事件
      filter(event => event.eventName === targetEventName)
    );
  }
}

const eventBus = new GlobalEventBus();
window.$microBus = eventBus; 

// --- 子应用 A (招聘模块):触发录用 ---
window.$microBus.emit('STAFF_HIRED', { staffId: '8848', name: '张三' });

// --- 主应用:监听录用事件并更新 UI ---
const hiredSub = window.$microBus.on('STAFF_HIRED').subscribe(({ payload }) => {
  console.log(`主应用接收到录用通知,更新系统通知栏:${payload.name}`);
});

// --- 子应用 B (薪资模块):监听录用事件初始化薪资档案 ---
const salarySub = window.$microBus.on('STAFF_HIRED').subscribe(({ payload }) => {
  console.log(`薪资模块接收:准备为 ${payload.staffId} 创建薪资账套`);
});

// 切记在微前端组件卸载 (onUnmounted) 时销毁订阅!
// hiredSub.unsubscribe();

场景三:业务大盘 / 数据看板的多维接口聚合

痛点描述: 进入系统首页大盘,需要同时调用“今日入职人数”、“待处理审批流”和“最新系统公告”三个毫无关联的接口。我们需要等它们全部返回后,消除 loading 状态,统一渲染。Promise.all 如果其中一个挂了,整体就全挂了。

RxJS 破局:forkJoin 与容错捕获

import { forkJoin, from, of } from 'rxjs';
import { catchError } from 'rxjs/operators';

// 封装接口请求,赋予独立的错误容忍能力
const fetchWithFallback = (apiPromise, fallbackValue) => {
  return from(apiPromise).pipe(
    catchError(err => {
      console.warn('接口请求降级:', err);
      return of(fallbackValue); // 即使报错,也返回一个兜底值,不阻断全局
    })
  );
};

const onboardingStats$ = fetchWithFallback(fetch('/api/stats/onboarding'), { count: 0 });
const approvals$ = fetchWithFallback(fetch('/api/approvals/pending'), []);
const notices$ = fetchWithFallback(fetch('/api/notices'), []);

// forkJoin 相当于强大的 Promise.all
forkJoin({
  stats: onboardingStats$,
  approvals: approvals$,
  notices: notices$
}).subscribe({
  next: (dashboardData) => {
    // 隐藏整体 Loading,统一渲染视图
    hideLoading();
    console.log('大盘数据初始化完成:', dashboardData);
    // dashboardData.stats | dashboardData.approvals
  }
});

场景四:长轮询(Polling)与优雅的终止控制

痛点描述: 导出几十万条工资条记录是一个慢任务,前端提交导出请求后,需要每隔 3 秒去轮询一次后端的任务状态。直到状态变为 SUCCESS,或者用户点击了页面上的“取消导出”按钮,彻底停止轮询。

RxJS 破局:timer + takeUntil

import { timer, fromEvent, Subject } from 'rxjs';
import { switchMap, takeUntil, filter, tap } from 'rxjs/operators';

const cancelBtn = document.getElementById('cancel-export-btn');
// 点击取消按钮的流
const cancelClick$ = fromEvent(cancelBtn, 'click');

// 触发导出的流(这里用 Subject 模拟触发)
const startExport$ = new Subject();

startExport$.pipe(
  // 每次触发导出,启动一个每 3 秒触发一次的定时器流
  switchMap(() => timer(0, 3000).pipe(
    // 每次定时器触发,发请求查询状态
    switchMap(() => from(checkExportStatus())),
    // 核心杀招1:如果状态是 SUCCESS,则截断这个流,停止轮询
    filter(res => {
      if (res.status === 'SUCCESS') {
        downloadFile(res.url);
        return false; // 阻断传递,但这里如果要停止整个流通常配合 takeWhile
      }
      return true; // 继续轮询
    }),
    // 核心杀招2:如果用户点击了取消按钮,立刻强制终止这根水管,结束轮询
    takeUntil(cancelClick$)
  ))
).subscribe();

// 业务触发
startExport$.next();

// 模拟状态查询
async function checkExportStatus() {
  console.log('查询导出进度中...');
  return { status: 'PENDING' }; // 后续变为 SUCCESS
}

结语

从基础的“观察者模式”迈入“RxJS 流式编程”,思维的转变是痛苦的,但收益是极其可观的。

当你在项目中遇到了竞态竞争、需要精确控制防抖节流、需要聚合多端数据或是管理极其复杂的微前端通信体系时,你会发现原先写成一坨意大利面条式的 async/await 状态变量,在 RxJS 的管道(Pipe)中,变成了一股股清晰、独立且易于维护的数据清泉。

技术没有银弹,但 RxJS 绝对是对抗复杂前端异步流的终极武器。