Skip to main content

MobX

Free2017-11-19#JS#MobX与Redux#MobX入门指南#MobX tutorial#MobX源码分析#MobX怎么读

Compared to Redux and Vuex, what advantages does MobX have?

1. Target Positioning

Simple, scalable state management

A simple, sufficient state management library. Still wants to solve the application state (data) management problem

2. Design Philosophy

Anything that can be derived from the application state, should be derived. Automatically.

Everything derived from application state should be automatically obtained. Such as UI, data serialization, service communication

That is, as long as we know which things are state-related (derived from application state), when state changes, all state-related things should be automatically completed, automatically update UI, automatically cache data, automatically notify server

This philosophy seems novel, actually it's data-driven. Thinking about it, React ecosystem (react + react-redux + redux + redux-saga) also satisfies this philosophy, after state changes (dispatch action triggers stateChange), UI automatically updates (Container update), automatically triggers cached data, notifies server and other side effects (saga)

3. Core Implementation

MobX is inspired by reactive programming principles as found in spreadsheets. It is inspired by MVVM frameworks like in MeteorJS tracker, knockout and Vue.js. But MobX brings Transparent Functional Reactive Programming to the next level and provides a stand alone implementation. It implements TFRP in a glitch-free, synchronous, predictable and efficient manner.

Referenced MeteorJS's tracker, knockout and Vue, these things' common characteristic is they all have built-in data binding, belong to so-called MVVM architecture, respectively borrowed:

  • MeteorJS's design philosophy: automatically track dependencies (tracker, autorun etc.), no need to declare dependencies anymore, makes many things simpler

  • knockout's data binding: ko.observable

  • Vue's runtime dependency collection and computed: implemented based on getter&setter data binding

So, MobX's core implementation is very similar to Vue, can be viewed as taking Vue's data binding mechanism out separately, then doing enhancement and extension:

  • Enhancement: observable not only supports Array, Object, also supports Map and immutable Value (corresponding to boxed value)

  • Extension: provides observer (expose data changes), spy (expose internal state), action (standard constraints, or to accommodate Flux)

P.S. From functionality perspective, having observable and observer can guarantee usability. action is constraint on flexibility, spy is for DevTools integration, neither is important

Additionally, MobX also uses ES Decorator syntax to let listening to changes combine with OOP, looks quite elegant, for example:

import { observable, computed } from "mobx";

class OrderLine {
    @observable price = 0;
    @observable amount = 1;

    @computed get total() {
        return this.price * this.amount;
    }
}

Without this class annotation syntax, it's not beautiful at all:

var OrderLine = function() {
    extendObservable(this, {
        price: observable.ref(0),
        amount: observable.ref(1),
        total: computed(function() {
            return this.price * this.amount;
        })
    });
}

Using this feels much more troublesome, far less elegant than annotation form. Using Decorator to combine observable and OOP system,算是 MobX's major highlight

P.S. Decorator feature is currently still in new proposal stage, belongs to very unstable feature, therefore mostly only use general form:

function myDecorator(target, property, descriptor){}

From babel transformation result, it's interception of Object.defineProperty (so Decorator method signature is completely consistent with Object.defineProperty)

P.S. Actually Vue ecosystem also has similar things combining with OOP, such as vuejs/vue-class-component

4. Structure

       modify        update           trigger
action ------> state ------> computed -------> reaction

Compare Flux

Kept Flux's action, added a computed layer, proposed reaction concept

The action here is much thicker than Flux's action concept, equivalent to action + dispatcher + part in store responsible for responding to action to modify state, in short, MobX's action is verb, Flux's action is noun. MobX's action is an action, directly modifies state, Flux's action is just an event message, modified by event receiver (part in store responsible for responding to action to modify state)

computed has same meaning as Vue's computed, both refer to derived data depending on state (data that can be calculated from state), after state changes, automatically recalculate computed. Additionally, computed is called derivation conceptually, that is "derived", because computed depends on state, is data derived from state

reaction refers to responses made to state changes, such as updating views, or notifying server (using autorun). Biggest difference from computed is computed produces new data without side effects (while reaction has side effects but doesn't produce new data)

Basically consistent with Flux's (state, action) => state thinking, computed can be viewed as upper layer state, and an important part in reaction is updating views, then simplified to:

       modify         trigger
action ------> state  -------> views

Compare Flux's structure:

action             传递 action         update state
------> dispatcher ---------> stores ------------> views

As mentioned above, action + dispatcher + part in store responsible for responding to action to modify state is equivalent to MobX's action

Compare Redux

                  call             new state
action --> store ------> reducers -----------> view

(From Redux)

Reducer in Redux is all stuffed into action in MobX, no need to use reducer to describe state structure anymore, also no need to care whether reducer is pure (MobX only requires computed to be pure function)

computed is a blank in Redux, so filled by reactjs/reselect, also to reuse data derivation logic, also comes with caching. So MobX is at least equivalent to Redux + reselect

Compare Vuex

       commit           mutate        render
action ------> mutation ------> state ------> view

Vuex's characteristic is distinguishing synchronous/asynchronous action from design perspective, corresponding to mutation and action respectively

Compared to MobX, exactly two extremes. Vuex found Flux's action not refined enough, didn't consider asynchronous scenarios, only then proposed action above mutation, while MobX found distinguishing synchronous/asynchronous, pure and impure too troublesome, only then proposed verb action, encompassing asynchronous and side effects

computed is called getter in Vuex, not much difference between the two. Vuex also considered state derived data from the beginning, unlike Redux needing reselect to fill the blank

5. Advantages

From implementation perspective, only MobX has built-in data change listening, that is putting data binding's core work at data layer, biggest benefit of doing this is modifying state becomes very natural, no need for dispatch, no need to create action, want to change just change according to intuition

State Modification Method Conforms to Intuition

React example:

@observer
class TodoListView extends Component {
    render() {
        return <div>
            <ul>
                {this.props.todoList.todos.map(todo =>
                    <TodoView todo={todo} key={todo.id} />
                )}
            </ul>
            Tasks left: {this.props.todoList.unfinishedTodoCount}
        </div>
    }
}

const TodoView = observer(({todo}) =>
    <li>
        <input
            type="checkbox"
            checked={todo.finished}
            {/* Want to change, just change directly */}
            onClick={() => todo.finished = !todo.finished}
        />{todo.title}
    </li>
)

(Complete example see React components)

No need to define action to change state (even to add reducer to define state), want to change just change directly, no need through library API. This point is same as Vue data binding's advantage, library itself can listen to data changes, no need for user to manually notify changes, business writing becomes convenient

More Powerful DevTools

In Flux, action layer's core function is to make state changes traceable, action as state change cause can be recorded (DevTools or logger), while MobX uses function name as action's carried cause information, implements state change traceability through spy, can implement more powerful DevTools, such as making component's data dependency visualized

mobx-react-devtools

Component-Level Precise Data Binding

Compared to react-redux, mobx-react can achieve more precise view updates, component-granularity precise re-rendering, unlike react-redux needing to diff from outside (Container) downward to find Views needing re-rendering, MobX clearly knows data dependency relationships, no need to search. Then from performance perspective, at least saved cost of finding dirty View

Another performance point is mobx-react removed Container concept, actually through intercepting component lifecycle way to implement (specifically see source code analysis section below), this reduces React component tree depth, theoretically performance will be slightly better

Additionally, because dependency collection is completed by MobX, brought benefit is being able to analyze actually needed data dependencies, avoiding performance loss brought by artificially produced unnecessary Containers

P.S. For more information about runtime dependency collection mechanism, please check Runtime Dependency Collection Mechanism

No Restrictions on State Structure

Flux requires state to be a pure object, this not only forces users to spend energy designing state's structure, also forcibly separates data and corresponding operations, using MobX's words:

But this introduces new problems; data needs to be normalized, referential integrity can no longer be guaranteed and it becomes next to impossible to use powerful concepts like prototypes.

Restricting state from being casually modified, this way some original advantages built on data models are lost, such as prototypes

While MobX has no restrictions on state's structure and type, MobX's state definition is:

Graphs of objects, arrays, primitives, references that forms the model of your application.

Doesn't require single state tree, also doesn't require pure objects, for example:

class ObservableTodoStore {
    @observable todos = [];
    @observable pendingRequests = 0;
?
    constructor() {
        mobx.autorun(() => console.log(this.report));
    }
?
    @computed get completedTodosCount() {
        return this.todos.filter(
            todo => todo.completed === true
        ).length;
    }
?
    @computed get report() {
        if (this.todos.length === 0)
            return "<none>";
        return `Next todo: "${this.todos[0].task}". ` +
            `Progress: ${this.completedTodosCount}/${this.todos.length}`;
    }
?
    addTodo(task) {
        this.todos.push({
            task: task,
            completed: false,
            assignee: null
        });
    }
}
?
const observableTodoStore = new ObservableTodoStore();

Such state definition is MobX's basic gameplay, no need to extract shared data from business, also no need to worry whether current state structure can satisfy future scenarios (what if there are multiple data in future, what if data volume is too large, how to adjust state structure)... Data and corresponding operations can be associated together, organize however you like (use class, or keep Bean + Controller)

When migrating existing projects, can better highlight advantage of not restricting state structure, without changing original model definitions, intrusiveness is very small, only need to add some annotations, can obtain benefits brought by state management layer, why not? Imagine applying Redux to a complex old project, at least need:

  1. Extract all shared states as state

  2. Extract corresponding operations as reducer and saga, and ensure reducer structure is consistent with state

  3. Define action, associate data and operations

  4. Insert Container in appropriate places

  5. Replace all parts modifying state with dispatch

... Forget it, cost is extremely high, not recommended to refactor

6. Source Code Analysis

mobx

Core part is Observable, that is part responsible for completing @observable decoration action:

export class IObservableFactories {
    box<T>(value?: T, name?: string): IObservableValue<T> {
        if (arguments.length > 2) incorrectlyUsedAsDecorator("box")
        return new ObservableValue(value, deepEnhancer, name)
    }

    shallowBox<T>(value?: T, name?: string): IObservableValue<T> {
        if (arguments.length > 2) incorrectlyUsedAsDecorator("shallowBox")
        return new ObservableValue(value, referenceEnhancer, name)
    }

    array<T>(initialValues?: T[], name?: string): IObservableArray<T> {
        if (arguments.length > 2) incorrectlyUsedAsDecorator("array")
        return new ObservableArray(initialValues, deepEnhancer, name) as any
    }

    shallowArray<T>(initialValues?: T[], name?: string): IObservableArray<T> {
        if (arguments.length > 2) incorrectlyUsedAsDecorator("shallowArray")
        return new ObservableArray(initialValues, referenceEnhancer, name) as any
    }

    map<T>(initialValues?: IObservableMapInitialValues<T>, name?: string): ObservableMap<T> {
        if (arguments.length > 2) incorrectlyUsedAsDecorator("map")
        return new ObservableMap(initialValues, deepEnhancer, name)
    }

    //...还有很多
}

(From mobx/src/api/observable.ts)

Recursively downward hang getter&setter on data, for example Class Decorator's implementation:

const newDescriptor = {
    enumerable,
    configurable: true,
    get: function() {
        if (!this.__mobxInitializedProps || this.__mobxInitializedProps[key] !== true)
            typescriptInitializeProperty(
                this,
                key,
                undefined,
                onInitialize,
                customArgs,
                descriptor
            )
        return get.call(this, key)
    },
    set: function(v) {
        if (!this.__mobxInitializedProps || this.__mobxInitializedProps[key] !== true) {
            typescriptInitializeProperty(
                this,
                key,
                v,
                onInitialize,
                customArgs,
                descriptor
            )
        } else {
            set.call(this, key, v)
        }
    }
}
// 定义 getter&setter
if (arguments.length < 3 || (arguments.length === 5 && argLen < 3)) {
    Object.defineProperty(target, key, newDescriptor)
}

(From mobx/src/utils/decorators.ts)

Array change monitoring see mobx/src/types/observablearray.ts, not much difference from Vue's implementation

mobx-react

"Container" implementation as follows:

// 注入的生命周期逻辑
const reactiveMixin = {
    componentWillMount: function() {},
    componentWillUnmount: function() {},
    componentDidMount: function() {},
    componentDidUpdate: function() {},
    shouldComponentUpdate: function(nextProps, nextState) {}
}
// 劫持组件的生命周期
function mixinLifecycleEvents(target) {
    patch(target, "componentWillMount", true)
    ;["componentDidMount", "componentWillUnmount", "componentDidUpdate"].forEach(function(
        funcName
    ) {
        patch(target, funcName)
    })
    if (!target.shouldComponentUpdate) {
        target.shouldComponentUpdate = reactiveMixin.shouldComponentUpdate
    }
}

(From mobx-react/src/observer.js)

Intercepting component lifecycle mainly has 3 functions:

  • Associate data updates with UI updates

  • Expose component state, integrate DevTools

  • Built-in shouldComponentUpdate optimization

react-redux triggers Container update through setState({}), while mobx-react triggers hijacked View update through forceUpdate:

const initialRender = () => {
    if (this.__$mobxIsUnmounted !== true) {
        let hasError = true
        try {
            isForcingUpdate = true
            if (!skipRender) Component.prototype.forceUpdate.call(this)
            hasError = false
        } finally {
            isForcingUpdate = false
            if (hasError) reaction.dispose()
        }
    }
}

(From mobx-react/src/observer.js)

Part integrating DevTools:

componentDidMount: function() {
    if (isDevtoolsEnabled) {
        reportRendering(this)
    }
},
componentDidUpdate: function() {
    if (isDevtoolsEnabled) {
        reportRendering(this)
    }
}

Built-in shouldComponentUpdate:

shouldComponentUpdate: function(nextProps, nextState) {
    if (this.state !== nextState) {
        return true
    }
    return isObjectShallowModified(this.props, nextProps)
}

(From mobx-react/src/observer.js)

Reference Materials

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment