最近在搜集MobX相关资料,意外的找到MobX作者一篇深度解析MobX工作机制的blog,看过之后受益匪浅。本篇文章将结合MobX源码来整理并理解这篇blog的内容。这篇博客主要是想为读者解读MobX的实现机制,其中穿插介绍了一些作者的设计思路。
几个重要的概念
首先,作者先澄清了几个MobX中的重要概念
- Observable State, 所有可以改变的值。
- Computed Value(又称Derivation), 可以通过Observable State直接计算(pure function)得出的值。
- Reaction, 与Computed Value类似也是基于Observable State 。但是不是返回一个结果,而是产生一个动作(side effects)
- Action, 所有修改Observable State的动作
这几个概念的关系如下图:
这个图解释了,在MobX体系里各个角色的作用。
首先是发生一个Action修改State,接着State的更新会自动触发与其相关联的Derivation 和Reaction。
需要注意的是Derivation在这张图中的双重角色。在观察者模式视角下,它不仅是observable也是observer。或者说对于State来说,它是一个observer监视State的变化;但是对于Reaction来说,它可能还是一个observable,它的变化会引发Reaction发生。
一个简单的示例
接着作者给出了一个使用MobX的例子,来说明这些概念。
classPerson { @observablefirstName="Michel"; @observablelastName="Weststrate"; @observablenickName; @computedgetfullName() { returnthis.firstName+" "+this.lastName; } } constmichel=newPerson(); // Reaction: log the profile info whenever it changesautorun(() =>console.log(person.nickName?person.nickName : person.fullName)); // Example React component that observes stateconstprofileView=observer(props=> { if (props.person.nickName) return<div>{props.person.nickName}</div>elsereturn<div>{props.person.fullName}</div>}); // Action:setTimeout(() =>michel.nickName="mweststrate", 5000) React.render(React.createElement(profileView, { person: michel }), document.body);
这个例子的逻辑很简单,通过判断Person对象是否存在nickName属性来决定展示在界面(profileView)上的内容(nickName还是fullName)。
它们的依赖关系大致如下图:
如果你仔细再看这张图中的fullName,大概你会更清楚为什么我之前说Derivation有着双重角色。实际上Derivation的特殊性也体现在实现里。
在MobX实现里,充当observable角色的类都会有一个lowestObserverState标示当前的状态(Stale,UP_TO_DATE等)。(像之前文章里提到的observable体系里类例如ObservableValue,ObservableArray都有这样的属性。) 同样的observer也会有一个dependenciesState标示当前状态。 然而ComputedValue类却同时拥有这两个属性。
动态更新依赖
回到刚刚的示例中,在最后我们触发了一个Action。
// Action:setTimeout(() =>michel.nickName="mweststrate", 5000)
为Person对象添加nickName 属性。所以界面profileView也会发生相应的变化。此时的依赖关系已经发生了微妙的变化。
profileView已经不再依赖或者说监视fullName。如果这时,firstName或者fullName发生了变化,也不会对profileView有任何影响。
这就是MobX里的动态更新依赖。这样的设计,好处在于保证observer只依赖于它需要的依赖。永远也不会出现undersubscribe (forgetting to listen for updates leading to subtle staleness bugs)或者oversubscribe (continue subscribing to a value or store that is no longer used in a component)。
MobX能实现依赖动态更新,是因为它的依赖关系是由框架在运行时计算得到的。或者说,这个依赖关系并不是由用户在写代码时手动的去关联起来,而是由框架自己在运行时自动确定的。
比如在示例中,当执行profileView部分时:
constprofileView=observer(props=> { if (props.person.nickName) return<div>{props.person.nickName}</div>elsereturn<div>{props.person.fullName}</div>});
它首先判断是否存在nickName属性,如果不存在就返回fullName属性,在执行profileView部分时就用到了nickName和fullName两个observable,所以现在profileView就依赖于nickName和fullName
但是注意fullName可不是一个普通的observable,它是一个computed value。同样执行fullNamegetter部分:
@computedgetfullName() { returnthis.firstName+" "+this.lastName; }
所以fullName又依赖于firstName和lastName。
整个过程如下图:
这样我们的依赖关系就形成了。
这里体现出了MobX的设计思想:
Reacting to state changes is always better than acting on state changes.
这句话初看时似懂非懂,reacting to change和acting on change似乎没什么差别。实际上这里一个细微的用词决定change发生后动作的主动权归属。acting的主动权在用户,动作是否发生,发生哪些动作都由用户来决定(依赖关系由用户手动订阅);而reacting的主动权在框架(依赖关系由框架自动生成)。
同样当Action被触发,profileView重新计算自己的依赖关系,这次nickName存在,不会用到fullName,所以profileView依赖更新,只依赖于nickName.
这一部分的最后提一下这个依赖更新的算法。本质上是一个两个数组无重复合并的算法,MobX的实现把这个算法的时间复杂度降到了o(n)。具体可以参考这篇文章。
从懒加载到MobX响应机制
让我们再回到这张图,不知道你是否注意到此时,fullName不再被监视时,它的状态已经被修改为lazy。 这代表着,如果现在修改firstName后者lastName,fullName不会立即执行。这就是MobX的性能优化之一——懒加载机制。
想要深入理解懒加载机制是如何实现的,需要我们首先明白MobX是如何响应变化的。当一个action产生时MobX内部发生了什么。
仍然拿之前的例子来说明,假设这样一个场景,依赖关系仍然是完整时(profileView仍然依赖fullName),此时firstName被修改。
- action发生,firstName值被修改,firstName状态变更为stale
2. firstName通知它的observer即fullName,fullName状态变更为possible change
3. fullName通知它的observer即profileView,profileView状态变更为possible change。
4. MobX监控到所有的状态变更和修改都已经完成
5. MobX通知fullName开始执行, fullName通过firstName的新值重新计算自己的值,fullName修改firstName和自己的状态为new
6. 这里出现了一个分支,通过fullName的新值和旧值做比较
6.1 如果fullName新值和旧值相同,即fullName其实没有发生变化,则直接修改profileView状态为new,不需要执行
6.2 如果fullName确实发生了变化,则profileView重新执行,并且修改自己的状态为new
至此一次完整的更新结束。
如果把上面的过程做一个小结,我大致可以把它分为两个阶段:
- 冒泡阶段,即被修改的observable去通知它的observer修改状态,这个过程是级联的(或者说是递归发生的) 。在冒泡阶段除了修改状态,实际上并没有发生其他事情。
- 执行阶段,当冒泡阶段结束,所有的状态都已经被修改完成后,开始执行需要的操作。
为什么一定要分成两个阶段而不是修改状态使立即执行呢?
这实际上也是MobX性能优化的一个设计。它的思路实际就是批处理。因为上面的例子只是一个非常简单的更新。当有多个action一起发生的时候,所以如果每次都立即执行,可能每个action都导致同一个computed value会需要重复执行很多次,求出很多无用的中间结果。 而等到所有状态更新和action都结束后,再执行求值,就只需要执行一次求出最终的结果。
到这里,我比较在意的是MobX的实现是怎么区分这两个阶段的。它怎么知道什么时候冒泡阶段完全结束了需要开始执行阶段论。这牵扯到MobX的另一个概念—— trasnaction
。
transaction在数据库中经常出现的概念,在数据库中它定义了一个原子性的操作,并且用它来管理并发访问。
但是MobX里的transaction不是干这个的。它被引入的目的就是为了标注出一次完整的更新冒泡阶段的开始和结束。在一个tranaction内是不会执行reaction或者computed value。
/** * During a transaction no views are updated until the end of the transaction. * The transaction will be run synchronously nonetheless. * * @param action a function that updates some reactive state * @returns any value that was returned by the 'action' parameter. */exportfunctiontransaction<T>(action: () =>T, thisArg=undefined): T { startBatch() try { returnaction.apply(thisArg) } finally { endBatch() } }
也就是说,在一个当endBatch()被调用后,MobX就知道冒泡阶段结束了,可以开始执行阶段了。
这里还要多说一句,transaction是一个底层的API,它在上层的封装就是@action。这也是为什么MobX的最佳实践里要求你把所有的state修改都加上action,实际就是把多个修改放到一个transaction里,batch update。
还有一点是执行阶段时的执行顺序。
这里的顺序让我联想到了构建一个最小堆(或者最大堆)时的shift down的过程,在建立一个堆时需要保证它的子树满足最小堆的规则。所以会从n个结点的完全二叉树最后一个分支节点floor( (n-2) / 2 )开始执行shif down。
而执行阶段也与之类似,它需要保证从整个依赖图(注意不是树)中最底层的一个父节点开始执行(实际就是按照拓扑排序的顺序)。因为执行的规则是保证当前节点所有子节点的值是最新的。
这个顺序是如何确定的呢?在冒泡阶段,MobX会把经过的Reaction放到一个待执行队列里。执行阶段就直接从队列里取出执行。注意我这里说的是Reaction而不包括Computed Value。所以fullName是不会加入队列的。
但是我刚刚一直在说执行阶段会先执行fullName。这其实并不矛盾。在执行阶段开始,MobX拿到profileView后,检查它的所有依赖,结果找到了fullName是处于possible change状态。它当然会先确定fullName的值,然后才能确定自己的值。
还记得我之前提到的懒加载吗?它就是这样实现的。因为Comupued Value不会加入队列里而是通过Reaction检查依赖获取的。所以如果Computed Value 没有任何observer依赖,就不会有Reaction能到达它。它就当然不会被执行了。只有当以后某个机会,它又与某个Reaction关联起来后,那时才会被执行。
所以懒加载的本质是只有冒泡阶段没有执行阶段。
小结
本篇文章介绍了MobX里的依赖动态更新机制以及变更响应机制的设计与实现。