Redux和Mobx:AK47和匕首

此前一直未从0开始写过Redux的工程,近日想简单对比下ReduxMobx的各自特点,于是动手撸了TodoList,感受了它们的不同。对比上看

  • Redux可靠规整,有一整套最佳实践,写大型应用时能避免很多坑
  • MobX轻便锋利,概念不多上手容易,在中小型应用中开发效率更高

Redux

Redux吸收了Flux和Elm的设计特点,正如它在Three Principles中写到的那样,唯一可信数据源,状态数据只读,状态改变为纯函数,这三大特点最大可能提升了可预测性,减少了调试的难度。同时,在概念上也易于理解。不过,复杂的设定和较多的代码入侵使得个人项目使用时稍显笨重,团队项目使用在改动已有代码时又会有牵一发动全身的感觉。

在数据流上,Redux规定action描述state的改变情况,reducer根据action定义state如何更新。

  • action,由type和payload部分组成,描述发生了什么变化,如{ type: 'ADD_TODO', text: 'Eat pie.' }
  • reducer,接受state和action作为入参,返回一个全新的state,正如文档里所说Redux assumes you never mutate your data

在这种设计理念下,我们借助redux创建state,之后所有的状态更新,都通过state.dispatch提交action完成,再借由connect等工具同步更新组件的props实现数据绑定的效果。除了设计理念外,Redux一些工程实践上的设计也值得一提

和React的互动

Redux并不是和React绑定了,但确实经常和React同时出现。react-redux是用来和react绑定的库。在结合了Redux后,React会有一些最佳工程实践

  • 区分开绘制组件和容器组件,前者只负责将数据转化为标签,后者负责接入数据,完成逻辑,组织绘制组件,并向其传递用户交互触发dispatch的函数。这样的层级设计将会增加解耦,减少后期修改时的工作量
  • 在最外侧调用createStore生成store并传入<Provider>组件,内部的容器组件,通过connectmapStateToPropsmapDispatchToProps就能便利地和store进行沟通,就像下面这样
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const mapStateToProps = (state, ownProps) => {
return {
active: state.filter === ownProps.filter
}
}
const mapDispatchToProps = (dispatch, ownProps) => {
return {
handleClick: () => {
dispatch(setFilter(ownProps.filter))
}
}
}
const FilterLink = connect(
mapStateToProps,
mapDispatchToProps
)(Link)

Action Creator

使用函数创建标准化的action,而不要把action直接写在dispatch内。就像下面这样

1
2
3
4
5
6
7
function addTodo(text) {
return {
type: ADD_TODO,
text
}
}
dispatch(addTodo(text))

这么做在action发生更改时,只需要修改定义函数的位置即可,简单方便。可以发现上面的action creator中,完成的工作只是简单的组装type和函数的入参在同一个action object里。当这样的函数很多时,还可以用action creator creator来帮我们一行生成这些类似的action creator。

1
2
3
4
5
6
7
8
9
10
11
12
13
const createActionCreator = (type, ...argNames) => {
return (...args) => {
let action = { type }
argNames.forEach((arg, index) => {
action[arg] = args[index]
})
return action
}
}
const addTodo = createActionCreator(ADD_TODO, 'text')
const setFilter = createActionCreator(SET_FILTER, 'filter')
const toggleTodo = createActionCreator(TOGGLE_TODO, 'id', 'to')

Split Reducer

除了上面讲到的Action Creator外,split reducer页很常见,它的应用场景出现在当state比较复杂时,可以针对state的每个field单独写reducer,然后通过Redux的combineReducers组合起来。

就像TodoList中,筛选条件filter和待办事项todos可以拆出两个reducer去更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const todos = (state = [], action) => {
switch (action.type) {
case ADD_TODO:
return [
...state,
{
id: action.id,
text: action.text,
status: 0
}
]
case TOGGLE_TODO:
return state.map(todo =>
(todo.id === action.id) ? {...todo, status: action.to || +!todo.status} : todo
)
default:
return state
}
}
const filter = (state = 'SHOW_ALL', action) => {
switch (action.type) {
case SET_FILTER:
return action.filter
default:
return state
}
}
const todoApp = combineReducers({
todos,
filter
})

Middleware和Async Action

首先,我们要明确一点,Redux中只有dispatch能改变store,然而默认情况下,dispatch只接受action。因此,当我们想异步修改store时,异步的逻辑只能写在组件里(Vuex里则可以通过action异步提交commit)。设想一下,假如一个fetchAPI的逻辑在多处都用到时,只能在这些地方重复书写这些代码。好在,Redux提供了middleware的概念,和Express中的中间件类似,不同的是Redux中中间件处理的是用户提交的dispatch请求而已。

文档里对中间件的介绍非常到位,鉴于我们对express以及co中类似概念的了解,我们直接从第4步看起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function logger(store) {
let next = store.dispatch
// Previously:
// store.dispatch = function dispatchAndLog(action) {
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
// Transform dispatch function with each middleware.
middlewares.forEach(middleware =>
store.dispatch = middleware(store)
)
}

可以看到,原理其实是类似的,关键点在于使用store的dispatch方法依次暂存上一个节点,这么做的好处是保证最终能抛出最后真正的dispatch,且能实现链式的效果。然而,使用dispatch显得还不自然,于是就有了下面的版本,每次将next主动传入,当让applyMiddleware里需要额外传入next。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}
// Warning: Naïve implementation!
// That's *not* Redux API.
function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
let dispatch = store.dispatch
middlewares.forEach(middleware =>
dispatch = middleware(store)(dispatch)
)
return Object.assign({}, store, { dispatch })
}

借助中间件的帮助,我们可以在dispatch前完成我们想要的操作:打log,catch错误,甚至提前终止流程。而异步dispatch正是借助了thunk中间件的帮助。它的实现很简单。

1
2
3
4
const thunk = store => next => action =>
typeof action === 'function'
? action(store.dispatch, store.getState)
: next(action)

在我们通过applyMiddleware注入到store中后,就可以在dispatch中写入函数了!就像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fetchPosts(subreddit) {
return dispatch => {
dispatch(requestPosts(subreddit))
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(subreddit, json)))
}
}
function fetchPostsIfNeeded(subreddit) {
return (dispatch, getState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit))
}
}
}

在使用时正常dispatch即可。

1
dispatch(fetchPostsIfNeeded(selectedSubreddit))

其他常用的store API

  • getState() 获取当前的state状态
  • subscribe(listener) 在每次更改state时触发
  • createStore() 根据输入的reducer,initState,middleware等生成store

脚手架

官网在介绍时列举了许多依赖库,却并没有给出示例的脚手架。没有舒服的脚手架,效率和工作热情都会受影响。这里介绍一种使用create-react-app快速搭建Redux脚手架的过程,下面的Mobx类似。

项目依赖大概有下面这些

但其实create-react-app可以帮你搭好其中包括react、react-dom、eslint、babel、webpack、postcss等绝大多数依赖环境,且完成配置。剩下的react-redux和react-router手动安装即可。

create-react-app类似于vue-cli,创建的默认配置不满意时,还可以npm run eject将默认配置撤销成用户配置,交给用户自己配置。

MobX

对比Redux,诞生于2015年3月的MobX在概念上吸收Vue,Knockout等MVVM框架要更多一些1,号称是TFRP(Transparent Functional Reactive Programming)。Transparent在依赖项的更新是隐式完成的,Functional在Computed中的使用,Reactive就不说了。MobX原名Mobservable,而后改名为MobX,官方并未说明如何发音,姑且读作moʊ-bex。和Redux对比来看,巧合的是MobX的设计理念也可以分为三部分:

  • 将所有的状态抽出来,用observable修饰
  • 描述出状态到视图的映射关系,这个过程因框架而异,但是一般React多一些,使用observer修饰
  • 在要修改状态的位置使用action包裹(强烈建议你这么做)

不过还有很核心的一点,文档里并没有提到

  • 把所有state修改的副作用放在autorun/reaction/when体内,在必要时在体内继续使用action

哇,是不是简洁了很多。你对observable对象做的所有修改(不论有没有用action包裹)都会自动反映在视图中,在项目结构上,你完全可以根据自己需要组织。不过,没有Redux里transaction的概念,MobX中对状态的修改在时间上都是不可回溯的。同时,没有中间件的概念,意味着在状态比较复杂时,可维护性就会下降。

整个MobX的关键API主要是由下面几部分组成的

  • observable 创建被依赖项,在设计中即state
  • computed 被依赖项的计算值,和Vue中的computed属性一致
  • action 动作,用来修改state,显式的使用可以使逻辑更清楚,当然不在action里修改observable也是允许的
  • observer和autorun/reaction/when,前者是derivation即根据state衍生出的结果,后者是reaction即state变化会触发的副作用(如IO等)

与React的结合

MobX和React相结合的方式就自由了很多。大体上使用components存储组件,stores描述状态。

stores描述状态时,

  • 通过@observable描述需要响应变化的状态变量,同时尽量将所有改变状态变量的操作封装成整个class的方法(并不强制),便于管理。
  • 通过@computed声明能够直接根据当前状态变量得到的衍生值。有意思的是,MobX只在observable变化时更新这个值,而不是在用户需要时去计算,从而节省了许多时间
  • 通过autorun()reaction()when()声明式地定义状态改变时的side effect,它们的执行结果都会返回一个dispose函数,在它们的生命周期结束后方便显式垃圾回收。它们三个都是除了用户操作外几乎唯一能进行副作用操作的地方,其中
    • autorun()在定义时就会被执行一次
    • reaction()仅在变化时执行,且对函数内部改变状态不敏感
    • when()则是在变化时执行一次即失效
  • 通过@action描述对状态做修改的行为,推荐使用,修饰在方法名前,开启strict模式后,则是强制要求使用

上面这些关键字都有修饰词(如:@action.bound)还有对应的ES5语法。(如:@observable key = value等同于extendObservable(this, { key: value }))。MobX并不要求使用单一的状态树,可以用多个文字组织你的状态。其中的一个store文件可能像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class TodoList {
id = 0
@observable todos = []
@computed get activeTodoCount() {
return this.todos.filter(todo => todo.status === 0).length
}
saveToStorage() {
// 第一次不会触发
reaction(
() => this.toJS(),
todos => localStorage.setItem('todo-mobx', JSON.stringify({ todos }))
)
}
addTodo(title) {
this.id++;
this.todos.push(new TodoItem({id: this.id, title, status: 0}))
}
toggleAll(status) {
this.todos.forEach(todo => todo.status = +status)
}
delete(id) {
let todoId = this.todos.findIndex(todo => todo.id === id)
if (todoId !== -1) {
this.todos.splice(todoId, 1)
}
}
clearCompleted() {
this.todos = this.todos.filter(todo => todo.status === 0)
}
toJS() {
return this.todos.map(todo => todo.toJS())
}
static fromJS(arr) {
const todoStore = new TodoList();
todoStore.todos = arr.map(todo => TodoItem.from(todo))
return todoStore
}
}

components文件夹下做的事和使用其他框架其实差别不大。区别主要在引入observer后,用@observer装饰组件类。需要修改状态时,可以直接对props中传入的store进行
修改(不过还是建议使用store中定义好的方法修改),视图就会同步更新,副作用也会同步完成。一个使用了mobx-react的组件大概像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@observer
class TodoList extends Component {
render() {
const { todoStore, filterStore } = this.props
if (!todoStore.todos.length) {
return null
}
return (
<section>
<input
type="checkbox"
onChange={this.toggleAll}
checked={todoStore.activeTodoCount === 0}
/>
<ul>
{this.getVisibleTodos().map(todo => (
<TodoItem
key={todo.id}
todo={todo}
filterStore={filterStore}
handleDestroy={this.handleDestroy}
/>
))}
</ul>
</section>
)
}
getVisibleTodos() {
const { todoStore, filterStore } = this.props
return todoStore.todos.filter(todo => {
switch (filterStore.filter) {
case ACTIVE:
return todo.status === 0
case COMPLETED:
return todo.status === 1
case REMOVED:
return todo.status === 2
default:
return true
}
})
}
toggleAll = (e) => {
let checked = e.target.checked;
this.props.todoStore.toggleAll(checked !== 0)
}
handleDestroy = (id) => {
this.props.todoStore.delete(id)
}
}

除了直接使用store外,mobx-react还提供了将observable对象通过<Provider>inject2传入组件的方式。其他的用法可以参看文档,还有中文版翻译

脚手架

除了Redux里面提到的,因为MobX中用到了最新的decorator特性,.babelrc配置文件大概是下面这样

1
2
3
4
5
6
7
8
{
"presets": [
"react",
"es2015",
"stage-1"
],
"plugins": ["transform-decorators-legacy", "react-hot-loader/babel"]
}

package.json中需要额外引入”mobx”和”mobx-react”两个库(至少)。官方还提供了mobx-react-boilerplate,这些环境都已帮你配置好,按照README.md操作即可。另外,官方提供的awesome list是一个非常好学习mobx的地方

参考

  1. https://github.com/mobxjs/mobx#credits
  2. https://github.com/mobxjs/mobx-react#provider-and-inject