==========前言============

A. Story Time

宁静的早上,执行官 MobX 将自己的计算性能优化机制报告呈现给警署最高长官。

在这份报告解说中,谈及部署成本最高的地方是在执行任务部分。因此优化这部分任务执行机制,也就相当于优化性能。

警署最高长官浏览了报告前言部分,大致总结以下 2 点核心思想:

  • 两组人会涉及到任务的执行:执行组(探长) 和 计算组(会计师)

言外之意,观察组(观察员)不在优化机制里,他们的行为仍旧按部就班,该汇报的时候就汇报,该提供数据的时候提供数据。

  • 由于执行任务的比较消耗资源,因此执行人员对每一次任务的执行都要问一个”为什么“,最核心的一点是:如果下级人员的数据不是最新的时候,上级人员就不应该执行任务。

执行人员依据什么样的规则来决定是否执行呢?

那么,执行人员依据什么样的规则来决定是否执行呢?

警署最高长官继续往下阅读,找到了解答该问题的详细解说。简言之,为了解决该问题执行官 MobX 给出了状态调整策略,并在这套策略之上指定的任务执行规则

由于专业性较强,行文解释里多处使用代码。为了更生动形象地解释这套行为规范,执行官 MobX 在报告里采用 示例 + 图示 的方式给出生动形象的解释。

接下来我们在 B. Source Code Time 部分详细阐述这份 任务执行规则 的内容。

B. Source Code Time

执行人员(探长和会计师)依据什么样的规则来决定是否执行呢?

答案是,执行官 MobX 提供了一个名为 shouldCompute 的方法,每次执行人员(探长和会计师)需要执行之前都要调用该方法 —— 只有该方法返回 true 的时候才会执行任务(或计算)。

在源码里搜索一下关键字 shouldCompute,就可以知道的确只有 derivation(执行组,探长也属于执行组)、reaction(探长)、computeValue(会计师)这些有执行权力的人才能调用这个方法,而 observerable(观察员)并不在其中。
只有执行组才涉及到 shouldCompute

也就说 shouldCompute 就是任务执行规则任务执行规则就是 shouldCompute。而背后支撑 shouldCompute 的则是一套 状态调整策略

1、状态调整策略

1.1、L 属性D 属性

翻开 shouldCompute 源码, 将会看到 dependenciesState 属性。

dependenciesState 属性

其实这个 dependenciesState(以下简称 D 属性) 属性还存在一个”孪生“属性lowestObserverState (以下简称 L 属性)。这两个属性正是执行官 MobX 状态调整策略的核心。

L 属性D 属性反映当前对象所处的状态, 都是枚举值,且取值区间都是一致的,只能是以下 4 个值之一:

  • -1: 即 NOT_TRACKING,表示不在调整环节内(还未进入调整调整,或者已经退出调整环节)
  • 0:即 UP_TO_DATE,表示状态很稳定
  • 1: 即 POSSIBLY_STALE,表示状态有可能不稳定
  • 2:即 STALE,表示状态不稳定

上面的文字表述比较枯燥,我们来张图感受一下:

用阶梯展示状态量

我们以 “阶梯” 来表示上述的状态值;

  • UP_TO_DATE(0) 是地面(表示“非常稳定”)
  • POSSIBLY_STALE(1) 是第一个台阶
  • STALE(2) 是第 2 个台阶,
  • NOT_TRACKING(-1)则到地下一层去了
  • 所谓 “高处不胜寒”,距离地面越高,就代表越不稳定
  • 状态值 UP_TO_DATE(0)代表的含义是 稳定的状态,是每个对象所倾向的状态值。

1.2、调整策略

依托L 属性D 属性,执行官 MobX 的调整策略应运而生:

  • 只有在 观察值发生变化 的时候(比如修改了 bankUser.income 属性值),才会启用这套机制;
  • 下级成员拥有 L 属性;而上级成员拥有 D 属性,比如:
    • 观察员 O1 只拥有 L 属性
    • 探长 R1 只拥有 D 属性
    • 会计师 C1 既拥有 L 属性,也拥有 D 属性
  • 某下级成员调整属性时,调整的策略必须要满足:自身的 D 属性 永远不大于(≤)上级的 L 属性
  • 某上级成员调整属性时,调整的策略必须要满足:其下级成员的 D 属性 永远不大于(≤)自身的 L 属性
  • 观察值的变更会让成员的属性值 上升(提高不稳定性),MobX 执行任务会让成员属性值 降低(不稳定性降低);

上述调整策略给我们的直观感受,就是外界的影响导致 MobX 执行官的部署系统不稳定性上升,为了消除这些不稳定,MobX 会尽可能协调各方去执行任务,从而消除这些个不稳定性
(举个不甚恰当的例子,参考人类的免疫机制,病毒感冒后体温上升就是典型的免疫机制激活的外在表现,抵御完病毒之后体温又回归正常)

2、执行任务规则

我们知道,只有上级成员(探长或者设计师)才有执行任务的权力;而一旦满足上面的调整策略,在任何时刻,执行官 MobX 直接查阅该上级成员的 D 属性 就能断定该上级成员(探长或者设计师)是否需要执行任务了,非常简单方便。

执行官 MobX 判断的依据都体现在 shouldCompute 方法中了。

本人窃认为这个 shouldCompute 函数的名字太过于抽象,如果让我命名的话,我更倾向于使用 shouldExecuteTask 这个单词。

依托L 属性D 属性,执行任务规则(即 shouldCompute)就出炉了:

  • 如果属性值为 NOT_TRACKING(-1)或者 STALE(2),说明自己所依赖的下级数值陈旧了,是时候该重新执行任务(或重新计算)了;
  • 如果属性值为 UP_TO_DATE(0),说明所依赖的下级的数值没有更改,是稳定的,不需要重新执行任务。
  • 如果属性值为 POSSIBLY_STALE(1),说明所依赖的值(一定是计算值,只有计算值的参与才会出现这种状态)有可能变更,需要让下级先确认完后再做进一步判断。这种情况可能不太好理解,后文会详细说明。

执行任务规则看上去比较简单,但应用到执行官 MobX 自动化部署方案中情况就复杂了。下面将通过 3 个场景,从简单到复杂,一步一步来演示L 属性D 属性 是如何巧妙地融合到已有的部署方案中,并以最小的成本实现性能优化的。

2.1、最简单的情况

var bankUser = mobx.observable({
  income: 3,
  debit: 2
});

mobx.autorun(() => {
  console.log('张三的存贷:', income);
});

bankUser.income = 4;

这里我们创建了 autorun 实例 (探长 R1)、observable实例(观察员O1)

这个示例和我们之前在首篇文章《【用故事解读 MobX源码(一)】 autorun》中所用示例是一致的。

当执行 bankUser.income = 4; 语句的时候,观察员 O1 观察到的数值变化直接上报给探长 R1,然后探长就执行任务了。关系简单:

上报关系

从代码层面上来讲,该 响应链 上的关键函数执行顺序如下:

(O1) reportChange 
    -> (O1) propagateChanged 
    -> (R1) onBecomeStale 
      -> (R1) trackDerivedFunction 
         -> fn(即执行 autorun 中的回调) 

其中涉及到 L、D属性 更改的函数有 propagateChangedtrack 这两个。

Step 1:在 propagateChanged 方法执行时,让观察员 O1 的 L 属性 从 0 → 2 ,按照上述的调整原则,探长 R1 的 D属性 必须要高于观察员 O1 的 L 属性,所以其值也只能用从 0 → 2。

 propagateChanged 方法执行时,所有值都变成 2

Step 2:而随着 trackDerivedFunction 方法的执行(即探长执行任务)后,观察员 O1 的 L 属性 又从 2 → 0,同时也让探长 R1 的 D属性 从 2 → 0;

trackDerivedFunction 方法的执行会让值变成 0

在这里我们已经可以明显感受到 非稳态的上升削减 这两个阶段:

  • 非稳态的上升:外界更改 bankUser.income 属性,触发 propagateChanged 方法,从而让观察员的 L 属性 以及探长的 D属性 都变成了 2 ,这是系统趋向不稳定的表现。从 层级上来看,是自下而上的过程。
  • 非稳态的削减:随着变更的传递,将触发探长 R1 的 onBecameStale 方法。执行期间 MobX 执行官查阅探长的 D属性 是 2,依据 shouldCompute 中的执行规定,同意让探长执行任务。执行完之后,观察员的 L 属性、探长的 D属性 都下降为 0,表示系统又重新回到稳定状态。从 层级上来看,是自上而下的过程。

2.2、有单个会计师的情况

上面介绍了最简单的情况,只有一个探长 R1(autorun)和一个观察员 O1(income)。

现在我们将环境稍微弄复杂一些,新增一个 会计师 C1divisor) ,此时再来看看上述的变更原则是如何在系统运转时起作用的:

var bankUser = mobx.observable({
  income: 3,
  debit: 2
});

var divisor = mobx.computed(() => {
  return bankUser.income / bankUser.debit;
});

mobx.autorun(() => {
  console.log('张三的 divisor:', divisor);
});

bankUser.income = 4;

这个示例和我们之前在首篇文章《【用故事解读 MobX源码(二)】 computed 》中所用示例是一致的。

当我们执行 bankUser.income = 4; 语句的时候,观察员 O1 先上报给会计师 C1,接着会计师 C1 会重新执行计算任务后,上报给探长,探长R1 再重新执行任务。

当有会计师角色参与时的情况

上面描述起来比较简单,但从代码层面上来讲还是有些绕,先列出该 响应链 上的关键函数执行顺序如下(很明显比上面的示例要稍微复杂一些):

(O1) reportChange 
    -> (O1) propagateChanged
      -> (C1) propagateMaybeChanged
      -> (R1) onBecomeStale(这里并不会让探长 `runReaction`)
-> (O1) endBatch
    -> (R1) runReaction(到这里才让探长执行 `runReaction`)
      -> (C1) reportObserved
      -> (C1) shouldCompute
         -> (C1) trackAndCompute 
         -> (C1) propagateChangeConfirmed
      -> (R1) trackDerivedFunction
         -> fn(即执行 autorun 中的回调) 

注:这里还需要啰嗦一句,虽然这里会触发探长 R1 的 onBecomeStale 方法,但 MobX 并不会直接让探长执行任务,这也是 MobX 优化的一种手段体现,详细分析请移步《【用故事解读 MobX源码(二)】 computed 》。

Step 1:在 propagateChanged 方法执行时,让观察员 O1 的 L 属性 从 -1 → 2 ,按照上述的调整原则,其直接上级 C1 的 D属性 必须要高于观察员 O1 的 L 属性,所以其值也只能用从 0 → 2;

和上述简单示例中最大的不同,在于该期间还涉及到会计师 C1 的状态更改,具体表现就是调用 propagateMaybeChanged ,在该方法执行后让会计师 C1 的 L 属性 从 0 → 1 ,其直接上级 R1 的 D属性 必须要高于会计师 C1 的 L 属性,所以其值也从 0 → 1;

propagateMaybeChanged 调用后的情况

注:虽然观察员 O1 的状态更改 不能直接 触发探长 R1 的状态更改,却可以凭借会计师 C1 间接 地让 探长 R1 的状态发生更改。

Step 2:此步骤是以 会计师 状态变更为中心演变过程,上一个案例并不存在会计师,所以并不会有该步骤。通过 trackAndCompute 方法,会计师 C1 的 D 属性 又从 2 → 0,同时也让观察员 O1 的 L属性 从 2 → 0;这个过程表明会计师 C1 的计算值已经更新了。

随后在 propagateChangeConfirmed 中让探长 R1 的 D 属性 从 1 (下级数值可能有更新)→ 2 (确定下级数值确定有更新),同时也让会计师 C1 的 L 属性 从 1(告知上级自己的值可能有更新)→ 2 (告知上级自己的值的确有更新);表明探长 R1 和 会计师 C1 的稳态还未达成,需要 Step 3 的执行去消除非稳态。

消除非稳态

Step 3:会计师的计算值 C1 更新完毕之后,探长才执行任务。通过 trackDerivedFunction 方法的执行(即探长执行任务)后,会计师 C1 的 L 属性 又从 2 → 0,同时也让探长 R1 的 D 属性 从 2 → 0;

探长通过执行 trackDerivedFunction 方法消除非稳态

虽然这个示例中,状态的变更比上面的示例要复杂一些,不过我们依然可以从整体上感受到 非稳态的上升削减 这两个阶段:

  • 非稳态的上升:外界更改 bankUser.income 属性,触发 propagateChanged 方法,从而让观察员 O1 的 L 属性 以及会计师 C1 的 D属性 都变成了 2 ,同时让会计师 C1 的 L 属性 以及探长 R1 的 D属性 都变成了 1 。这是系统趋向不稳定的表现。从 层级上来看,是自下而上的过程。
  • 非稳态的削减:随着变更的传递,有两次削减非稳态的手段: ① 让会计师 C1 重新计算; ② 让探长执行任务。这两个阶段结束之后,所有成员的属性都下降为 0,表示系统又重新回到稳定状态。从 层级上来看,是自上而下的过程。

2.3、有两个会计师的情况

我们继续在上一个示例上修改,再新增一个计算值 indication(这个变量的创建没有特殊的含义,纯粹是为了做演示),由会计师 C2 了负责其进行计算。

var bankUser = mobx.observable({
  income: 3,
  debit: 2
});

var divisor = mobx.computed(() => {
  return bankUser.income / bankUser.debit;
});

var indication = mobx.computed(() => {
  return divisor / (bankUser.income + 1);
});

mobx.autorun(() => {
  console.log('张三的 indication', indication);
});

bankUser.debit = 4;

大体成员和之前的示例相差不大,只是这次我们修改 bankUser.debit 变量(前面两个示例都是修改 bankUser.income)。

这么做的目的是为了营造出下述的 响应链 结构,我们通过修改 bankUser.debit 变量,从而影响 会计师 C1,继而影响 会计师 C2,最终让探长 R1 执行任务。

当有两个设计师时的场景

同样的,我们从代码层面上来列出该 响应链 上的关键函数执行顺序,比上两个示例都复杂些,大致如下:

(O2) reportChange 
    -> (O2) propagateChanged
      -> (C1) propagateMaybeChanged
      -> (C2) propagateMaybeChanged
      -> (R1) onBecomeStale(这里并不会让探长 `runReaction`)
-> (O2) endBatch
    -> (R1) runReaction(到这里才让探长执行 `runReaction`)
      -> (R1) shouldCompute
         -> (C2) shouldCompute
           -> (C1) shouldCompute
           -> (C1) trackAndCompute
           -> (C1) propagateChangeConfirmed
         -> (C2) trackAndCompute
         -> (C2) propagateChangeConfirmed
      -> trackDerivedFunction
         -> fn(即执行 autorun 中的回调) 

Step 1:在 propagateChanged 方法执行时,让观察员 O1 的 L 属性 从 0 → 2 ,按照上述的调整原则,其直接上级 C1 的 D属性 必须要高于观察员 O1 的 L 属性,所以其值也只能用从 0 → 2;

该期间还涉及到会计师 C1、C2 的状态更改,具体表现就是调用 propagateMaybeChanged ,在该方法执行后让会计师 C1、C2 的 L 属性 从 0 → 1 ,他们各自的直接上级 C2、 R1 的 D属性 值也从 0 → 1;

描述起来比较复杂,其实无非就是多了一个 会计师 C2 的 propagateMaybeChanged 方法过程,一图胜千言:

多了一个 会计师 C2 的 propagateMaybeChanged 方法过程

Step 2:此步骤是以 会计师 状态变更为中心演变过程,该步骤是上一个示例中 Step 2 的“复数”版,多个人参与就复杂些,不过条理还是清晰明了的。上个示例中只有一个会计师,所以 trackAndCompute ->propagateChangeConfirmed 的过程只有一次,而这里有两个会计师,所以这个过程就有两次(下图中两个蓝框);

有两个会计师,消除非稳态过程就有两次

经过该步骤之后会计师 O2、C1 的 L 属性 又从 2 → 0,同时也让C1、C2 的 D 属性 从 2 → 0;这个过程表明观察员 O1 和 会计师 C1 的计算值已经更新,达到稳态。

而 C2 的 L 属性 、探长 R1 的 D 属性 又从 0 → 2,表明探长 R1 和 会计师 C2 的稳态还未达成,需要 Step 3 的执行去消除非稳态。

Step 3:探长执行任务,通过 trackDerivedFunction 方法的执行(即探长执行任务)后,会计师 C2 的 L 属性 又从 2 → 0,同时也让探长 R1 的 D 属性 从 2 → 0;这一步和上个示例中的 Step 3 几乎相同。

通过 trackDerivedFunction 方法的执行所有非状态都归 0

在这个示例中,状态的变更纵使比上面的示例要复杂得多,但我们还是很清晰地从整体上感受到 非稳态的上升削减 这两个阶段:

  • 非稳态的上升:外界更改 bankUser.debit 属性,触发 propagateChanged 方法,从而让观察员 O1 开始,依次影响 会计师 C1、C2,以及探长 R1 的 L、D 属性从 0 变成 1 或者 2,这是系统趋向不稳定的表现。从 层级上来看,是自下而上的过程。
  • 非稳态的削减:随着变更的传递,有两次削减非稳态的手段: ① 让会计师 C1 、C2 重新计算; ② 让探长 R1 执行任务。这两个阶段结束之后,所有成员的属性都下降为 0,表示系统又重新回到稳定状态。从 层级上来看,是自上而下的过程。

2.4、一点点总结

通过上面三个从简单逐步到复杂的示例,我们简单总结归纳一下 MobX 在处理状态变更过程中所采取执行机制以及其背后的调整策略:

  • 先是自下而上传递非稳态:这是一个自下而上的过程,由观察员发起这个过程,在这个过程中依次将外界的变更层层向上传递,改变每个相关成员的 L、D属性。 这个期间会拒绝一切成员任务执行的申请(比如探长执行任务、会计师执行计算任务等等)。
  • 其次自上而下消解非稳态:这是一个自上而下的过程。当非稳态到达顶层后,由顶层人员(一般是探长类)开始做决策执行任务,在执行任务中凡是遇到有非稳态的成员(比如会计师、观察员),责令他们更新状态,消除非稳态,逐层逐层地消除非稳态。等整个任务执行完之后,每个成员都处于稳态状态,开始下一个变更的到来。

3、状态图

在软件设计中,为了更好地显示这种状态变更和事件之间的关系,常常使用 状态图 来展现(没错,就是 UML建模中的那个状态图)

如果不太熟悉,这里给个参考文章 UML建模之状态图(Statechart Diagram) 方便查阅。

挨个总结上述 3 个案例中 L、D属性,我们将其中的事件和属性改变抽离出来,就能获取状态图了,方便我们从另外一个角度理解和体会。

3.1、L 属性

Observable(观察员)、ComputeValue(会计师)这两种类型拥有 L 属性

L 属性变更状态图

3.2、D 属性

Reaction(探长)、ComputeValue(会计师)这两种类型拥有 D 属性
D 属性变更状态图

所以,会计师同时拥有 L属性D 属性

4、小测试

如果我们将 2.3、有两个会计师的情况 示例中的 bankUser.debit = 4; 修改成 bankUser.income = 6; 的话,那各个成员对象的 D 属性L 属性 的变化情况又是怎么样的?

5、本文总结

如何在复杂的场景下兼顾计算性能?

MobX 提供了 shouldCompute 方法用于直接判断是否执行计算(或任务),判断的依据非常简单,只要根据对象的 dependenciesState 属性是否为 true 就能直接作出判断。

而其背后的支持则是 dependenciesState 属性(上文中的 D 属性)和 lowestObserverState (上文中的 L 属性),这两个属性依托 MobX 中自动化机制在适当时机(搭”顺风车“)进行变更。因此,无论多么复杂的场景下 MobX 能以低廉的成本兼顾性能方面的治理,充分运用惰性求值思想减少计算开销

初看 MobX 源码,它往往给你一种 ”杂项丛生“的感觉(调试这段代码的时候真是心里苦啊),但其实在这背后运转着一套清晰的 非稳态传递非稳态削减 的固定模式,一旦掌握这套模式之后,MobX 自动化响应体系的脉络已清晰可见,这将为你更好理解 MobX 的运行机制打下扎实的基础。

到本篇为止,我们已经耗费 3 篇文章来解释 MobX 的(绝大部分)自动化响应机制。经过这 3 篇文章,读者应该对 MobX 的整个运转机制有了一个比较清晰明了的理解。后续的文章中将逐渐缩减”故事“成分,将讲解重心转移到 MobX 本身概念(比如 ObservabledecoratorAtom等)源码的解读上,相信有了这三篇文章的作为打底,理解其余部分更多的是在语法层面,阅读起来将更加游刃有余。