主要组成

本节将详细介绍 MobX 的主要组成部分以及每一部分涉及的重要 API。

state

state 是驱动应用的数据,是应用的核心。同 Redux 类似,我们依然可以把 state 分为三类:与领域直接相关的领域状态数据、反映应用行为(登录状态、当前是否有 API 请求等)的应用状态数据和代表 UI 状态的 UI 状态数据。在实际使用中,一般还会另外创建一个 store 来管理 state,这和 Redux 中的 store 也是类似的。但 MobX 中,可以在一个应用中使用多个 store,store 中的 state 也是可变的。另外,MobX 的 state 的结构不需要做标准化处理(Normalize),可以有多层嵌套结构,以方便 UI 组件使用为指导原则,这也是和 Redux 的 state 不同的地方。

MobX 提供了 observable 和 @observable 两个 API 创建可观测的 state,用法如下:

observable(value)

@observable classProperty = value

这两个 API 几乎可以用在所有的 JS 数据类型上。但根据不同类型的值创建出的可观测 state 的表现行为是有不同点的:

普通对象(Plain Object)

普通对象指原型不存在或原型是 Object.prototype 的对象,例如,var obj={"book":"react"}、var obj = new Object({"book":"react"}) 都是普通对象。MobX 根据普通对象创建一个可观测的新对象,新对象的属性和普通对象相同,但每一个属性都是可观测的,例如:

import { observable, autorun } from "mobx";

var person = observable({
    name: "Jack",
    age: 20
});

// mobx.autorun 会创建一个 reaction 自动响应 state 的变化,后面将会介绍
autorun(() => console.log(`name:${person.name}, age: ${person.age}`));

person.name = "Tom";
// 输出:name:Tom, age:20

person.age = 25;
// 输出:name:Tom, age:25

person 的 name 和 age 属性都是可观测的,任意属性的变化都会触发 autorun 的重新执行。使用 @observable 可以将代码改写如下:

import { observable, autorun } from "mobx";

class Person {
    @observable name = "Jack";
    @observable age = 20;
}

var person = new Person();

// mobx.autorun 会创建一个 reaction 自动响应 state 的变化,后面将会介绍
autorun(() => console.log(`name:${person.name}, age: ${person.age}`));

person.name = "Tom";
// 输出:name:Tom, age:20

person.age = 25;
// 输出:name:Tom, age:25

使用普通对象转换成可观测对象时,还需要注意下面几个问题:

只有当前普通对象已经存在的属性才会转换成可观测的,后面添加的新属性都不会自动变成可观测的,例如:

import { observable, autorun } from "mobx";

var person = observable({
    name: "Jack",
    age: 20
});

autorun(() => console.log(`name:${person.name}, age: ${person.age}, address:${person.address}`));

person.address = "Shanghai";
// 没有新的输出

address 是后来添加的属性,它的改变并不会引起 autorun 的重新执行。属性的 getter 会自动转换成 computed value,效果和使用 @computed 相同。

import { observable, autorun } from "mobx";

var person = observable({
    name: "Jack",
    age: 20,

    // 自动转换成 computed value
    get labelText() {
        return `name:${this.name},age:${this.age}`;
    }
});

autorun(() => console.log(person.labelText));

person.name = "Tom";
// 输出:name:Tom, age:20

person.age = 25;
// 输出:name:Tom, age:25

labelText 是一个 getter 方法,会自动转换成 computed value,autorun 中使用到了 labelText,labelText 的计算值又依赖于 name 和 age,所以 name 和 age 的改变会导致 autorun 重新执行。

observable 会递归地遍历整个对象,每当遇到对象的属性值还是一个对象时(不包含非普通对象),这个属性值将会继续被 observable 转换,例如:

import { observable, autorun } from "mobx";

var person = observable({
    name: "Jack",
    address: {
        province: "Shanghai",
        district: "Pudong"
    }
});

autorun(() =>
    console.log(`name:${person.name},address:${JSON.stringify(person.address)}`)
);

person.address.district = "Xuhui";
// 输出:name:Jack, address:{"province":"Shanghai","district":"Xuhui"}

person 的 name 和 address 属性是可观测的,address 的值是一个对象,因此会继续被 observable 处理,address 的 province 和 district 属性也被转换成可观测的。所以,当 person.address.district ="Xuhui" 执行后,district 的改变也会导致 autorun 的重新执行。

此外,如果以后再给可观测属性赋新值并且新值是一个对象(不包含非普通对象)时,新值也会自动被转换成可观测的,例如:

import { observable, autorun } from "mobx";

var person = observable({
    name: "Jack",
    address: {
        province: "Shanghai",
        district: "Pudong"
    }
});

autorun(() =>
    console.log(`name:${person.name},address:${JSON.stringify(person.address)}`)
);

// 给可观测属性 address 赋新值
person.address = {
    province: "Beijing",
    district: "Xicheng"
};
// 输出:name:Jack,address:{"province":"Beijing","district":"Xicheng"}

person.address.district = "Dongcheng";
// 输出:name:Jack,address:{"province":"Beijing","district":"Dongcheng"}

person 的 address 被赋予一个新对象时,新对象被自动转换成可观测对象,因此,新对象的 district 属性发生改变后,autorun 依然会被触发。

ES 6 Map

返回一个新的可观测的 Map 对象。Map 对象的每一个对象都是可观测的,而且向 Map 对象中添加或删除新元素的行为也是可观测的,这也是 Map 类型的可观测 state 最大的特点,例如:

import { observable, autorun } from "mobx"

// Map可以接收一个数组作为参数,数组的每一个元素代表 Map 对象中的一个键值对
var map = new Map([["name", "Jack"], ["age", 20]]);
var person = observable(map);

autorun(() =>
    console.log(`name:${person.get("name")}, age:${person.get("age")},address:${person.get("address)}`)
);

person.set("address", "Shanghai");
// 输出:name:Jack, age:20, address:Shanghai

person 是一个可观测的 Map 对象,当通过 Map 的 API 向 person 中添加新元素 address 时,autorun 会重新执行。

数组

返回一个新的可观测数组。数组元素的增加或减少都会自动被观测,例如:

import { observable, autorun } from "mobx";

var todos = observable(["Learn React", "Learn Redux"]);

autorun(() => console.log(`待办事项数量:${todos.length}`));

todos.push("Learn MobX");
// 输出:待办事项数量:3

todos.shift();
// 输出:待办事项数量:2

observable 作用于数组类型时,也会递归地作用于数组中的每个元素对象,处理规则和处理普通对象时的规则相同,例如:

import { observable, autorun } from "mobx";

var todos = observable([
    { text: "Learn React", finished: false },
    { text: "Learn Redux", finished: false }
]);

autorun(() => console.log(`todo 1: ${todos[0].text}, finished: ${todos[0].finished}`));

todos[0].finished = true;
// 输出:todo 1:Learn React, finished: true

todos 数组中的元素也转换成可观测对象,因此,元素属性的变化会导致 autorun 的重新执行。

非普通对象

这里,非普通对象的概念是针对普通对象而言的,特指以自定义函数作为构造函数创建的对象。observable 会返回一个特殊的 boxed values 类型的可观测对象。注意,返回的 boxed values 对象并不会把非普通对象的属性转换成可观测的,而是保存一个指向原对象的引用,这个引用是可观测的。对原对象的访问和修改需要通过新对象的 get() 和 set() 方法操作,例如:

import { observable, autorun } from "mobx";

function Person(name, age) {
    this.name = name;
    this.age = age;
}

var person = observable(new Person("Jack", 20));

// person 是 boxed values 类型,必须通过 get() 才能获取到原对象
autorun(() => console.log(`name:${person.get().name},age:${person.get().age}`));

person.get().age = 25
// 没有输出,因为 person 对象的属性不可观测

// person 封装的对象设置为一个新对象,引用发生变化,可观测
person.set(new Person("Jack", 20));
// 输出:name:Jack, age:20

将非普通对象的属性转换成可观测的是自定义构造函数的责任。正确的实现方式是:

import { extendObservable, autorun } from "mobx";

function Person(name, age) {
    // 使用 extendObservable 在构造函数内创建可观测属性
    extendObservable(this, {
        name: name,
        age: age
    });
}

var person = new Person("Jack", 20);

autorun(() => console.log(`name:${person.name}, age:${person.age}`));

person.age = 25;
// 输出:name:Jack, age:25

改写成使用装饰器 @observable 的方式:

import { observable, autorun } from "mobx";

class Person {
    @observable name;
    @observable age;

    constructor(name, age) {
        this.name = name;
        this.age = age
    }

    var person = new Person("Jack", 20);

    autorun(() => console.log(`name:${person.name}, age:${person.age}`));

    person.age = 25;
    // 输出:name:Jack,age:25
}

基本数据类型

MobX 是将包含值的属性(引用)转换成可观测的,而不是直接把值转换成可观测的。当 observable 接收的参数是 JavaScript 的基本数据类型时,MobX 不会把它们转换成可观测的,而是同处理非普通对象一样,返回一个 boxed values 类型的对象,例如:

import { observable, autorun } from "mobx";

// Jack这个值是不可观测的,可观测的是指向这个对象的引用
const name = observable("Jack");

autorun(() => console.log(`name:${name.get()}`));

name.set("Tom");
// 输出:name:Tom

除了直接使用 observable 创建可观测对象外,还可以使用语义更加精确的 API 创建不同类型的可观测对象,例如:

observable.object(value)  // 创建一个可观测的 Object
observable.array(value)  // 创建一个可观测的 Array
observable.map(value) // 创建一个可观测的 Map
observable.box(value) // 创建一个可观测的 Boxed value

observable(value) 相当于以上 API 的简写方式,会自动根据参数类型的不同使用不同的转换逻辑。

computed value

computed value 是根据 state 衍生出的新值,新值必须是通过纯函数计算得到的。computed value 依赖的 state 改变时,会自动重新计算,前提是这个 computed value 有被 reaction 使用。也就是说,computed value 采用延迟更新策略,只有被使用时才会自动更新。一般通过 computed 和 @computed 创建 computed value,使用方式如下:

computed(() => expression)

@computed get classProperty() { return expression; }

computed 一般用于接收一个函数,例如:

import { observable, computed, autorun } from "mobx"

var person = observable.object({
    name: "Jack",
    age: 20,
});

// 使用 computed 函数创建 computed value
const isYoung = computed(() => {
    return person.age < 25;
})

autorun(() =>
    console.log(`name:${person.name},isYong:${isYong}`)
);

person.age = 25;
// 输出:name:Jack, isYoung:false

@computed 一般用于修饰 class 的属性的 getter 方法,例如:

import { observable, computed, autorun } from "mobx";

class Person {
    @observable name;
    @observable age;

    // 使用 @computed 装饰器创建 computed value
    @computed get isYoung() {
        return this.age < 25;
    }

    constructor(name, age) {
        this.name = name;
        this.age = age
    }
}

var person = new Person("Jack", 20);

autorun(() => console.log(`name:${person.name},isYong:${person.isYoung}`));

person.age = 25;
// 输出:name:Jack, isYong:false

reaction

reaction 是自动响应 state 变化的有副作用的函数。和 computed value 相同的地方是,它们都会因为 state 的变化而自动触发,所以 computed value 和 reaction 在 MobX 中都被称为 derivation(衍生)。derivation 是指可以从 state 中衍生出来的任何东西,例如值或者动作。与 computed value 不同的是,reaction 产生的不是一个值,而是执行一些有副作用的动作,例如打印信息到控制台、发送网络请求、根据 React 组件树更新 DOM 等。

使用 observer/@observer 封装 React 组件是常用的创建 reaction 的方式。observer/@observer 是 mobx-react 这个包提供的 API,常用的使用方式有如下三种:

observer((props, context) => ReactElement)
observer(class MyComponent extends React.Component {...})
@observer class MyComponent extends React.Component {...}

observer 的参数可以是一个 React 函数组件,也可以是一个 React 类组件,但对于类组件一般习惯使用 @observer 创建 reaction。observer/@observer 本质上是将组件的 render 方法转换成 reaction,当 render 依赖的 state 发送变化时,render 方法会被重新调用。

除了 observer/@observer 外,常用的创建 reaction 的 API 还有 autorun、reaction、when,这几个 API 直接作用于函数而不是组件。

autorun

用法:

autorun(() => { sideEffect })

autorun 在前面的例子中已经多次使用到。使用 autorun 时,它接收的函数会被立即触发执行一次,以后的执行就依赖于函数使用的 state 的变化了。autorun 会返回一个清除函数 disposer,当不再需要观察相关 state 的变化时,可以调用 disposer 函数清除副作用,例如:

var numbers = observable([1,2,3]);
var sum = computed(() => numbers.reduce((a,b) => a + b, 0));

var disposer = autorun(() => console.log(sum.get()));
// 输出:6
numbers.push(4);
// 输出:10

disposer();  // 清除 autorun
numbers.push(5);
// 没有输出

reaction

用法:

reaction(() => data, data => { sideEffect }, options?)

它接收两个函数,第一个函数返回被观测的 state,这个返回值同时是第二个函数的输入值,只有第一个函数的返回值发生变化时,第二个函数才会被执行。第三个参数 options 是可选参数,提供一些可选设置,一般很少用到。reaction 也会返回一个清除函数 disposer。可见,相较于 autorun,reaction 可以对跟踪哪些对象有更多的控制。下面是一个示例:

const todos = observable([
    {
        title: "Learn React",
        done: true
    },
    {
        title: "Learn MobX",
        done: false
    }
]);

// 错误用法:只响应 todos 数组长度的变化,不会响应 title 属性的变化
const reaction1 = reaction(
    () => todos.length,
    length => console.log("reaction 1:", todos.map(todo => todo.title).join(','))
);

// 正确用法:同时响应 todos 数组长度和 title 属性的变化
const reaction2 = reaction(
    () => todos.map(todo => todo.title),
    titles => console.log("reaction 2:", titles.join(", "))
);

todos.push({title: "Learn Redux", done: false });
// 输出:
// reaction 2: Learn React, Learn Mobx, Learn Redux
// reaction 1: Learn React, Learn Mobx, Learn Redux

todos[0].title = "Learn Something";
// 输出:
// reaction 2: Learn Something, Learn Mobx, Learn Redux

when

用户:

when(() => condition, () => { sideEffect })

condition 会自动响应它使用的任何 state 的变化,当 condition 返回 true 时,函数 sideEffect 会执行,且只执行一次。when 也会返回一个清除函数 disposer。when 非常适合用在以响应式的方式执行取消或清除逻辑的场景,例如:

class MyResource {
    constructor() {
        when(
            () => !this.isVisible,
            () => this.dispose()
        );
    }

    @computed get isVisible() {
        // 判断某个元素是否可见
    }

    dispose() {
        // 清除逻辑
    }
}

action

action 是用来修改 state 的函数。MobX 提供了 API action 和 @action 用来包装 action 函数,但这并不是必需的。当 MobX 运行在严格模式下(调用 mobx.useStrict(true) 即可启动严格模式)时,必须使用这两个 API 包装 action 函数。常见的用法有:

action(fn)
@action classMethod

为了让代码更加清晰可读,建议创建 action 函数时都要使用 action/@action。此外,action/@action 还能带来性能的提升,当函数内多次修改 state 时,action/@action 会执行批处理操作,只有所有的修改都执行完成后,才会通知相关的 computed value 和 reaction。下面是一个获取 BBS 帖子列表的 action:

@action fetchPostList(url) {
    this.pendingRequestCount++;
    return fetch(url).then(
        action(data => {
            this.pendingRequestCount--;
            this.posts.push(data);
        })
    );
}

这里需要注意,我们使用了两次 action 函数,因为 fetch 是异步执行的,执行完成的回调函数中也会修改 state,所以需要单独使用一个 action 包装回调函数。

使用 action 时,需要注意函数内 this 指向的问题,例如:

class Ticker {
    @observable tick = 0

    @action
    increment() {
        this.tick++;
    }
}

const ticker = new Ticker()
setInterval(ticker.increment, 1000) // 报错

在上面的例子中,increment 执行时,this 指向的是全局的 window 对象,并不是期望的 Ticker 的实例对象。可以使用箭头函数解决 this 绑定的问题:

class Ticker {
    @observable tick = 0;
    @action
    increment = () => {
        this.tick++;
    }
}

const ticker = new Ticker();
setInterval(ticker.increment, 1000);

此外,MobX 还提供了 @action.bound 和 action.bound 两个 API 帮助完成 this 绑定的工作:

class Ticker {
    @observable tick = 0;

    @action.bound
    increment() {
        this.tick++;
    }
}

const ticker = new Ticker();
setInterval(ticker.increment, 1000);

const ticker = observable({
    tick: 1,
    increment: action.bound(function () {
        this.tick++;
    })
})

setInterval(ticker.increment, 1000);