精读《react-rxjs》

本周精读的代码是 react-rxjs

1 引言

本周精读的是 git 仓库 - react-rxjs,它给出了一个思路,让 rxjs 更好的与 react 结合。

2 概述

View 层

View 层设计没商量,至少应该看不出 rxjs 的痕迹,它做到了:

// view.tsx
export default (props) => (
    <div>
      {props.number}
      <button onClick={props.inc}>+</button>
      <button onClick={props.dec}>-</button>
    </div>
)

Container 层

链接 View 与 Store 的层,同样也看不出 rxjs 的痕迹:

import { inject } from 'react-rxjs'
import store$, { inc, dec } from './store'
import MyComponent from './view'

const props = (storeState: number): MyProps => ({
    number: storeState,
    inc,
    dec
})

export default inject(store$, props)(MyComponent)

这里 storeState 就是 store 全部数据,注意 react-rxjs 是多 store 思想,所以 inject 第一个参数传入不同的 store,组件就会与对应的 store 绑定。

Store 层

这里代码就很有意思了,必须将 rxjs 与 action 对接起来:

import { createStore } from 'react-rxjs'

const inc$ = new Subject<void>()
const dec$ = new Subject<void>()

const reducer$: Observable<(state: number) => number> = Observable.merge(
    inc$.map(() => (state: number) => state + 1),
    dec$.map(() => (state: number) => state - 1)
)

const store$ = createStore("example", reducer$, 0)

export inc = () => inc$.next()
export dec = () => dec$.next()
export default store$

如果转换成 redux 思维,action 就是下面的 inc 函数:

const inc$ = new Subject<void>()
export inc = () => inc$.next()

reducer 就是下面的 reducer$,整个 store 对应 Observable.merge,switch case 的地方被 inc$dec$ 自动识别出来了。

const reducer$: Observable<(state: number) => number> = Observable.merge(
    inc$.map(() => (state: number) => state + 1),
    dec$.map(() => (state: number) => state - 1)
)

笔者优化一下代码结构,让 action 与 reducer 看起来更内聚:

const inc$ = new Subject<void>()
export inc = () => inc$.next()
const incReducer = inc$.map(() => (state: number) => state + 1)

const dec$ = new Subject<void>()
export dec = () => dec$.next()
const decReducer = dec$.map(() => (state: number) => state - 1)

const reducer$: Observable<(state: number) => number> = Observable.merge(
    incReducer,
    decReducer
)

3 精读

让我们聚焦到 Action 部分:

const inc$ = new Subject<void>()
export inc = () => inc$.next()

可以看出,Action 功能很弱,我们只能触发 reducer,却无法 mergeMap 等流汇总的处理。

上周和叔叔讨论了 Rxjs 的一种代码组织方式:将 Rxjs 切成两部分使用,第一部分是数据源的抽象、聚合;第二部分是,对已经聚合过的单一数据源订阅后进行处理,这里处理过程只能包含对这个数据源的操作,不能再 merge 其他数据源。

这恰恰也是 Rxjs 在数据流中发挥的两大作用。分别是抽象,或者说是对副作用的隔离;以及强大的流处理能力。

react-rxjs 虽然代码看上去很简单,但 Action 部分没有足够的抽象能力,举例子说就是无法进行流的 merge,因为 Subject 自己就是一个事件触发器,想要进行流合并,必须发生在 reducer 中:

const incReducer = inc$.merge(requestUser$).map(() => (state: number) => state + 1)

但这样就丧失了 Action 与 Reducer 一一对应的关系,因为 reducer 可以擅自 merge 任意数据流,那就完全不受控制了。

所以回到第二个约定:对已经聚合过的单一数据源订阅后进行处理,此时不能包含任何 merge 操作。

可以总结一下,react-rxjs 的方式是解决了 rxjs 与 react 结合繁琐的问题,但如果遵守开发约定,Action 的功能就很弱,无法进行进一步抽象,如果不遵守开发约定,就可以解决 Action 能力弱的问题,但带来的是 Reducer 与 Action 脱离关系,这在项目维护中是不可接受的。

所以 react-rxjs 是一个看上去方便,但实践起来会发现怎么都不舒服的方案。

redux-observable

我们再看 redux-observable 这个库,就很容易理解为什么这么做了。

const pingEpic = action$ =>
  action$.filter(action => action.type === 'PING')
    .delay(1000) // Asynchronously wait 1000ms then continue
    .mapTo({ type: 'PONG' });

// later...
dispatch({ type: 'PING' });

redux-observable 只有一个数据源,在 dispatch 的过程触发事件,进入 action 逻辑。其实每个 action 都源自对同一个数据源的订阅,通过 action.type 的筛选来确保执行了正确的 action。

所以每次 dispatch,包括 mapTo 也是 dispatch,都会触发数据源的事件派发,然后所有 Action 因为订阅了这个数据源,所以都会执行,最后被 .filter 逻辑拦截后,执行到正确的 Action。整个 Action 间调用的链路打个比方,就像我们使用微信一样,当触发任何消息,都会将其送到后台服务器,服务器给所有客户端发消息(假设系统设计的有问题,没有在服务端做 filter。。),每个客户端根据用户名做一个筛选,如果不是发给自己的消息,就过滤掉。然后,任何人与人之间的消息发送,都会走一遍这个流程。

reducer 与 redux 的 reducer 一摸一样:

const pingReducer = (state = { isPinging: false }, action) => {
  switch (action.type) {
    case 'PING':
      return { isPinging: true };

    case 'PONG':
      return { isPinging: false };

    default:
      return state;
  }
}

redux-observable 的设计比 react-rxjs 好在哪呢?我认为好在遵循了上面总结的两条经验:

第一部分是数据源的抽象、聚合;第二部分是,对已经聚合过的单一数据源订阅后进行处理,这里处理过程只能包含对这个数据源的操作,不能再 merge 其他数据源。

Action 之间的 dispatch 就是第一部分对数据源的整合,这里包括所有副作用。Reducer 只需要挑选合适的 ActionType 绑定,这样确保了 Reducer 中处理操作一定是对单一数据源的,不存在对其他数据源 merge,换句话说就是和 Action 一一对应。

所以整体来看,我认为 redux-observable 比 react-rxjs 要靠谱。

但是 react-rxjs 抛开了 redux 繁琐的样板代码,而 redux-observable 样板代码只会比 react-redux 要多。如果要投入项目使用,比较好的方式是按照 dva 的思路,减少 redux-observable 的样板代码。

4 总结

最后稍稍聊一下 cyclejs,因为用这个库,基本就脱离了 react 生态,我们 react 系开发者只能干瞪眼看看。

cyclejs 就一个目的,解决 react + rxjs 中阴魂不散的循环依赖问题:视图的回调函数可以产生数据源(observable),但视图又可能依赖这个数据源。

就是解决 A 依赖 B,B 又依赖 A 的问题,而且它做到了:

function main(sources) {
  const input$ = sources.DOM.select('.field').events('input')
  const name$ = input$.map(ev => ev.target.value).startWith('')

  const vdom$ = name$.map(name =>
    div([
      label('Name:'),
      input('.field', {attrs: {type: 'text'}}),
      hr(),
      h1('Hello ' + name),
    ])
  )

  return { DOM: vdom$ }
}

可以看到,最让我们不舒服的部分,就是 sources.DOM.select('.field')input('.field') 这个循环节,为什么呢?因为初始化函数还没有返回 DOM 节点,为啥就能选中 DOM 节点?而且还作为参数参与这个 DOM 的生成。

可惜 React 无法解决这个问题,我们只能通过预定义数据源来解决:首先定义一个数据源,DOM 订阅它,Action 触发时找到这个数据源,手动调用 .next()。或者 redux-observable 这样,全局只有一个数据源。

总的来说,笔者认为 rxjs 还是难以落地到 react 业务代码中,究其本质,就是没有 cyclejs 这种机制解决数据源引起的循环依赖问题。

5 更多讨论

讨论地址是:精读《react-rxjs》 · Issue #65 · dt-fe/weekly

如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):