受网友指点,要写一篇关于 React 的全局状态管理器的对比文章,从而说明 react-immut 的优势和不足。为此,我再次调查和研究了相关的状态管理器,并打算以我厂钟正楷大佬的这篇文章作为蓝本,对我所观察到的不同 react 全局状态管理器进行横向对比。本次对比的指标主要包含:
- 开发体验方面
- store 配置/model 定义
- 状态使用方式
- 状态修改方式
- 缓存衍生计算
- 最小化更新
- 目录结构(索引)
- 运行方面
- 性能
参与对比的主要包含如下几个:
- immutable 典型代表 redux (react-redux)
- mutable 典型代表 mobx (mobx-react)
- 纯 hooks 实现,例如 constate, unstate-next,此次挑选 constate
- Redux 包装,例如 Dva, rematch,此次挑选 rematch
- 先定义 model 再以 hooks 消费,例如 overmind, overstated,此次挑选 overstated
- 自家产品 react-immut
下面我们开始进行对比吧。
配置/定义方式
简单的说,基本上大部分全局状态管理器都依赖一个初始的配置/定义,来明确要管理的状态的初始结构。
redux
function reducer(state = { name: null }, action) { switch(action.type) { case 'CHANGE_NAME': return { ...state, name: action.data } } } const store = createStore(reducer) <Provider store={store}> ... </Provider>
reducer 是一个纯函数,接收前一个 state,返回下一个 state。其中 reducer 支持 combine 操作来进行分区(分区概念是指将一个庞大的 state 按照功能切分不同块来进行管理)。
mobx
class Store { @observable name = null @action.bound setName(name) { this.name = name } } const store = new Store() <Provider store={store}> ... </Provider>
单纯从这段代码看,mobx 和 redux 的最大不同在于如何定义 store 部分。redux 基于 reducer,而 mobx 基于观察者模式,两者在思想层面完全是两回事。
constate
function defineStore() { const [state, setState] = useState({ name: null }) const setName = name => setState(state => { return { ...state, name } }) return { state, setName } } const [Provider, useStore] = constate(defineStore) <Provider> ... </Provider>
和前面定义 store 的方式不同,而且 constate 本身是没有 store 概念的,只是我为了便于理解,把名字命名为 store 而已。它通过一个 constate 函数,生成一个 Provider 和一个 useStore hook 函数。它完全基于 hooks,没有自己的 store。
rematch
Rematch 是在 redux 上面封装了一层,本质上还是 redux。封装的一层对于我们而言,最主要是对前文所说的 reducer 分区进行了调整,让 action 的功能分开,不需要写在一个函数内部。同时,它增加了 action 的扩展,支持异步操作。
const some = { state: { name: null, }, reducers: { setName(state, name) { return { ...state, name } }, }, effects: { async updateName(name) { // 默认支持异步 this.setName(name) }, }, } const store = init({ models: { some }, }) <Provider store={store}> ... </Provider>
它的大前提,就是要求开发者在 reducer 基础上进行分区,传不同分区给 models 这个参数。另一个好处是,不需要自己去配置,自动默认支持异步操作。但是,本质上,和 reducer 那套没有任何区别,而且在代码量上,几乎没有任何优势,甚至感觉要写很多重复代码。
overstated
class Some extends Store { state = { name: null } setName(name) { return this.setState(state => { return { ...state, name } }) } } <Provider> ... </Provider>
和前面所有的实现方案不同 overstated 是内部自己保管了一个共享的全局 store。真正在用的时候,需要把上面定义好这个 store 传入到 hooks 中使用,例如:
function MyComponent() { const { name, setName } = useStore(Some, store => { return { name: store.state.name, setName: store.setName } }) ... }
后面这个 store 是 Some 的实例对象。用的时候才传入 Some,而非直接提供给 Provider。但是我比较困惑的是,它为啥要提供一个 Provider 呢?感觉没啥意义啊。
react-immut
const store = createStore({ name: '', }) <Provider store={store}> ... </Provider>
用法上保持和 react-redux 一致,仅仅是在定义 store 的时候,只需要传入初始状态就行了,但是为了分区(上面代码写法),传入第二个参数更简单,比 rematch 那种省了 10 倍效率。不需要写大堆 reducer 代码。
小结
配置/定义 | redux(react-redux) | mobx(mobx-react) | constate | rematch | overstated | react-immut |
定义方式 | reducer function | store class | hooks function | model object | store class | object |
支持分区 | ❌ | ✅ | ❌ | ✅ | ❓ | ✅ |
支持无根 Provider | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
无 this | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
overstated 将传入的 Store 类或实例作为索引,两个不同的 useStore 但却根据传入的参数,引用相同的一个 store。这种方式,我也不知道算不算是一种分区的方式,仁者见仁吧。
状态使用方式
组件通过什么方式才能读取到全局状态?不同管理器还还是有一点区别。
Redux & rematch
React-redux 是 redux 连接组件的工具,由于 rematch 实际上只是对 redux reducer 逻辑的封装,所以也是用 react-redux 来做连接器。
const mapStateToProps = (state) => { return { ...state } } export default connect(mapStateToProps)(MyComponent)
完全通过将 state map 到 props 的方式,将 state 传入组件后使用。
Mobx
@observer class MyComponent extends React.Component { render() { const { state } = this.props.store } }
本质上,@observer 和 connect 功能一致,是组件的包装器,它会自动注入 store 到 props 上。Mobx-react 还可以通过 @inject 来将 Provider 提供的状态属性注入到组件中。@inject 接收的内容和 map 效果一致。
constate
function MyComponent() { const { state } = useStore() // 这个 useStore 是 constate 函数生成的,需要引入 }
useStore 得到的结果完全由 defineStore 的返回值决定,感觉非常直接。
overstated
function MyComponent() { const { name } = useStore(Some, store => { return { name: store.state.name } }) }
读取到 store 上的什么内容,完全靠第二个参数的返回值决定。这里 Some 这个 class 一方面是作为实例化出 store 的依据,另一方面也是取出对应 store 的依据。
react-immut
function MyComponent() { const [state] = useStore() }
虽然 react-immut 也提供 connect 方案,但是使用 hooks 更方便,通过 useStore 直接读取整个 store 的 state 和 dispatch。通过传入 keyPath 可以缩小作用域。
function MyComponent() { const [bookName] = useStore('books[0].name') }
小结
状态读取方式 | redux(react-redux) | mobx(mobx-react) | constate | rematch | overstated | react-immut |
推荐连接方式 | connect | @observe | hooks | - | hooks | hooks |
使用时无需 map | ❌ | ✅ | ✅ | - | ❌ | ✅ |
便捷支持 hooks | ✅ | ❌ | ✅ | - | ✅ | ✅ |
便捷获取子属性 | ❌ | ✅ | ❌ | - | ❌ | ✅ |
react-redux 提供了 useSelector 这个 hook 函数,不仅可以在里面直接 map state,而且还借鉴了 reslect 的思想实现缓存。
状态修改方式
怎么才能修改到全局状态呢?可不可以只修改局部的状态,或者可否快速更新嵌套的属性?经过我观察,更新状态的方式是对我们开发体验最明显的地方。特别是异步操作,不同状态管理器给人的感受可能存在天壤之别。
Redux
connect 会自动将 dispatch 方法加入 props 给组件使用。组件从 props 上读取来自 connect map 的内容。最常用的异步处理,是使用中间件 redux-thunk。这个中间件允许 dispatch 接收一个函数。
function createAction(type) { return (dispatch, getState) => { fetch().then((data) => { dispatch({ type, data }) }) } } this.props.dispatch(createAction('UPDATE_NAME'))
这个 createAction 函数被称为 action creator,用于生成一个被 dispatch 接收的函数,而生成的函数拥有 dispatch 和 getState 两个参数函数,在该函数中,可以执行异步操作,并且使用 getState 来获取整个 state。
Mobx
在 store 中,修改全局状态极其简单,直接修改对应的属性即可,它是 mutable 的。
class Store { @observable name = null setName(name) { this.name = name // 直接修改属性 } }
由于可直接修改属性,所以异步操作变得很简单,异步请求完,将新的数据赋值给对应的属性即可。
constate
直接使用创建的 hook 函数返回值进行更新。由于完全基于 hooks,所以异步操作都是走 hooks 的异步方式。
rematch
const Some = { state: { name: null } reducers: { setName(state, name) { return { ...state, name } }, }, effects: { async fetchName() { const { name } = await fetch() this.setName(name) }, }, }
Rematch 自动加入了 map dispatch 的逻辑:
const mapDispatchToProps = (dispatchers) => { const { Some } = dispatchers const { fetchName } = Some // 把 effects.fetchName 读取出来给 props return { fetchName } }
但总体而言,还是 react-redux 那一套。
overstated
由于在 overstated 中需要事先定义 store,而在定义时需要调用 this.setState 来更新状态,所以异步操作也自然而言不需要复杂逻辑。
class Some extends Store { state = { name: null, } async fetchName() { fetch().(({ name }) => { this.setState({ name }) }) } }
react-immut
react-immut 提供了多种 dispatch 的方式,最容易理解的就是通过 key->value 的形式更新状态。
dispatch('key', value)
简单直观,但是不适合批量更新。而依赖 immer 的能力,批量更新也很方便
dispatch(state => { state.name = 'name' state.age = 10 state.books = [] })
由于 react-immut 也提供分区方式载入到 store 中,异步能力也提前考虑了。
export const state = { name: '', } export const updateName = async (dispatch) => () => { const data = await fetch() dispatch('name', data.name) }
小结
状态修改方式 | redux(react-redux) | mobx(mobx-react) | constate | rematch | overstated | react-immut |
基于不可变原则 | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
原子(子属性)更新 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
便捷嵌套批量更新 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
便捷 map dispatch | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
key->value 模式 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
原生异步支持 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
无 this | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
缓存衍生计算
读取 state 时,我们往往需要通过一番计算之后,得到新值,再作为 props 传给组件。再 vue 中,我们熟知 computed property,同样,在 mobx 中,@computed 轻松实现计算缓存,而在 redux 生态中,reselect 也可以对计算进行缓存,不过开发体验上,就输很多。由于 reselect 是独立库,所以,也可以用在 react-immut 中,不过,实际上,react 的 memo, useMemo 可以替代 reselect 方法。
缓存衍生计算 | redux(reselect) | mobx(mobx-react) | constate | rematch | overstated | react-immut |
支持缓存衍生计算结果 | ❓ | ✅ | ❌ | - | ❌ | ❓ |
计算属性 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
无 this | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
最小化渲染
所谓最小化渲染,是指不要 state 有一点点变动,都触发所有被 connect/observe 的组件进行更新,应该在只有被依赖的 state 节点变化时,才进行更新(依赖收集)。目前能够完全做到这一点的,仅有 mobx 能做到。不过在 react-immut 中,也能在无根 Provider 模式下,使用 useStore(keyPath) 部分支持。
Mobx
class Some { @observable name = 'tom' @observable age = 10 }
@inject('store') @observer class MyComponent { render() { const { name } = this.props.store // 只用到 name,所以只有在 name 发生变化的时候,才会触发重新渲染 } }
react-immut
init({ name: 'tomy', }) function MyComponent() { const [name] = useStore('name') // 在没有使用 Provider 的情况下,只有 name 发生变化,这个组件才会重新渲染 }
小结
最小化渲染 | redux(redux-react) | mobx(mobx-react) | constate | rematch | overstated | react-immut |
仅指定属性变化时再渲染 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
无 this | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
目录结构
我们主要想讨论下,哪个状态管理器能够以更清晰的目录结构来管理模块状态。
Redux
- App.js - store.js - components/some-component/ - store/ - index.js - reducers.js - actions.js - some.jsx
Mobx
- App.js - store.js - components/some-component/ - store.class.js - some.jsx
constate
- App.js - components/some-component/ - store/ - a.js - b.js - some.jsx
Rematch
- App.js - store.js - components/some-component/ - model.js - some.jsx
overstated
- App.js - components/some-component/ - store/ - a.class.js - b.class.js - some.jsx
react-immut
- App.js - components/some-component/ - store/ - namespace-a.js - namespace-b.js - some.jsx
小结
在所有管理器中,redux 是最复杂的。mobx, rematch 需要通过一个全局的 store.js 来将所有涉及的 stores 收拢到一个文件中,这样才能为 Provider 提供 store。而 constate, overstated, react-immut 无须收拢所有 store,在组件文件夹内定义 store 之后,直接引入使用。其中 constate 虽然可以单独为组件提供 store,但是要复用它的 store,必须导出 Provider,将 Provider 放在顶层,所以本质上,如果需要根 Provider,还是要收拢所有 store。而 overstated 和 react-immut 内置了全局 store,因此,只要在组件文件夹内自己管理自己相关的 store namespace,就可以完成开发,不需要再到外部进行引入,虽然 overstated 也需要根 Provider,但是只要定义一处,内部不再需要任何操作,而且也不需要给 Provider 传 store。react-immut 连 Provider 都不需要,是所有管理器里面最简洁的。
目录结构 | redux(redux-react) | mobx(mobx-react) | constate | rematch | overstated | react-immut |
简洁性评分 | 0 | 0.5 | 4 | 3 | 5 | 5 |
无 this | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
性能
性能测试非常麻烦,因为不同管理器的编程方式不同,没有办法找到一个可以作为标准的方式衡量。但是为了进行对比,我姑且平衡下面的因素来做对比:
- 实现相同效果:一个渲染10000行的table组件的实现
- 最优化:对所有管理器进行尽可能最高程度的优化,使它们都发挥自己最大的优势
评价指标:
- 第一次渲染时间
- 更新2条不连续的记录渲染时间
评价方法:
- 编写相同的基础组件,所有管理器都使用这些组件
- 通过 chrome performance 记录结果
- 每个管理器测试 5 次,去除最高最低,取最优值
限制:
- 由于条件限制,我只做 redux, mobx 和 react-immut 的对比
用于对比的代码可以在这里阅读。
Redux
基于 redux + react-redux 的架构,在渲染 10000 行数据时,平均耗时 2295ms,更新用时平均 340ms 左右。
黄色部分是 script 执行时间,该次截图消耗 1281ms。
该次截图是更新时消耗,脚本部分用时 285ms。而真正执行 update 函数(修改数据)仅 1ms。
Mobx
基于 mobx + mobx-react 的架构,第一次渲染耗时 1996ms,更新用时 430ms 左右。
该次截图中,黄色脚本执行部分耗时 1181ms。
该次截图更新脚本耗时 374ms。update 函数消耗 30ms。
ReactImmut
基于 react-immut 来实现,第一次渲染耗时 1850ms,更新用时 388ms。
本次截图脚本耗时 1049ms。
本次截图脚本用时 325ms,真正的 update 函数耗时 6ms。
小结
在首次渲染 1000 行数据的整体耗时上,三个状态管理器并没有太大的差别,都是在一个量级,而且 layout 部分消耗时间也很多。真正的差别在更新阶段,单纯看 update 函数,也就是执行数据修改,得到新数据这个过程,redux 系虽然需要写更复杂的对象解构代码,却是最快的,仅用了 1ms,因为只产生新对象,而不会对原有对象进行修改。mobx 消耗 30ms,差距比较大,因为 mobx 基于 observable 实现,内部需要处理复杂的关系。而 react-immut 基于 immer 实现,而 immer 基于 Proxy 实现,理论上应该比 mobx 基于 Object.defineProperty 实现更消耗性能,但由于在嵌套对象拦截时处理的更好,所以,反而比 mobx 更快。在整体更新性能上,由于实际上还是把整个 1000 行数据重新遍历一边,所以也没有突出的性能差别。
最终结论
本文主要从开发体验的角度,对比了 redux(react-redux)、mobx(mobx-react)、constate、rematch、overstated、react-immut 在各个方面的开发体验,最后由于条件所限,只对比了 redux(react-redux)、mobx(mobx-react)、react-immut 的部分性能,性能对比不算严格,但能大致了解它们之间的差别。
做这个对比,主要还是想和读者探讨 react 全局状态管理器的一些特质。在我撰写 react-immut 的过程中,我最初只提供了非常单一的能力,代码只有 150 行,但是随着自己对某些场景和能力的认知,最终代码也到达了 250 行。你可以在这里阅读它的源码,如果有你自己的想法,也不妨在本文下方给我留言,一起探讨。
2020-09-13 7378