MOBX原理与实践

3,900 阅读10分钟

mobx是什么

Mobx是一个通过函数响应式编程,让状态管理更加简单和容易拓展的库。 MobX背后的哲学很简单:**任何源自应用状态的东西都应该自动地获得。**简单来说就是状态只要一变,其他用到状态的地方就都跟着自动变。

Flux 或者说 Redux 的思想主要就是函数式编程(FP)的思想,所以学习起来会觉得累一些。而 MobX 更接近于面向对象编程,它把 state 包装成可观察的对象,这个对象会驱动各种改变。什么是可观察?就是 MobX 老大哥在看着 state 呢。state 只要一改变,所有用到它的地方就都跟着改变了。这样整个 View 可以被 state 来驱动。

他的原理非常简洁:

  • Actions 是唯一允许修改state而且有其他副作用的函数

  • State 是一组可观察的状态,而且不应该包含冗余的数据(例如不会被更新的状态)

  • Computed Value 是一些纯函数,返回通过 state 可以推导出的值

  • Reactions 类似于 Computed Value,但是它允许副作用的产生(如更新 UI 状态)

核心概念

Observable state

Mobx 为 JavaScript 本身已有的几种数据结构都添加了可观察的功能,也可以使用ES6的新特性,通过装饰器来添加这些功能。

可观察的对象:

const obj = observable({
    a: 1,
    b: 2
})

// autorun 函数是来运行状态后更新后引发的 reaction
autoRun(() => {
    console.log(obj.a)
})

obj.b = 3 // 什么都没有发生
obj.a = 2 // observe 函数的回调触发了,控制台输出:2

上面的obj,他的 obj.a 属性被使用了,那么只要 obj.a 属性一变,所有使用的地方都会被调用。autoRun 就是这个老大哥,他看着所有依赖 obj.a 的地方,也就是收集所有对 obj.a 的依赖。当 obj.a 改变时,老大哥就会触发所有依赖去更新。

可观察的数组:

import {observable, autorun} from "mobx";

var todos = observable([
    { title: "Spoil tea", completed: true },
    { title: "Make coffee", completed: false }
]);

autorun(() => {
    console.log("Remaining:", todos
        .filter(todo => !todo.completed)
        .map(todo => todo.title)
        .join(", ")
    );
});

todos[0].completed = false;
// 输出: 'Remaining: Spoil tea, Make coffee'

todos[2] = { title: 'Take a nap', completed: false };
// 输出: 'Remaining: Spoil tea, Make coffee, Take a nap'

todos.shift();
// 输出: 'Remaining: Make coffee, Take a nap'

可观察的Map:

import {observable} from "mobx";
const map = observable.map();
map.set("a", 100);
console.log(map.get("a"))
// 输出: 100
map.observe(({name, newValue}) => console.log(name, "->", newValue))
map.set("b",100)
// 输出: b -> 100

原生的值和引用:

import {observable} from "mobx";
const cityName = observable("Vienna");
console.log(cityName.get());
// 输出 'Vienna'
cityName.observe(function(change) {
    console.log(change.oldValue, "->", change.newValue);
});
cityName.set("Amsterdam");
// 输出 'Vienna -> Amsterdam'

MobX 允许有多个 store,而且这些 store 里的 state 可以直接修改,不用像Redux 那样每次还返回个新的。这个有点像Vuex,自由度更高,写的代码更少。不过它也会让代码不好维护。

MobX 和 Flux、Redux 一样,都是和具体的前端框架无关的,也就是说可以用于 React(mobx-react) 或者 Vue(mobx-vue)。一般来说,用到 React 比较常见,很少用于 Vue,因为 Vuex 本身就类似 MobX,很灵活。如果我们把 MobX 用于 React 或者 Vue,可以看到很多 setState() 和 this.state.xxx = 这样的处理都可以省了。

常用装饰器

  • @observable 将一个变量变得可观察

  • @observer 常用于React组件,可监视其render函数里使用的可观察变量,从而作出相应reactions

  • @autorun 常用于组件类或store类的constructor里,用来创建实例时,可监视其函数参数里使用的可观察变量,从而作出相应reactions,一般是将函数再执行一遍。

  • @when 有条件的@autorun

  • @computed 通过可观察变量经过纯函数计算得来的值,使用时才会计算,没有使用时不会去计算

  • @action 能改变可观察变量值的操作(一般是函数方法)

注意

  • mobx 只对observables 属性的引用监视,不会对其值进行监视
import DevTools from "mobx-react-devtools";
...
render(){return (
    <div>
        <DevTools />
    </div>
)}
...

//Counter组件,简单展示
@observer
class Counter extends Component {
  @observable count = 0; // 将count变成可观察属性

  countCopy = this.count;  // 被赋值给一个本地变量,不会随着this.count的变化而变化

  constructor(props) {
    super(props);
  }
  render() {
    return (
      <div>
        <span>这是 @observable 变量 count:{this.count}</span>
        <br />
        <span>这是 @observable 变量 count的本地拷贝:{this.countCopy}</span>
        <br />
        <span onClick={this.handleAdd}>Count加一</span>
        <br />
        <span onClick={this.handleDec}>Count减一</span>
      </div>
    );
  }

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

  @action
  handleDec = () => {
    this.count--;
  };
}

可以看到拷贝出来的变量不会再变化
  • 尽量晚些引用observables属性的子属性
// 这个例子中,父组件在使用子组件时直接赋值了整个observable,导到如果此Observable地址不变,子组件极有可能不会重新渲染

class SubCounter extends Component {
 render() {
  const count=this.props.count;
   return (
     <div>
       <span>
         这是子组件显示父组件 @observable 变量 countObj.count1:
         {count.count1}
       </span>
       <br />
       <span>
         这是子组件显示父组件 @observable 变量 countObj.count1:
         {count.count2}
       </span>
     </div>
   );
 }
}

@observer
class Counter extends Component {
 @observable count = 0;

 countCopy = this.count;

 @observable countObj = {
   count1: 1,
   count2: 2
 };

 constructor(props) {
   super(props);
 }
 render() {
   const countCopy2 = this.count;
   return (
     <div>
      // 这里直接引用了整个observable,导到如果此Observable地址不变,子组件极有可能不会重新渲染
       <SubCounter count={this.countObj} /> 
       <span>
         这是 @observable 变量 countObj.count2:{this.countObj.count2}
       </span>
       <br />
       <span>这是 @observable 变量 count的本地拷贝:{this.countCopy}</span>
       <br />
       <span onClick={this.handleAdd}>CountObj.count1加一</span>
       <br />
       <span onClick={this.handleDec}>Count减一</span>
     </div>
   );
 }

 @action
 handleAdd = () => {
   this.countObj.count1++;
 };

 @action
 handleDec = () => {
   this.count--;
 };
}
export default Counter;
  • 嵌套组件最好每层都是observer
class SubCounter extends Component {
  render() {
    return (
      <div>
        <span>
          这是子组件显示父组件 @observable 变量 countObj.count1:
          {this.props.count.count1}
        </span>
        <br />
        <span>
          这是子组件显示父组件 @observable 变量 countObj.count1:
          {this.props.count.count2}
        </span>
      </div>
    );
  }
}

@observer
class Counter extends Component {
  @observable count = 0;

  countCopy = this.count;

  @observable countObj = {
    count1: 1,
    count2: 2
  };

  constructor(props) {
    super(props);
  }
  render() {
    const countCopy2 = this.count;
    return (
      <div>
        <SubCounter count={this.countObj} />
        <span>
          这是 @observable 变量 countObj.count2:{this.countObj.count2}
        </span>
        <br />
        <span>这是 @observable 变量 count的本地拷贝:{this.countCopy}</span>
        <br />
        <span onClick={this.handleAdd}>CountObj.count1加一</span>
        <br />
        <span onClick={this.handleDec}>Count减一</span>
      </div>
    );
  }

  @action
  handleAdd = () => {
    this.countObj.count1++;
  };

  @action
  handleDec = () => {
    this.count--;
  };
}
export default Counter;

autoRun 的用途

使用 autoRun 实现 mobx-react 非常简单,核心思想是将组件外面包上 autoRun,这样代码中用到的所有属性都会像上面 Demo 一样,与当前组件绑定,一旦任何值发生了修改,就直接 forceUpdate,而且精确命中,效率最高。

依赖收集

autoRun 的专业名词叫做依赖收集,也就是通过自然的使用,来收集依赖,当变量改变时,根据收集的依赖来判断是否需要更新。

实现步骤拆解

为了兼容,Mobx 使用了 Object.defineProperty 拦截 getter 和 setter,但是无法拦截未定义的变量,为了方便,我们使用 proxy 来讲解,而且可以监听未定义的变量哦。

步骤一 存储结构

众所周知,事件监听是需要预先存储的,autoRun 也一样,为了知道当变量修改后,哪些方法应该被触发,我们需要一个存储结构。 首先,我们需要存储所有的代理对象,让我们无论拿到原始对象,还是代理对象,都能快速的找出是否有对应的代理对象存在,这个功能用在判断代理是否存在,是否合法,以及同一个对象不会生成两个代理。

const proxies = new WeakMap()
function isObservable<T extends object>(obj: T) {
    return (proxies.get(obj) === obj)
}

重点来了,第二个要存储的是最重要的部分,也就是所有监听!当任何对象被改变的时候,我们需要知道它每一个 key 对应着哪些监听(这些监听由 autoRun 注册),也就是,最终会存在多个对象,每个对象的每个 key 都可能与多个 autoRun 绑定,这样在更新某个 key 时,直接触发与其绑定的所有 autoRun 即可。 代码如下:

const observers = new WeakMap<object, Map<PropertyKey, Set<Observer>>>()

第三个存储结构就是待观察队列,为了使同一个调用栈多次赋值仅执行一次 autoRun,所有待执行的都会放在这个队列中,在下一时刻统一执行队列并清空,执行的时候,当前所有 autoRun 都是在同一时刻触发的,所以让相同的 autoRun 不用触发多次即可实现性能优化。

const queuedObservers = new Set()

代码如下:

我们还要再存储两个全局变量,分别是是否在队列执行中,以及当前执行到的 autoRun。

代码如下:

let queued = false
let currentObserver: Observer = null

步骤二 将对象加工可观察

这一步讲解的是 observable 做了哪些事,首先第一件就是,如果已经存在代理对象了,就直接返回。

代码如下:

function observable<T extends object>(obj: T = {} as T): T {
    return proxies.get(obj) || toObservable(obj)
}

我们继续看 toObservable 函数,它做的事情是,实例化代理,并拦截 get set 等方法。 我们先看拦截 get 的作用:先拿到当前要获取的值 result,如果这个值在代理中存在,优先返回代理对象,否则返回 result 本身(没有引用关系的基本类型)。 上面的逻辑只是简单返回取值,并没有注册这一步,我们在 currentObserver 存在时才会给对象当前 key 注册 autoRun,并且如果结果是对象,又不存在已有的代理,就调用自身 toObservable 再递归一遍,所以返回的对象一定是代理。 registerObserver 函数的作用是将 targetObj -> key -> autoRun 这个链路关系存到 observers 对象中,当对象修改的时候,可以直接找到对应 key 的 autoRun。 那么 currentObserver 是什么时候赋值的呢?首先,并不是访问到 get 就要注册 registerObserver,必须在 autoRun 里面的才符合要求,所以执行 autoRun 的时候就会将当前回调函数赋值给 currentObserver,保证了在 autoRun 函数内部所有监听对象的 get 拦截器都能访问到 currentObserver。以此类推,其他 autoRun 函数回调函数内部变量 get 拦截器中,currentObserver 也是对应的回调函数。 代码如下:

const dynamicObject = new Proxy(obj, {
    // ...
    get(target, key, receiver) {
        const result = Reflect.get(target, key, receiver)

        // 如果取的值是对象,优先取代理对象
        const resultIsObject = typeof result === 'object' && result
        const existProxy = resultIsObject && proxies.get(result)

        // 将监听添加到这个 key 上
        if (currentObserver) {
            registerObserver(target, key)
            if (resultIsObject) {
                return existProxy || toObservable(result)
            }
        }

        return existProxy || result
    }),
    // ...
})

setter 过程中,如果对象产生了变动,就会触发 queueObservers 函数执行回调函数,这些回调都在 getter 中定义好了,只需要把当前对象,以及修改的 key 传过去,直接触发对应对象,当前 key 所注册的 autoRun 即可。

代码如下:

const dynamicObject = new Proxy(obj, {
    // ...
    set(target, key, value, receiver) {
        // 如果改动了 length 属性,或者新值与旧值不同,触发可观察队列任务
        if (key === 'length' || value !== Reflect.get(target, key, receiver)) {
            queueObservers<T>(target, key)
        }

        // 如果新值是对象,优先取原始对象
        if (typeof value === 'object' && value) {
            value = value.$raw || value
        }

        return Reflect.set(target, key, value, receiver)
    },
    // ...
})

没错,主要逻辑已经全部说完了,新对象之所以可以检测到,是因为 proxy 的 get 会触发,这要多谢 proxy 的强大。 可能有人问 Object.defineProperty 为什么不行,原因很简单,因为这个函数只能设置某个 key 的 getter setter~。 symbol proxy reflect 这三剑客能做的事还有很多很多,这仅仅是实现 Object.observe 而已,还有更强大的功能可以挖掘。

  • symbol拓展

  • reflect拓展