在 React+RxJS 的开发方案下,「流」为开发者带来了强大的抽象能力,但 React/RxJS 之间的集成并不容易,这中间会涉及到两种不同的编程模式,代码写法的转变、输入输出的适配、开发思路的切换等都会大大提高 RxJS 在 React 环境下的使用成本。 rxjs-hooks 为我们带来了最轻量的 RxJS+React hooks 的集成方案,避免了原来琐碎的样板代码,让我们愉快地在 React 中享受 RxJS 带来的好处。

手动订阅,手动管理声明周期,还要通过 React 中的 state 搭建一个与 render 函数 (UI) 之间的桥梁。那么使用 rxjs-hooks 之后呢:………… 没有手动订阅,不需要再理会生命周期的管理。只需要一个不到 1kb 的依赖,就能在 React 世界里快乐地拥抱 RxJS.

“不够复杂”

LeetCode-OpenSource/rxjs-hooks 是一个非常不错的类库,易于上手,接入成本低。不过近期我在使用 rxjs-hooks 开发一些页面的过程中,却发现 rxjs-hooks “不够复杂”,进而导致不好用。“不够复杂” 并不是因为 rxjs-hooks 本身的问题,而是因为 rxjs-hooks 考虑的场景较为简单,而我负责的页面中出现了一些复杂的需求无法用 rxjs-hooks 简单直观地表达出来,导致应用层代码较为臃肿。考虑到 rxjs-hooks 源码行数也不多,我就直接 fork 了 rxjs-hooks 然后自己进行修改。为了使得我们能够更充分利用的 RxJS 所带来的优势,@little-saga/rx-hooks 在原来的基础添加了以下特性……

特性一:更好用的缓存状态(memoized state)& 状态导出

useMemo 是 React hooks 下的默认缓存方案,在该方案下,开发者声明一个依赖数组来决定缓存何时失效。每当依赖数组中的值发生变化时,工厂函数便会再次运行计算出最新的值。不过因为我们无法自定义依赖数组的比较方式,在一些比较复杂的情况下,我们需要不断在 useMemo 的上游加上更多的 useMemo,以保证 React 可以正确地判断依赖数组是否发生变化。
而在 RxJS 的世界中,缓存状态可以用很简单的抽象进行表达 —— 「一份变化没那么频繁的状态流」。我们利用 RxJS 提供的 distinct、distinctUntilChanged 等操作符可以方便地从「高频流」中创建「低频流」,使得获取一份缓存状态的成本非常低。此外基于流(Observable)的缓存状态方案还有一些其他的优势:

  • distinctUntilChanged 操作符允许接受自定义的比较函数,借此我们可以去细粒度地控制缓存失效时机,不用受到 “useMemo 只会进行 shallow equal 比较” 的限制。
  • 获取一份缓存状态在代码中的写法是「为一个流应用一个操作符」,与已有的其他代码保持一致。
  • RxJS 中缓存状态的获取不受上下文限制;而 useMemo 则需要遵守 hooks 的规则,受上下文限制太大,很难说得上是一个通用的缓存方案。

在用 RxJS 实现页面的 “模型层” 时,当涉及到复杂状态的缓存时,我们应该用 RxJS 代替 React 提供的 useMemo。那么当缓存状态被抽象为相应的流之后,我们应该如何在 render 中消费它们呢?在 rxjs-hooks 中,工厂函数返回的 Observable 对象会被直接绑定到组件的 state 上,无法处理缓存状态(缓存状态可以基于其他原始状态计算而来,我们只需要将原始的状态绑定到组件 state 即可)。

在 @little-saga/rx-hooks 中,缓存逻辑会被放入 novel(@little-saga/rx-hooks 中 RxJS 逻辑代码组织单元)中算出若干个缓存状态的 Observable,然后通过 novel 返回对象中的 derived 字段导出。在组件的 render 中,我们通过 useNovel 可以获取到 derived 中字段的值,然后在渲染代码中消费它们。

  1. import { useNovel } from '@little-saga/rx-hooks'
  2. function someNovel(input$, state$) {
  3. // 一些比较复杂的逻辑或计算
  4. const someState$ = someLogic(input$, state$)
  5. const derivedState1$ = someCalculation1(someState$)
  6. const derivedState2$ = someCalculation2(someState$)
  7. const nextState$ = someReallyComplexLogic(someState$, derivedState1$)
  8. return {
  9. // 通过 nextState 字段导出决定下一个状态的 Observale
  10. // 每当 nextState$ 发出一个 NEXT notification 时,组件的 state 就会被更新,组件将重渲染
  11. // 与 LeetCode-OpenSource/rxjs-hooks 的行为是一致的
  12. nextState: nextState$,
  13. // 通过 derived 字段导出一些缓存状态
  14. // dervied$ 不会触发组件的渲染,但是会影响到 useNovel 的返回值
  15. derived: combineLatest(derivedState1$, derivedState2$).pipe(
  16. map(([{ foo, bar }, { buzz }]) => ({ foo, bar, buzz })),
  17. ),
  18. }
  19. }
  20. function Component() {
  21. const [stateAndDerived, exports] = useNovel(null, initState, someNovel)
  22. // stateAndDerived 是一个对象,是 novel 中返回的 state 和 derived 的合并结果
  23. // exports 对应 novel 返回值中的 export 字段(将在本文下方介绍)
  24. const { foo, bar, buzz, ...others } = stateAndDerived
  25. return <div>...</div>
  26. }

特性二:支持清理逻辑

很多时候,我们需要在 novel 中进行订阅操作(subscrption),那么相应地我们需要在适当的时候(例如组件卸载时)进行 unsubscribe。@little-saga/rx-hooks 允许 novel 将清理逻辑放在返回值的 teardown 字段中,这样一个 novel 将包含执行逻辑与对应的清理逻辑,形成一个独立的逻辑单元。

  1. function someNovel(input$, state$) {
  2. const subscription = new Subscription()
  3. // action 可以用于在 novel 中进行消息转发
  4. const action$ = new Subject<Action>()
  5. const nextState$ = action$.pipe(
  6. withLatestFrom(state$),
  7. map(([action, state]) => reducer(state, action)),
  8. )
  9. const fetchResultAction$ = action$.pipe(
  10. ofType('fetch-some-api'),
  11. switchMap(action => fetch(action.url, action.params)),
  12. map(response => ({ type: 'fetch-success', data: response.data })),
  13. )
  14. const subscription = fetchResultAction$.subscribe(fetchAction => {
  15. action$.next(fetchAction)
  16. })
  17. return {
  18. nextState: nextState$,
  19. // 将 novel 对应的清理逻辑写在 teardown 中
  20. // 当 novel 对应的组件被卸载时,teardown 函数将被执行
  21. teardown() {
  22. action$.complete()
  23. subscription.unsubscribe()
  24. },
  25. }
  26. }

特性三:支持对象导出

很多时候我们需要将 novel 中的对象或函数暴露 / 导出到组件中,例如 novel 在初始化时建立了一个 websocket 连接,我们将 WebSocket 对象导出到组件中,以方便组件直接访问该对象上的属性或方法。另一个典型场景是 novel 中创建了一个 Subject 用于消息转发,novel 需要暴露一些方法以允许 novel 外面的世界向内部发送消息。

  1. function powerNovel(input$, state$) {
  2. const treeActionProxy$ = new Subject()
  3. const expandToDepth = (node, depth) => { treeActionProxy$.next({ type: 'expand', node, depth }) }
  4. const deleteNode = node => { treeActionProxy$.next({ type: 'delete-node', depth }) }
  5. return {
  6. nextState: nextState$,
  7. // 通过 exports 字段导出对象
  8. exports: { expandToDepth, deleteNode },
  9. }
  10. }
  11. function MyComponent() {
  12. // 通过解构 useNovel 的返回值,获取到 novel 导出的对象
  13. const [state, { exportToDepth, deleteNode }] = useNovel(input, initState, powerNovel)
  14. return <div>...</div>
  15. }

该导出机制允许我们导出任意对象(甚至是 Observable,谨慎使用),极大提升代码灵活度,非常适合用于构建 novel 输入 / 输出机制,降低 React 与 RxJS 之间的集成成本。

rxjs-hooks 与 @little-saga/rx-hooks 本质上都是一种 React hooks + RxJS 的集成方案,本文是我在日常业务开发过程中对此类集成方案的一些思考和改进的尝试。一个优雅的集成方案能够帮我们用更简短的代码去构建 React hooks 与 RxJS 之间的沟通桥梁,但更重要的是我们要去理解 React hooks 与 RxJS 本身的运行机制,根据开发中遇到的实际需求不断优化集成方案。
https://zhuanlan.zhihu.com/p/92248348