近期使用Rx.js有感

2,392 阅读4分钟

写在前面,读完文章你将获得:

  • Rxjs 适合的使用场景
  • 对于 Rxjs 的使用有个初步认知

背景

最近接手了一个类聊天窗口的项目,项目接收消息主要是通过ws被动更新,但包含着 mtop 等主动发消息的场景。而在17年,在业内出现了一股响应式变成的讨论,笔者也是在那时第一次了解到这个概念(初次知道了Rx.js),但当时只是在demo场景中浅尝则之,而根据「消息场景是天然适合响应式编程」这一映像,开始尝试在实际业务中使用Rx.js。

Wikipedia:

计算中,响应式编程反应式编程(英语:Reactive programming)是一种面向数据和变化传播的声明式编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。

A | B | C			 A | B | C				A | B | C
---------			------------				------------
 Server			前端的数据层				  前端的action
                            ------------				------------
                               Server			       前端的reducer
                                                                    ------------
                                                                     Server

数据层发展历程: React、Reflux、Redux,但对于异步流程的竞态问题,数据层框架依然没有很好地解决

为了解决上述问题,MS 提出了响应式编程的概念,出现了ReactiveX的大家族,它的目标是建立一个库,专门处理异步流程中的流问题,可以被看作为loadash for stream。

结论

适合的场景

业务场景抽象出来的状态机,包含各种复杂状态,每个状态在时间维度上有先后关系,每个状态有 推/拉 操作驱动状态流转,状态的推/拉操作又是异步为主。

针对这种复杂的、多状态、异步、注重时序控制的场景,天然就适合Rx

  1. 实时数据更新:股票市场行情更新、实时聊天应用、在线游戏中的状态同步
  2. 异步事件处理:支持用户交互的前端应用程序,比如反应性UI、流媒体服务,处理视频和音频流
  3. 高并发和高吞吐量的系统:社交媒体动态更新流
  4. 复杂的数据流处理:数据处理流水线(Data pipeline),例如ETL过程
  5. 依赖异步IO操作的应用

缺点:在处理复杂的单页面数据层问题上是一个好用的工具,只是上手的复杂度较高(初次看代码的话不太能理解,需要了解操作符和按流的方式思考),调试不方便。所以,目前看来rxjs相对于它所带来的好处,成本代价太高,只适用于强实时的重型单页面架构。

场景Case

异步流程竞态

Case 1

image.png

image.png

在 UI 界面上会展示一个提示词,这个提示有三种状态,图一为普通状态,当选择了商品或者图片时,提示词要随着选择的事物做出改变。

import { useAppStore } from '@/infrastructure/store';
import { MessageType } from '@/sdk';
import { IMessageSender } from '@/types';
import { catchError, EMPTY, from, retry, Subject, switchMap } from 'rxjs';
import { container } from 'tsyringe';

interface IAssociateWord {
  msgType?: MessageType;
}

const associateWord$ = new Subject<IAssociateWord>();
export const associateWordTrigger = (msgType?: MessageType) => associateWord$.next({ msgType });

associateWord$
  .pipe(
    switchMap(({ msgType }) =>
      from(container.resolve<IMessageSender>(IMessageSender).getAssociate(msgType)).pipe(
        retry(2),
        catchError((err) => {
          console.error('getAssociate error:', err);
          return EMPTY;
        }),
      ),
    ),
  )
  .subscribe((associateWords) => {
    useAppStore.setState({ associateWords });
  });

image.png

Case 2

有一个实时数据的面板,第一次打开要求要有数据。且每次打开都要刷新数据并且解读数据,且右上角的圆圈要求有loading状态。

image.png

import { useAppStore } from '@/infrastructure/store';
import { EMtop_Name, index } from '@/utils/mtop';
import { exhaustMap, filter, from, map, retry, shareReplay, Subject, tap } from 'rxjs';

const { setDatePreviewList } = useAppStore.getState();

const trigger$ = new Subject<{ needOperate: boolean }>();

export const dataPreviewTrigger = (needOperate = false) => {
  trigger$.next({ needOperate });
};

export const dataPreview$ = trigger$.pipe(
  exhaustMap(({ needOperate }) => from(
    index(EMtop_Name.dataQuery, {})).pipe(map((data) => ({ data, needOperate })))
  ),
  tap(({ data }) => setDatePreviewList(data.result)),
  filter(({ needOperate }) => needOperate),
  exhaustMap(() => from(index(EMtop_Name.dataOperate, {}))),
  retry(2),
  shareReplay(1),
);

dataPreview$.subscribe(
  (value) => console.log('dataPreview$', value),
  (error) => console.error('dataPreview$'),
);
const reloadData = () => {
  setIsLoading(true);
  dataPreviewTrigger(true);
};

useEffect(() => {
  const subscription = dataPreview$.subscribe(
    () => setIsLoading(false),
    (err) => setIsLoading(false),
  );
  reloadData();
  
  return () => subscription.unsubscribe();
}, []);

image.png

合流

Case 1

image.png

    import { EEnterEvent } from '@/types';
    import { EMtop_Name, index } from '@/utils/mtop';
    import { history } from 'ice';
    import { BehaviorSubject, combineLatest, from, map } from 'rxjs';

    /* 商业化指引打开页面 */
    type TOpenPageVal = {
      eventName: EEnterEvent;
    };
    const fromServerOpenPage$ = from(index(EMtop_Name.enterEvent, {}, {}))
      .pipe(map((val) => val.result));
    const openPage$ = new BehaviorSubject<TOpenPageVal>({ eventName: EEnterEvent.None });

    export function openPageTrigger(val: TOpenPageVal) {
      openPage$.next(val);
    }

    combineLatest([openPage$, fromServerOpenPage$])
      .pipe(map(([a, b]) => (a.eventName === EEnterEvent.None ? b : a)))
      .subscribe((val: TOpenPageVal) => {
        if (val?.eventName === EEnterEvent.NewGuidance) {
          // goto guide page
          history?.push('/guide');
        }
      });

Case 2

image.png

image.png

卡片的按钮区和选择区是两个上下文,按钮除了选择提交外,还可能有其他功能。
并且卡片在点击之后,会出现消失自己或者消失一整排或者不消失的情况。

    const msgActButtonClk$ = actionButtonClick$.pipe(
          tap((val) => {
            messageActionLog('actionButtonClick$ filter', msgId);
            sendAesEvent({ id: AesEventId.聊天页面_卡片_行动点, type: AesEventType.CLK, args: [msgId] });
          }),
          filter((i) => i.msgId === msgId),
          share(),
        );

    const _commit$ = msgActButtonClk$.pipe(
      filter((i) => i.actionButton.action.eventName === EEventNameType.CommitSelect),
      withLatestFrom(
        commitSelect$.pipe(
          filter((i) => i.msgId === msgId),
          startWith(null),
        ),
      ),
      filter(([a, b]) => {
        if (!b) Toast.show('至少选择一个选项');
        return !!b;
      }),
      share(),
    );
    const _postMessage$ = msgActButtonClk$.pipe(
      filter((i) => i.actionButton.action.eventName === EEventNameType.PostMessage),
      share(),
    );
    const _openUrl$ = msgActButtonClk$.pipe(
      filter((i) => i.actionButton.action.eventName === EEventNameType.OpenUrl),
      share(),
    );

    const clickSubscription = merge(_commit$, _postMessage$, _openUrl$)
      .pipe(
        map((v) => {
          if (Array.isArray(v)) {
            return v[1] ? v[0] : null;
          } else {
            return v;
          }
        }),
      )
      .subscribe((val) => {
        if (val) {
          const { msgId, actionButton, index } = val;
          const { clickStrategy } = actionButton;
          switch (clickStrategy) {
            case EClickStrategy.None:
              break;
            case EClickStrategy.HiddenAll:
              dataManager.hideMessageActionButton(msgId);
              break;
            case EClickStrategy.HiddenSelf:
              dataManager.hideMessageActionButton(msgId, index);
              break;
          }
        }
      });

总结

个人感觉 Rxjs 比较合适用在数据层,不必将所有行为都抽象成流的方式,例如原来 view 层处理用户事件可继续按照原来的方式。