Skip to main content

MobX 源码: 响应式架构如何让数据保持同步

响应式状态管理库 MobX 可以浅感知地嵌入业务模型,让它获得强大的响应式功能。一般的 mobx 建模和操作对使用者来说可能有点费解,但在 mobx 内部实现的眼中,所有的操作都是有迹可循的。

例子

试试下方的例子:

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

const len = observable(5)
const area = computed(() => len.get() * len.get())

autorun(() => {
  console.log('Area -> ', area.get())
})
// Area -> 25

len.set(6)
// Area -> 36
len.set(6)
// <无日志输出>

注意上方的示例中的 autorun,在执行一次以后,它就好像订阅了 len、area,当它们改变时, autorun 都会产生反应并执行打印。实际上 mobx 确实让 autorun 订阅了它们,只是这些依赖被安排到不同的层级树上。

通过源码剖析可以更好地理解 mobx 的运作原理和优化手段。本文会在接下来的解析中嵌入上方的例子来帮助理解 mobx 的具体实现。

核心实现

mobx 的核心通过观察者(observable)和推算(derivation)的组合实现。

响应对象(Observable)

观察者是 mobx 中经常用到的模型。它作为一个接口存在,大部分核心对象都有 Observable 的特性。

Observable 有听众(observers)目标(observing),前者是观察它的对象,后者是它观察的目标:

interface Observable {
  observers: Observable[] // 听众们
  observing: Observable[] // 收听目标
}

可以看到,每个响应对象都有这些特征,类似链的结构。它们可以构建出一个依赖树,表示响应对象之间的依赖关系。

示例中创建的 len 就是响应对象对象,而 area 尝试获取了它的值,autorun 又尝试获取了 area 的值,所以它们的模型分别是:

len = { observers: [ area ], observing: [] }
area = { observers: [ autorun ], observing: [ len ] }
autorun = { observers: [], observing: [ area ] }

当 Observable 收到自己被引用时,会告诉 mobx:我被引用了,mobx 会把它放到临时引用列表。

当 Observable 收到自己被修改了,会把它的听众通知一遍。

最小的响应对象(Atom)

Atom 是最简单的 Observable 实现,它不存储任何数据。只维护听众和收听目标。

推算(Derivation)

推算对象是一种 Observable,存储了数据运算的方法。它会执行运算方法,以获取最新的数据。

示例 () => w.get() * h.get() 表示一个计算面积的过程,生成的 area 会是一个推算对象。

一个推算对象会有这些状态:

  • NOT_TRACKED 推算暂未被 mobx 管理。
  • UP_TO_DATE 推算处于最新的状态,不需要重新执行运算
  • POSSIBLY_STALE 推算不确定是否需要重新执行运算
  • STALE 脏了,会立即重新执行运算

在执行运算前,Derivation 会告诉 mobx:我准备执行运算了,mobx 会把它全局标记起来。期间执行的运算可能会引用到 Observable,而 Observable 被引用了,会通知 mobx。由此 mobx 可以得知这个 Derivation 引用了哪些 Observable。运算结束,Derivation 得到了它的收听目标(observing)。

例子中: area 是一个 Derivation,第一次运算期间,引用了 len,它的收听目标会是:

area = { observing: [ len ] }
len = { observers: [ area ] }

此时如果有个 Observable 值发生了变动,它会告知所有的听众。Derivation 收到了修改通知,除了往下告知听众外,自己也会被标记为 STALE,表明自己需要重新执行运算。由于这次运算已经不是第一次的运算了,Derivation 存在现有的 Observable 引用,它会把现有的收听目标取消掉,绑定运算得到的新目标,这种重新绑定的方法定义为 bindDependencies(derivation),重新绑定在运算中出现分支时非常有用,目的是把无用的 Observable 引用去掉。

例子中: len.set(6) 导致 len 发生变化,len 通知了 area

area 重新运算后,取消对 len 收听,又重新绑定了 len 的收听,所以看起来 area 持续保持着对 len 收听。

area = { observing: [ len ] } // 第一次运算得到的收听列表
// 运算: len.get() * len.get()
area = { observing: [ len ] } // 第二次运算得到的收听列表

缓存值(ComputedValue)

缓存值是带有一个缓存值的特殊推算对象,目的是把缓存运算结果,避免不必要的重新计算。它也是一个 Observable 对象,被引用的时候会报告给 mobx。

缓存值被通知的时候,它处于 POSSIBLY_STALE 状态,不会立即重新计算。直到引用它的 Derivation 重新运算时,引用 ComputedValue (.get()),ComputedValue 才会跟着重新运算,并刷新它的状态和缓存的值。

例子中: area 就是一种 ComputedValue

多次调用 area.get() 得到的是 area 缓存的值。

让它通知听众的前提是 len 的变更(len.set())

让它触发重新运算的前提是 autorun 的重新运算

反应(Reaction)

Reaction 是一种临时观察者。Reaction 维护一个 Observable 引用列表。

Reaction 会把触发的动作交给 Scheduler 调度执行,所以直到所有 Observable 变更处理结束后,Reaction 的动作才会被执行。Reaction 的运算过程不会因为 Observable 的变更而触发重新运算,而是会触发 onInvalidate(),表示 Reaction 的引用列表已经不可用了。

class Reaction implements IDerivation {
  observing: Observable[];
  onInvalidate: Function;
  ...
}

执行事务(Action)

虽然可以直接通过 .set() 触发 Observable 状态变化,但 mobx 推荐所有的修改动作都放在 Action 里面执行。

Action 可以执行事务,直到所有的事务结束后,再开始触发 Observable 的处理。它的底层依靠 inBatch 信号来控制,直到 inBatch 为 0 时,才会执行 Observable 处理。

let inBatch = 0

function startBatch() { // 开始事务
  inBatch++
}

function endBatch() {
  if (--inBatch === 0) {
    // 事务期间挂起的操作,在这里会全部执行
  }
}

function runInAction(fn: Function) {
  startBatch()
  fn()
  // ...
  endBatch()
}

事务在 Observable 多次触发的期间使用,可以避免重复触发、重复计算带来的性能问题。mobx 的外部 API runInAction(fn: Function) 可以操作事务。

例: loadinglen 在一个 action 中操作, action 结束后才开始处理这些 observable

const loading = observable(false)
const len = observable(5)
runInAction(() => {
  loading.set(false)
  len.set(8)
})

以上就是 mobx 的核心功能,它的主要部分是 DerivationObservable,通过 Observable 的组合设计可以构建出一个依赖树,而重新绑定引用可以把无用的 Observable 依赖抖掉,最后触发值变化和重新运算。

外部接口

mobx 的核心功能很多都不储存业务数据,对接业务数据由 mobx 对外API 负责。mobx 会对业务数据做装箱处理,和 Vue 的组件注入相近,用到 JavaScript 的代理(Proxy)功能。

box 装箱和注入

mobx 检查传入的值,对于基本类型会做装箱操作,遇到对象会用 Proxy 建立数据拦截。同时 mobx 会提供 deep 选项,决定是否深度遍历对象的所有子值,把它们都转换成 mobx observable。

对于对象属性访问,mobx 也会再提供一层按需转换。

// 基本类型值会被装箱成 ObservableValue 对象
model = observable(1)
model = observable(null)
// 遇到对象,会生成 Proxy 对象
model = observable({
  loading: false,
  len: 5,
})

// 第一次访问 loading 时,对 loading 装箱,再访问 model.loading.get()
model.loading 

// 实际调用 model.loading.set(true)
model.loading(true)

autorun 原理

mobx 外部接口 autorun(fn: Function) 存储了运算过程,用到了 Reaction。上文已说明了 Reaction 用来保存引用的依赖,并在它们变化时触发 onInvalidate()

autorun 会在创建 Reaction 之后立即执行一次运算 fn,让 Reaction 得到并收听 fn 的依赖。在 onInvalidate() 触发阶段再次执行运算 fn 计算出新的依赖,按此重复来实现 autorun 的响应式效果。 autorun 的时机遵循 Reaction,在响应处理最后执行。

const len = observable(5)
const area = computed(() => len.get() * len.get())

autorun(() => {
  console.log('Area -> ', area.get())
})
// Area -> 25,autorun 立即执行计算的输出

len.set(6) // Area -> 36, onInvalidate 触发 autorun 重新计算,Reaction 继续持有 len

len.set(6) // <无日志输出>,因为 Reaction 依赖的值没有变化

len.set(9) // Area -> 81, onInvalidate 触发 autorun 重新计算,Reaction 继续持有 len

附录

Computed(fn)、fn的执行时机?

Computed 是懒加载的,它新建时不会立即调用,而 Reaction(autorun)新建时会马上执行计算,此时被引用的 Computed 才会进行计算。

fn 会在依赖变化时跟随执行,此时 Computed 的状态是 POSSIBLY_STALE

  • fn 计算后结果与之前的值相等,则这个 Computed 的听众不会被通知刷新。
  • 反之与之前的值不等,则这个 Computed 会执行 propagateChangeConfirmed(),状态标记为 STALE,这时要通知听众自己发生变化了。
// Snippet: mobx@6.6.2
score = mobx.observable(0)
passed = mobx.computed(() => { 
  console.log(`score computed: ${score.get()}`); // 只要调用 score.set() 就会触发 computed fn
  return score.get() > .5 
})
// 定义 passed 后不会立即计算 passed 的值,直到 autorun。
dispose = mobx.autorun(() => console.log(`score is passed: ${passed.get()}`))

参考链接

MobX Github Repo (https://github.com/mobxjs/mobx)

[Michel Weststrate@Medium] Becoming fully reactive: an in-depth explanation of MobX