我为什么从 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)。