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)? 因为:
- Vue 是响应式的
- React 不是 Vue
- 所以 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: useObservable
和useEventCallback
, 和我们之前幻想的 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 迁移成本最低, 是这个问题的最优解. 如果你不想用现成的库, 自己手写一个也很方便, 当然记得要写测试.