我为什么从Redux迁移到了Mobx

Redux是一个数据管理层,被广泛用于管理复杂应用的数据。但是实际使用中,Redux的表现差强人意,可以说是不好用。而同时,社区也出现了一些数据管理的方案,Mobx就是其中之一。

Redux的问题

Predictable state container for JavaScript apps

这是Redux给自己的定位,但是这其中存在很多问题。 首先,Redux做了什么?看Redux的源码,createStore只有一个函数,返回4个闭包。dispatch只做了一件事,调用reducer然后调用subscribe的listener,这其中state的不可变或者是可变全部由使用者来控制,Redux并不知道state有没有发生变化,更不知道state具体哪里发生了变化。所以,如果view层需要知道哪一部分需要更新,只能通过脏检查。

再看react-redux做了什么,在store.subscribe上挂回调,每次发生subscribe就调用connect传进去mapStateToPropsmapDispatchToProps,然后脏检测props的每一项。当然,我们可以利用不可变数据的特点,去减少prop的数量从而减少脏检测的次数,但是哪有props都来自同一个子树这么好的事呢?

所以,如果有n个组件connect,每当dispatch一个action的时候,无论做了什么粒度的更新,都会发生O(n)时间复杂度的脏检测。

// Redux 3.7.2 createStore.js

// ...
    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    const listeners = currentListeners = nextListeners
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }
// ...

更糟糕的是,每次reducer执行完Redux就直接调用listener了,如果在短时间内发生了多次修改(例如用户输入),不可变的开销,加上redux用字符串匹配action的开销,脏检测的开销,再加上view层的开销,整个性能表现会非常糟糕,即使在用户输入的时候往往只需要更新一个"input"。应用规模越大,性能表现越糟糕。(这里的应用指单个页面。这里的单页不是SPA的单页的意思,因为有Router的情况下,被切走的页面其所有组件都被unmount了)

在应用规模增大的同时,异步请求数量一多,Redux所宣传的Predictable也根本就是泡影,更多的时候是配合各种工具沦为数据可视化工具。

Mobx

Mobx可以说是众多数据方案中最完善的一个了。Mobx本身独立,不与任何view层框架互相依赖,因此你可以随意选择合适的view层框架(部分除外,例如Vue,因为它们的原理是一样的)。

目前Mobx(3.x)和Vue(2.x)采用了相同的响应式原理,借用Vue文档的一张图:
为每个组件创建一个Watcher,在数据的getter和setter上加钩子,当组件渲染的时候(例如,调用render方法)会触发getter,然后把这个组件对应的Watcher添加到getter相关的数据的依赖中(例如,一个Set)。当setter被触发时,就能知道数据发生了变化,然后同时对应的Watcher去重绘组件。

这样,每个组件所需要的数据时精确可知的,因此当数据发生变化时,可以精确地知道哪些组件需要被重绘,数据变化时重绘的过程是O(1)的时间复杂度。

需要注意的是,在Mobx中,需要把数据声明为observable。

import React from 'react';  
import ReactDOM from 'react-dom';  
import { observable, action } from 'mobx';  
import { Provider, observer, inject } from 'mobx-react';

class CounterModel {  
    @observable
    count = 0

    @action
    increase = () => {
        this.count += 1;
    }
}

const counter = new CounterModel();

@inject('counter') @observer
class App extends React.Component {  
    render() {
        const { count, increase } = this.props.counter;

        return (
            <div>
                <span>{count}</span>
                <button onClick={increase}>increase</button>
            </div>
        )
    }
}

ReactDOM.render(  
    <Provider counter={counter}>
        <App />
    </Provider>
);

性能

在这篇文章中,作者使用了一个一个128*128的绘图板来说明问题。 由于Mobx利用gettersetter(未来可能会出现一个平行的基于Proxy的版本)去收集组件实例的数据依赖关系,因此每单当一个点发生更新的时候,Mobx知道哪些组件需要被更新,决定哪个组件更新的过程的时间复杂度是O(1)的,而Redux通过脏检查每一个connect的组件去得到哪些组件需要更新,有n个组件connect这个过程的时间复杂度就是O(n),最终反映到Perf工具上就是JavaScript的执行耗时。

虽然在经过一系列优化后,Redux的版本可以获得不输Mobx版本的性能,当时Mobx不用任何优化就可以得到不错的性能。而Redux最完美的优化是为每一个点建立单独的store,这与Mobx等一众精确定位数据依赖的方案在思想上是相同的。

Mobx State Tree

Mobx并不完美。Mobx不要求数据在一颗树上,因此对Mobx进行数据可是化或者是记录每次的数据变化变得不太容易。在Mobx的基础上,Mobx State Tree诞生了。同Redux一样,Mobx State Tree要求数据在一颗树上,这样对数据进行可视化和追踪就变得非常容易,对开发来说是福音。同时Mobx State Tree非常容易得到准确的TypeScript类型定义,这一点Redux不容易做到。同时还提供了运行时的类型安全检查。

import React from 'react';  
import ReactDOM from 'react-dom';  
import { types } from 'mobx-state-tree';  
import { Provider, observer, inject } from 'mobx-react';

const CountModel = types.model('CountModel', {  
    count: types.number
}).actions(self => ({
    increase() {
        self.count += 1;
    }
}));

const store = CountModel.create({  
    count: 0
});

@inject(({ store }) => ({ count: store.count, increase: store.increase }))
class App extends React.Component {  
    render() {
        const { count, increase } = this.props;

        return (
            <div>
                <span>{count}</span>
                <button onClick={increase}>increase</button>
            </div>
        )
    }
}

ReactDOM.render(  
    <Provider store={store}>
        <App />
    </Provider>
);

Mobx State Tree还提供了snapshot的功能,因此虽然MST本身的数据可变,依然能打到不可变的数据的效果。官方提供了利用snaptshot直接结合Redux的开发工具使用,方便开发;同时官方还提供了把MST的数据作为一个Redux的store来使用;当然,利用snapshot也可以MST嵌在Redux的store中作为数据(类似在Redux中很流行的Immutable.js的作用)。

// 连接Redux的开发工具
// ...
connectReduxDevtools(require("remotedev"), store);  
// ...

// 直接作为一个Redux store使用
// ...
import { Provider, connect } from 'react-redux';

const store = asReduxStore(store);

@connect(// ...)
function SomeComponent() {  
    return <span>Some Component</span>
}

ReactDOM.render(  
    <Provider store={store}>
        <App />
    <Provider />,
    document.getElementById('foo')
);

// ...

并且,在MST中,可变数据和不可变的数据(snapshot)可以互相转化,你可以随时把snapshot应用到数据上。

applySnapshot(counter, {  
    count: 12345
});

除此之外,官方还提供了异步action的支持。由于JavaScript的限制,异步操作难以被追踪,即时使用了async函数,其执行过程中也是不能被追踪的,就会出现虽然在async的函数内操作了数据,这个async函数也被标记为action,但是会被误判是在action外修改了数据。以往异步action只能通过多个action组合使用来完成,而Vue则是通过把action和mutation分开来实现。在Mobx State Tree利用了Generator,使异步操作可以在一个action函数内完成并且可以被追踪。

// ...

SomeModel.actions(self => ({  
    someAsyncAction: process(function* () {
        const a = 1;
        const b = yield foo(a); // foo必须返回一个Promise
        self.bar = b;
    })
}));

// ...

总结

Mobx利用gettersetter来收集组件的数据依赖关系,从而在数据发生变化的时候精确知道哪些组件需要重绘,在界面的规模变大的时候,往往会有很多细粒度更新,虽然响应式设计会有额外的开销,在界面规模大的时候,这种开销是远比对每一个组件做脏检查小的,因此在这种情况下Mobx会很容易得到比Redux更好的性能。而在数据全部发生改变时,基于脏检查的实现会比Mobx这类响应式有更好的性能,但这类情况很少。同时,有些benchmark并不是最佳实践,其结果也不能反映真实的情况。

但是,由于React本身提供了利用不可变数据结构来减少无用渲染的机制(例如PureComponent,函数式组件),同时,React的一些生态和Immutable绑定了(例如Draft.js),因此在配合可变的观察者模式的数据结构时并不是那么舒服。所以,在遇到性能问题之前,建议还是使用Redux和Immutable.js搭配React。

The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.

一些实践

由于JavaScript的限制,一些对象不是原生的对象,其他的类型检查库可能会导致意想不到的结果。例如在Mobx中,数组并不是一个Array,而是一个类Array的对象,这是为了能监听到数据下标的赋值。相对的,在Vue中数组是一个Array,但是数组下标赋值要使用splice来进行,否则无法被检测到。

由于Mobx的原理,要做到精确的按需更新,就要在正确的地方触发getter,最简单的办法就是render要用到的数据只在render里解构。mobx-react从4.0开始,inject接受的map函数中的结构也会被追踪,因此可以直接用类似react-redux的写法。注意,在4.0之前inject的map函数不会被追踪。

响应式有额外的开销,这些开销在渲染大量数据时会对性能有影响(例如:长列表),因此要合理搭配使用observable.refobservable.shallow(Mobx),types.frozen(Mobx State Tree)。

欢迎关注我们的公众号