用故事解读 MobX 源码(二)computed
收藏

温馨提示:因微信中外链都无法点击,请通过文末的” “阅读原文” 到技术博客中完整查阅版;(本文整理自技术博客)      
  • 初衷:以系列故事的方式展现源码逻辑,尽可能以易懂的方式讲解 MobX 源码;

  • 本系列文章

  • 《【用故事解读 MobX源码(一)】 autorun》

  • 《【用故事解读 MobX源码(二)】 computed》

  • 《【用故事解读 MobX源码(三)】 shouldCompute》

  • 《【用故事解读 MobX 源码(四)】装饰器 和 Enhancer》

  • 《【用故事解读 MobX 源码(五)】 Observable》

  • 文章编排:每篇文章分成两大段,第一大段以简单的侦探系列故事的形式讲解(所涉及人物、场景都以 MobX 中的概念为原型创建),第二大段则是相对于的源码讲解。

  • 本文基于 MobX 4 源码讲解

在写本文的时候,由于 MobX 以及升级到 4.x,API 有较大的变化,因此后续的文章默认都将基于 4.x 以上版本进行源码阅读。

前一篇文章仍然以 mobx v3.5.1 的源码,autorun 逻辑在新版中没有更改,因此源码逻辑仍旧一致。

A. Story Time

1、 场景

为了多维度掌控嫌疑犯的犯罪特征数据,你(警署最高长官)想要获取并实时监控张三的 贷款数额、存贷比(存款和贷款两者比率) 的变化。

于是你就拟定了新的命令给执行官 MobX:

 1var bankUser = mobx.observable({
2  income3,
3  debit2
4});
5
6var divisor = mobx.computed(() => {
7  return bankUser.income / bankUser.debit;
8});
9
10mobx.autorun(() => {
11  console.log('张三的贷款:', bankUser.debit, ';张三的存贷比: ' + divisor);
12});

相比上一次的命令,除了监控张三贷款这项直接的指标,还需要监控 贷款比divisor) 这项间接指标。

执行官 MobX 稍作思忖,要完成这个任务比之前的要难一点点,需要费一点儿精力。

执行官 MobX 稍作思忖

不过,这也难不倒能力强大的 MobX 执行官,一番策略调整之后,重新拿出新的执行方案。部署实施之后,当张三去银行存款、贷款后,这些变化都实时反馈出来了:

实时反馈计算值

2、部署方案

这次的部署和前一次相差不大,除了需要让观察员 O2(监视 income)参与进来之外,考虑到警署最高长官所需的 存贷比divisor),还得派出另一类职员 ——  会计师

  • 会计师:此类职员专门负责计算,从事 数据的再加工(此项任务中,就是搜集数据并计算 存贷比

会计师角色

会计师是一个很有意思的角色,要想理解他们,必须得思考他们的数据“从哪儿来?到哪里去?” 这两个问题:

  • 从哪儿来:从观察员那儿获取,也可以从其他会计师那儿获取;

  • 到哪儿去:所生产的数据,要么是被探长消费,要么被其他会计师所用;(当然,没有人消费他所生产的数据也是可能的,不过这就得追究 MobX 执行官的责任了,浪费了人力资源)

引入了会计师角色之后,MobX 执行官重新绘制了部署计划图:

重新绘制部署计划图

解释一下此计划图的意思:

  1. 明确此次任务是 当张三账户存款或者贷款变更时,打印其贷款数额(`debit`)和存贷比(`divisor`)

1() => {
2  console.log('张三的贷款:', bankUser.debit, ';张三的存贷比: ' + divisor);
3}
  1. 将任务指派给执行组中的探长 R1

  2. 派遣 2 名观察组中的观察员 O1、O2 分别监察张三账户的 bankUser.income 属性和 bankUser.debit 属性;

  3. 派遣计算组中的会计师 C1 计算张三的贷款比,其所需数值来源于观察员 O1、O2;

  4. 探长 R1 任务中所需的“张三的账户存款” 数值从观察员 O2 那儿获取;所需的 “张三的存贷比” 数值从会计师 C1 那儿获取;

  5. 同时架设数据情报室,方便信息交换;

2.1、部署细节

因为还是 autorun 命令,所以仍然执行 A计划方案(详情参考上一篇《【用故事解读 MobX源码(一)】 autorun》)MobX 执行官的部署方案从整体上看是一样的,考虑到多了会计师这个角色的参与,所以特意在探长 获取存贷比(divisor 逻辑处空出一部分留给会计师让它自由发挥:

会计师角色位置

这样做,MobX 执行官也为了在实际行动中向他的警署长官证实该 A计划方案 的确拥有“良好的扩展性”。

解开这层新增的会计师计算逻辑 “面纱”,图示如下:

会计师计算逻辑图

你会发现历史总是惊人的相似,新增的会计师执行计算任务的逻辑其实 探长 执行任务的逻辑是一样的,下图中我特意用 相同的序号(不同的颜色形状)标示 出,序号所对应含义如下:

  1. 设置成 正在执勤人员

  2. 开始执行任务

  3. 从观察员或会计师那儿获取执行任务所需的数值,并同他们取得联系,

  4. 计算任务执行完成后,更新与观察员 O1、观察员 O2 之间的联系;

会计师执行计算任务的逻辑和探长几乎一致

此执行计算任务的逻辑,如果不告诉观察员的话,观察员还以为又来了一名“探长”上级。

从部署图里我们可以看出会计师具有两面性;

  1. 对探长而言:会计师和观察员地位差不多,都属于“下级”,都需要将自己的信息及时反馈给探长;

  2. 对观察员而言:会计师是属于 “上级”,拥有部分类似探长执行任务权力,只不过其任务类型只能是 计算类型的任务,执行任务结束之后,像探长那样和观察员互相关联起来,方便下一次的运算;

自从有了会计师的参与,探长还是那个探长,但他的下级已经不是之前的下级了。借助 A计划任务的执行,会计师 C1 在上报计算值的时候,会顺水推舟地执行计算任务,同时更新他的 ”关系网“。

2.2、 懒惰的会计师

会计师有一个特性就是比较懒:就算观察员所观察到的值变更了,他们也不会立即重新计算,而只在必要的时候(比如当上级前来索取时)才会重新计算。

举个例子,当观察员 O1 发现张三的账户存款从原来的 3 变成 6 :

1bankUser.income = 6;

这个时候会触发一系列的 “涟漪”:

  • ① 观察员 O1 先注册事务,相当于到数据情报室”上班打卡“,声明这次事件由 观察员 O1 主导

  • ② 告知其上级,也就是会计师 C1 ,说是张三存款(income)有变更

  • ③  会计师 C1 获知消息后,”慵懒地“调整自己的状态

  • ④  随后会计师 C1 继续往上级汇报,告知本会计师的值有更改(注意,此时会计师只是告诉上级自己的值有更改这一事实,但并没有执行计算任务 !)

  • ⑤  探长 R1 接收到会计师的反馈后,就向 MobX 执行官申请要执行任务!因为其下级会计师 C1 汇报说值有更改,说明这个时候应该要重新执行任务啦~

  • ⑥  执行官 MobX 调阅数据情报室信息一看,发现目前观察员 O1 正在执行事务,就让探长 R1 再等等,现在不是执行任务的最佳时机,等到事务结束再说。

  • ⑦  不一会儿观察员 O1 完成了自己的职责,”下班打卡“,在数据情报室中注销事务

  • ⑧  这个时候,执行官 MobX 才让探长 R1 开始执行任务

将上面的文字转换成流程图,可以清晰看到各角色在这次“涟漪”中所起到的作用:

会计师惰性求值

这里需要注意 3 点:

  1. 当观察员O1 汇报张三存款有更改的时候,会计师 C1 并没有立即重新计算值哦,仅仅是更改自身的状态;

  2. 会计师告知上级(探长 R1)自己有值更改,探长申请执行任务,不过 MobX 执行官并没有允许他这么做,而是让他先等待一下,因为此时 观察员 O1 还在汇报工作。等观察员 O1 工作汇报完毕,这个时候才让探长执行任务。因为有可能有其他计算组职员也正在响应该观察值的更改,事情一件一件来,不要着急,这和 debounce 思想一致,减少不必要的计算。

  3. 只有在最后探长执行任务时 需要用到会计师的值的时候,会计师才会去执行计算操作。这就是典型的惰性求值思维。

会计师这种拖延到 只有被需要的时候才进行计算 的行为,有没有让你回忆起学生时代寒假结束前一天疯狂补作业的场景?

疯狂补作业的场景

2.3、避免不必要的计算

当执行官 MobX 拿着这份执行报告送达给你(警署最高长官),阅览完毕:”不错,这套方案的确部分证实了你之前所言的可扩展性。但随着职员的引入,运转机构逐渐庞大,如何避免不必要的开销的呢?“

”长官您高瞻远瞩,这的确是一个问题。在井然有序的规则下,个别职员的运作效率的确会打折扣。因此避免职员不必要的计算开销,也是在我方案部署规划之内。正如您所见,上述方案中会计师的‘惰性’、探员在事务之后再进行任务等机制,都是基于优化性能所采取的措施。“ 执行官 MobX 稍作停顿,继续道,”为了更好地阐述这套运行方案的性能优化机制,我明天呈上一份报告,好让您得以全面了解。“

”Good Job!期待你的报告“。

那么,执行官 MobX 是凭借什么机制减少开销的呢?且听下回分解。
(本节完,未完待续)

B. Source Code Time

本节部分,仍然是就着上面的”故事“来讲 MobX 中的源码。

先罗列本文故事中新出现的 会计师 角色与 MobX 源码概念映射关系:

会计师角色映射关系

探长、执行官等角色的映射关系,参考上一篇《【用故事解读 MobX源码(一)】 autorun》

会计师对应于 MobX 中的 ComputedValue

本文的重点内容就是 computedvalue 的部分源码(它在 autorun 等场景中的应用)

autorun(A 计划)的源码在上一节讲过,这里不再赘述。我们仅仅讲解一下 computedValueautorun 中的表现。

1、会计师,请开始你的表演

在故事中我们讲到过,当探长向会计师索要计算值的时候,此时懒惰的会计师为了 ”应付交差“,这时候才开始计算,其计算的过程和探长执行的任务流程几乎一致。

从源码角度去看一下其中的原因。

当探长执行任务:

1() => {
2  console.log('张三的贷款:', bankUser.debit, ';张三的存贷比: ' + divisor);
3}

任务中也涉及 bankUser.debit 变量和 divisor 变量;其中在获取 bankUser.debit 变量之时会让观察员 O2 触发 reportObserved方法,这个上一篇文章着重讲过,此处就不详细展开了;而请求 divisor 数值的时候,则会触发该值的 valueOf() 方法 —— 即调用会计师(computedValue)的 valueOf() 方法。

为什么调用就触发 valueOf() 方法呢?请看下方的“知识点”备注?

======== 插播知识点 =========

任何原始值还是对象其实都包含 valueOf()toString() 方法,valueOf() 会返回最适合该对象类型的原始值,toString() 将该对象的原始值以字符串形式返回。
这两个方法一般是交由 JS 去隐式调用,以满足不同的运算情况。比如在数值运算(如a + b)里会优先调用 valueOf(),而在字符串运算(如alert(c))里,会优先调用 toString() 方法
顺带附上两篇 参考文章

  • js中 toString 和 valueOf 的区别?:知乎问答

  • valueOf() vs. toString() in Javascript:SF 上的回答,非常详尽地告诉你其执行结果

======== 完毕 ==========

一旦调用调用会计师的 valueOf 方法:

1valueOf(): T {
2    return toPrimitive(this.get())
3}

其实就是调用 this.get() 方法,我们瞧一眼源码;

会计师(computedValue)的 valueOf() 方法

1.1、 重量级计算 还是 轻量级 计算?

这里有个分叉点,根据 globalState.inBatch 决定到底是启用 重量级计算 还是 轻量级计算

  • globalState.inBatch 值大于 0,说明会计师被上级征调(处于上级事务中),比如此案例中,陷于 A 计划(autorun)的会计师,在上级探长 R1 需要查阅计算值时候,就会进入重量级计算模式

  • 当会计师无上级征调的时候,globalState.inBatch 值为 0,就会进入轻量级计算模式,简化计算的逻辑。

但无论轻量级还是重量级计算,都会涉及到调用 computeValue() 方法来执行计算任务。

调用的时候,如果是 重量级计算track 这个 bool 值为 true,否则track 值为 false

区分轻量级和重量级计算的差别

计算值有个属性,this.derivation 就是会计师要计算数值时所依据的计算表达式,也就是而我们定义会计师时所传入的匿名函数:

1() => {
2  return bankUser.income / bankUser.debit;
3}

无论是 重量级计算 模式还是 轻量级计算 模式,最终都是会调用该计算表达式获取计算值

重量级计算 模式和 轻量级计算 模式两者的差别只是在于前者在执行该计算表达式之前会设置很多环境,后者直接就按这个表达式计算数值返回。

在上述的故事中,由于探长 R1 人物的存在,会计师会执行 重量级计算 模式,接下来的源码分析也走这条分支路线。( 轻量级计算 模式的情况当做课后思考题)。

1.2、像探长学习

重量级计算的时候,computeValue(true) 就会走和  探长 操作模式一样 trackDerivedFunction 步骤。没错,探长和会计师调用的就是同一个方法,所以他们在执行任务的时候,行为痕迹是一样的,没毛病。

重量级计算时和探长执行的计算过程类似

如果忘记 trackDerivedFunction 方法内容,请查看 《【用故事解读 MobX源码(一)】 autorun》的 ”2.2.2、trackDerivedFunction“ 部分

只不过会计师只能执行计算类的任务(纯函数)罢了,探长可以执行任意类型的任务。

和探长一样,会计师执行计算任务完毕之后调用 bindDependencies 将绑定 观察员 O1 和 观察员 O2 ;而在执行计算之后,会计师会调用 propagateChangeConfirmed 方法,更改自己和上级 探长 的状态 —— 这说明,对探长而言,会计师就相当于 观察员的角色,在探长执行任务结束后像观察员一样需要上报自己的计算值,并和 探长 取得联系;

这么看会计师还真 ”墙头草,两边倒”。

至此,会计师这个角色以较低的成本就能完美地整合进执行官 MobX 所部署的 A 集合部署方案中。??

2、 响应观察值的变化

一旦张三的账户存款(income)发生变化,将会触发 MobX 所提供的 reportChanged 方法:

1  public reportChanged() {
2      startBatch()
3      propagateChanged(this)
4      endBatch()
5  }

注意这里的 startBatchendBatch 方法,说明观察员 O1 发起事务了。

2.1、传递变化的信息

我们知道(不知道的请阅读上一篇文章)该 reportChanged() 方法中的 propagateChanged() 会触发上级的 onBecomeStale() 方法。

观察员 O1 此时的上级是 会计师 C1,其所定义的 onBecomeStale 如下:

1onBecomeStale() {
2    propagateMaybeChanged(this)
3}

看一下 propagateMaybeChanged(this) 源码,也比较简单,主要做了两件事情,① 会计师会调整自身的状态;②然后触发其上级(探长 R1)的 onBecomeStale() 方法。

调用 onBecomeStale 方法

可见观察员 01 会引起会计师 C1 的响应,而会计师会引起探长 R1 的响应,这种响应“涟漪”就是通过下级触发上级的 onBecomeStale 方法形成的连锁反应。

不同上级(比如会计师和探长)的 onBecomeStale 定义不同。

探长的这个 onBecomeStale 方法在上一篇文章的 “3、响应观察值的变化 - propagateChanged”  中我们讲过,探长将请求 MobX 请求重新执行一遍 A 计划方案。

然而,MobX 拒绝了这次请求,让他再等待一下。??

这是因为在 runReactions 方法中:

1if (globalState.inBatch > 0 || globalState.isRunningReactions) return

由于此时 inBatch 是 1(因为观察员执行了 startBatch()),所以会直接 return 掉。

直到观察员执行 endBatch() 的时候,除了会结束本次的上报事务,同时执行官 MobX 会重新执行 runReactions 方法,让久等的探长去执行任务:

endBatch 来结束本次事务

探长在执行任务的时候,就会打印张三的贷款(debit)、存贷比(divisor)了。

2.2、虽然懒,但是懒得有技巧

综上,当张三存款(income)变更,就能让 A 计划(autorun)自动运行,探长会打印张三的贷款(debit)、存贷比(divisor)。

这里需要提及一下,关于会计师重新计算的时机,是在探长执行 shouldCompute 的时候,探长发现会计师值 陈旧 了,就让会计师重新计算:

在 shouldCompute 中进行精细化判断

看看这里,对计算值而言,isComputedValue()(如果是计算值)返回 true,就会执行 obj.get() 方法,这个方法刚才刚讲过,会让会计师执行 重量型计算操作,更新自己的计算值。

所以,这次计算时机并非等到探长执行任务时(真正用到该值)的时候才让其重新计算,和第一次 autorun 的时机不一致

估计这是 MobX 考虑到会计师的值肯定需要更新的(已经确定要被探长 R1 用到),还有可能会被其他上级引用,既然迟早要更新的,那就尽可能将更新前置,这样在整体上能降低成本。

更新完之后,在探长执行任务的时候,会计师汇报自己是最新的值了,就不用再重新计算一遍。

虽然懒,但是懒得有技巧。

至此,有关会计师的源码解读已经差不多,后续有想到的再补充。

3、其他说明

本文为了方便说明,所以单独使用 mobx.computed 方法定义计算值,平时使用中更多则是直接应用在 对象中属性 上,使用 get 语法:

1var bankUser = mobx.observable({
2  income3,
3  debit2,
4  get divisor() {
5    return this.income / this.debit;
6  }
7});

这仅仅是写法上不一样,源码分析的思路是一致的。

4、小测试

4.1、测试1

问题:当我们更改张三贷款数额 bankUser.debit = 4; 时,请从源码角度解答 MobX 的执行流程是如何的?

参考答案提示

1reportChanged() 
2    => propagateChanged() 
3    => propagateMaybeChanged() 
4    => runReaction() 
5    => track() 
6    => get() 
7    => computeValue() 
8    => bindDependencies()

4.2、测试2

问题:如果不存在 autorun (即没有探长参与,仅有观察员和会计师),此时仅改变张三存款数值:

 1var bankUser = mobx.observable({
2  income3,
3  debit2
4});
5
6var divisor = mobx.computed(() => {
7  return bankUser.income / bankUser.debit;
8});
9
10bankUser.income = 6// 请问此时的执行情况是什么样的?
11
12console.log('张三的存贷比:', divisor)

请问会计师会重新计算数值么?此时这套系统的执行情况又会是怎么样的呢?

参考答案提示:会计师此时执行 轻量级计算模式

5、小结

此篇文章讲解 MobX 中 计算值 (computedValue) 的概念,类比故事中的会计师角色。总结一下 计算值 (computedValue)的特征:

  1. 计算值是基于现有状态或其他计算值衍生出的数值,一般是通过 纯函数 的方式衍生而得。

  2. 一旦观察值更改之后,计算值是能够重新执行计算,不过并非立即执行,而是 惰性 的 ———— 只有在必要的时候才会执行计算。

  3. 对观察值而言,计算值和 autorun(或reaction) 很像,之所以相似是在 执行任务 时都涉及到调用 trackDerivedFunction 方法;而对 autorun(或reaction)而言,计算值和观察值很相,都是数据提供者。

正如 官方文档 而言,计算值是高度优化过的,所以尽可能应用他们。

官方文档对计算值的说明

下一篇文章将探讨 MobX 中与 autoruncomputed 相关的计算性能优化的机制,看看 MobX 如何平衡复杂场景下状态管理时的效率和性能。

往期精彩回顾
从 URL 输入到页面展现到底发生了什么
你未必知道的 49 个 css 知识点

用故事解读 MobX 源码(一)autorun


“阅读原文”,获取原文地址(能查阅外链)
在看”的永远18岁~