Reactive React

发表于更新于阅读时长 9 分钟

I heard you like React, so I put Rx in your React so you can react when you React

0. 背景知识

为什么 React 不是响应式的(Why React is not reactive)? 因为:

  1. Vue 是响应式的
  2. React 不是 Vue
  3. 所以 React 不是响应式的

这样的回答当然没有什么建设性, 但正确的回答可能会非常复杂, 下文将尽力给出一个准确的回答. 让我们先从响应式这个名词开始

0.1 什么是响应式

举例说来, 在普通语言中, 写出如下代码时

a = 1; b = 2;
c = a + b;
a = 2;

都会预期此时 c 的值还是 3 而不是 4. 但我相信读者都用过 Excel. 和上面的例子不一样, Excel 里在 C3 格里写下=A1 + B2之后, 如果把 A1 里的值改成 2, C3 里的值会自动更新, 变成 4. 相信任何读者都能看出这和 Vue 的编程模型之间的相似性: 在 Vue 里, 更新一个属性之后, 就会自动调用它的所有 watcher 并进行计算, 其中第一个 watcher 负责更新 VDOM, 最终使得这次修改的效果传播到整个 state 和 view 上.

从理论上说来, 响应式指的是运行时会维护响应式值之间的依赖关系, 这显然是一个 DAG. 其中, 点表示计算某个值所需进行的计算, 而边表示各值之间的依赖关系. 这样, 在外部对程序进行输入时(这在图上表现为源点), 运行时就会依照这张图逐步执行计算并更新被影响到的值. 这样建模程序无疑有很多好处, 尤其是在处理异步数据时, 可以使用线性的方式组织代码.

特别地, 在 GUI 开发中, 把每个输入都映射为一个对视图的操作是非常困难的, 因此可以只把最终渲染结果当作输出(汇点), 而让运行时来考虑如何渲染. 而刚刚好, 我们手头正有这样一个运行时: React 强大的 Virtual DOM.

0.2 为什么 React 开发团队不这么做?

一个回答是设置 Vue 那样的响应式系统需要花费很时间, 而在 GUI 领域里短启动时间是至关重要的. 我猜 React 团队不这么做的另一个原因则是在 ES6 普及以前, 使用Object#defineProperty的实现有种种无法妥善处理的缺陷, 这对追求一致 API 的 React 团队来说可能是不可接受的.

另一个更本质的原因则写在文档里设计理念这一章. 简单地说来, 为了使得程序能对用户的输入更快地做出响应, 采用了种种优化: 推迟低优先级任务的计算, 对发生速度过快的事件进行 batching, 把计算量过大的任务分成 chunk. 而这与一般的响应式编程库的设计完全不同, 在 JS 世界中, 大部分这样的库都假设在数据变化应该立刻进行计算.

如果读者还有疑问, 推荐观看这个视频, 它给出了更直观的示范.

0.3 所以?

所以为了在使用 React 的时候享受到响应式编程的好处, 我们需要一种工具使得 React 支持响应式编程.

RxJS 的全称是 Reactive Extensions Library for JavaScript. 其中 Reactive Extensions 的意思是响应式扩展, 即是说这个库可以为命令式的语言提供响应式编程的能力. 显然, 这正是我们需要寻找的工具, 它可以将 React 这个运行时(是的, 我没写错)变得支持响应式.

那么具体要怎么做呢? React 因为种种原因(比如框架只提供 UI 功能), 有着非常繁荣的生态环境. 在 React 历史上, 很多人都尝试过把 RxJS 整合进 React. 下文将会讨论它们的具体实现以及优缺点.

1. 前 hook 时代

毫无疑问, 最直观的使用方法无过于在 React 组件的componentDidMount(只有此时才可以 setState)里订阅/产生 Observable, 把订阅存到属性上, 然后在componentWillUnmount里取消订阅. 可以简单实现如下:

class FooComp extends React.Component {
  componentDidMount() {
    this.sub = ob$.subscribe(v => this.setState(v))
  }
  componentWillUnmount() {
    this.sub.unsub()
  }
}

这个模式是如此的普遍, 所以自然而然地, 可以用 HOC 去抽象它. rxreact正是这样的库. 它的实现并没有脱离上面的说法多远, 相信读者很快能脑补出它的具体实现.

更出名的库则是recompose, 它也提供了 RxJS 的基础设施, 而其中最主要的 API 则是componentFromStream. 它的实现和 rxreact 有一些不同:

propsEmitter = createChangeEmitter()

// Stream of props
props$ = config.fromESObservable(/* ... */)

// Stream of vdom
vdom$ = config.toESObservable(propsToVdom(this.props$))

componentWillMount() {
  // Subscribe to child prop changes so we know when to re-render
  this.subscription = this.vdom$.subscribe(vdom => this.setState({ vdom }))
  this.propsEmitter.emit(this.props)
}

componentWillReceiveProps(nextProps) {
  // Receive new props from the owner
  this.propsEmitter.emit(nextProps)
}

它把 props 抽象成一个流, 传给转换函数得到一个 VDOM 流, 再把这个流渲染出来. 此外, 它用到了停在 stage-1 两年多的observable proposal.

另一个有趣的实现是rxjs-react. 它可以写出这样的代码

const App = reactive(() => <div>{interval(100)}</div>)

换言之, 可以在 render 里写 Observable. 它的实现十分精巧, 因为 React 里 View 不过是普通的 JS 对象, 所以在reactive函数里它会生成一个 HOC, 遍历 render 出来的结果并收集其中所有的 Observable 并把它们combineLatest成一个 source, 然后再把这个 source 根据前面搜集的情况map到最终的渲染结果. 简而言之, 就是

{ a: Observable, b: Observable, c: string }
// 转换为
combineLatest(a, b).pipe(
    map(([v1, v2]) => { a: v1, b: v2, c })
)

这样的一个函数. 具体实现较为复杂, 有兴趣的读者可以去看看具体实现.

还有一种做法, 定义自己的基类, 然后让用户继承它而非React.Component. 这当然是不推荐的做法, 我相信读者应该充分多地听过"使用组合而非继承"这句话. 不过rxjs-react-component使用了这样的做法. 它给出了这样的约定: 组件的任何以$的方法, 都应当接受一个 Observable 作为输入, 可以返回一个 Observable 作为输出. 在运行时, 则在componentWillMount生命周期里处理这些方法, 其大致逻辑如下.

const observables = // 遍历原型上的所有以$结尾的方法
const stateChangers = Object.keys(observables).reduce((stateChangers, key) => {
  const subject = new Subject() // 为每个这样的方法生成一个 Subject
  if (key === 'componentDidMount$') {// 所有React生命周期方法
    this.componentDidMount = () => subject.next()
  }
  // 得到该方法输出的 Observable
  const stateChange$ = observables[key].call(this, subject, getState)
  return stateChangers.concat({
        key,
        cb() {subject.next(arguments[0])},
        stateChange$
      })
}

stateChangers.forEach((stateChanger) => {
  this[stateChanger.key] = stateChanger.cb; // 用cb替换所有方法, 作为事件回调
});

虽然这种做法存在大量的浪费, 而且这个库的实现非常简陋, 但还算比较符合直觉, 所以记录下来, 说不定能对读者有一些启发.

2. Redux

技术上来讲这也属于前一章的内容, 但是 Redux 在 React 生态圈里扮演了如此重要的角色, 以至于很多人都觉得这是 React 状态管理的事实标准. 而 Redux 本身所提供的功能又如此单薄, 所以围绕着 Redux 也有丰富的中间件生态. 毫无疑问, 其中的一些使用了 RxJS.

说到这里, 很多人可能已经想到了redux-observable, 这可能是 RxJS 最有名的 Redux 中间件了, 它在 Github 上获得了 7k star. 下文将简要介绍它的用法以及实现.

redux-observable 的基础组成是epic, 它是这样的函数:

function userEpic(action$: Observable<Action>, state$: Observable<State>)
: Observable<Action> {
  return action$.pipe(
    filter(action => act.type === 'FETCH_USER'), // 该插件提供的 ofType 简化这个行为
    mergeMap(action =>
      ajax.getJSON(`https://api.github.com/users/${action.payload}`),
    map(fetchUserFulfilled)
  )
}

换言之, 它接受两个 Observable, 它们的内容分别是 Redux 的 action 和 state, 返回一个 Observable, 它会根据前两者发出新的 action.

把这个 epic 传入 epicMiddleware, redux-observable 就会订阅它返回的流, 并 dispatch 这个流发出的 action. epicMiddleware 只接受一个 epic, 因此如果用户定义了多个 epic, 就需要把它结合成一个 rootEpic. 毫无疑问, 这可以用 RxJS 的merge完成, 当然也可以使用该插件自带的combineEpic. 在 rootEpic 里也可以定义一些诸如全局错误处理之类的操作.

redux-observable 的核心代码并不复杂, 都在createEpicMiddleware.ts里, 核心代码就是以下几行:

const result$ = epic$.pipe(
  map(epic => {
    const output$ = epic(action$, state$, options.dependencies!)
    return output$
  }),
  mergeMap(output$ => from(output$).pipe(
    subscribeOn(uniqueQueueScheduler), observeOn(uniqueQueueScheduler)
  ))
)

result$.subscribe(store.dispatch)

return next => {
  return action => {
    const result = next(action)

    stateSubject$.next(store.getState())
    actionSubject$.next(action)

    return result
  }
}

可以看到, 和 redux-thunk 不同, 该插件不会拦截 action, 而会在 redux 更新完状态之后把 action 和新 statet 发送给 epic, 在 epic 发射出 action 之后直接转交给 dispatch.

思路上类似的还有redux-cycles, 但是它引入了更多概念, 总之先来让我们看看怎么用它实现上面的例子:

function main(sources) {
  const request$ = sources.ACTION.pipe(
    filter(action => action.type === FETCH_USER),
    map(action => ({
      url: `https://api.github.com/users/${action.payload}`,
      category: 'users'
    }))
  )

  const action$ = sources.HTTP.select('users').pipe(mergeMap(fetchUserFulfilled))

  return {
    ACTION: action$,
    HTTP: request$
  }
}

可以看到主要效果是代码量变多了. 就像 cycle.js 本身一样, redux-cycles 的主要特色也是长期没人维护声明式的 side effect. 与上面例子里把发出请求 -> 处理数据 -> 发出 action 的整个过程传出去不一样, 这里是把发出请求和处理请求的两个流传给运行时.运行时在收到了 request$ 发出的事件后, 会再引起 action$ 里对 http 请求的处理. 这也就是"cycle"这个词的含义: 传出的流发出的事件, 能在 sources 里再取到, 形成一个闭环.

它的实现和 redux-observable 没有太大差别, 这里不展开讲了.

3. hook 时代

React 相比于其他框架(目前)而言最美妙的特性就是 hook, 那么能不能用 hook 的方式使用 RxJS 呢? 答案是显然的. 只需稍稍思考一下, 就能得到一个简陋的原型

function useRx<T>(ob$: Observable<T>) {
  const [state, setState] = useState()
  useEffect(() => {
    const sub = ob$.subscribe(s => setState(s))
    return () => sub.unsub()
  }, [ob$])
  return state
}

如果想要本地生成的 Observable, 那就把函数改成传入 Observable 工厂; 如果想要能发出事件, 就把参数改成Subject<T>然后再暴露一个e => ob$.next(e)函数. 这里没有传入初始值, 因此 state 可能是 null, 那就再加一个参数 initialState. 几分钟就能写出一整套 RxJS hook, 特别有成就感. 因此社区里使用 hook 实现的 RxJS 应用也非常多.

在这些实现中, 最有名的应该是rxjs-hooks. 它暴露了两个 API: useObservableuseEventCallback, 和我们之前幻想的 API 差不多. 前者接受一个 Observable 工厂, 后者则是专门用于处理事件的 Subject. 它们都支持像 useEffect 一样传入依赖.

考察它的源码, 可以发现实现并不复杂:

const [state, setState] = useState(typeof initialState !== 'undefined' ? initialState : null)

const state$ = useConstant(() => new BehaviorSubject<State | undefined>(initialState))
const inputs$ = useConstant(() => new BehaviorSubject<RestrictArray<Inputs> | undefined>(inputs))

useEffect(() => {
  inputs$.next(inputs)
}, inputs || [])

useEffect(() => {
  let output$: BehaviorSubject<State>
  if (inputs) {
    output$ = (inputFactory as (
      inputs$: Observable<RestrictArray<Inputs> | undefined>,
      state$: Observable<State | undefined>
    ) => Observable<State>)(inputs$, state$) as BehaviorSubject<State>
  } else {
    output$ = (inputFactory as (state$: Observable<State | undefined>) => Observable<State>)(
      state$
    ) as BehaviorSubject<State>
  }
  const subscription = output$.subscribe(value => {
    state$.next(value)
    setState(value)
  })
  return () => {
    subscription.unsubscribe()
    inputs$.complete()
    state$.complete()
  }
}, [])

return state

其中useConstant是使用useRef实现的仅运行一次工厂函数的 hook(useMemo 语义上不保证永远不重新求值). state$看起来没什么用, 但是可以用来在 Observable 中取当前 state 的值.

4. path to the dark side

如果你觉得 hook 方案的酷炫指数还不够高, 那还有条邪路可以走, 那就是@cycle/react-dom, 这样顺带也能完全实现将 React 作为 UI 运行时, 而且把你的前端项目转变为完全函数式, 完全响应式的代码. 顺带这个框架还支持 xstream 和 most.js, 可以让你想用什么用什么, 根本不用被局限在 RxJS 的小天地里.

那么代价呢? 那就是 cycle 这个框架非常缺乏维护, 而且设计有严重问题. 当然出于娱乐可以尝试使用这个框架, 不建议在任何情况的生产环境下使用.

5. 结论

对比前面所有方案, 毫无疑问, hook 方案实现最简单, 功能最强大, 而且从 plain js 迁移成本最低, 是这个问题的最优解. 如果你不想用现成的库, 自己手写一个也很方便, 当然记得要写测试.

© 2016 - 2023Austaras Devas