【译】深入理解Mobx

1,484 阅读10分钟

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/3/8/16959647b2e6a1d2~tplv-t2oaga2asx-image.image

首先,先让我们看一下Mobx的核心概念。

  1. 可观察状态(Observable state)。 任何可以变异且可以作为计算值源的值都是state。Mobx可以开箱即用地使绝大多数类型的值(基本类型,数组,类,对象)变成可观察的(甚至深度观察)。

  2. 计算值(Computed values)。 任何可观察的值可以使用纯函数计算得出任何值。计算值的范围可以从简单的字符串到复杂的对象甚至dom操作上。计算值会懒惰地对状态变化做出反应。

  3. 反应(Reactions)。 反应有点类似于计算值,但是它不产生新值,而是作为桥梁衔接了响应式编程和命令式编程,产生一个副作用(I/O操作),例如打印到控制台,发送网络请求,更新dom树等。

  4. 操作(Actions)。 操作是改变状态(state)的主要手段。操作不是状态改变后的反应,是改变的来源,例如用户事件或web-socket连接,用以改变可观察的状态。

计算值和反应在本文章的后续中都称为衍生(derivations)。到目前为止,这听起来可能有点学术性,所以,我们让它具体点。在excel电子表格中,所有具有值的数据单元格都是可观察状态的,公式和图标是可以从数据单元格和其他公式衍生的计算值。在屏幕上绘制数据单元格或公式的结果就是一个反应(reaction),改变数据单元格或公式就是一个操作(action)

下面这个例子结合了Mobx和React,并且包含了以上4个概念:

class Person {
  @observable firstName = "Michel";
  @observable lastName = "Weststrate";
  @observable nickName;
  
  @computed get fullName() {
    return this.firstName + " " + this.lastName;
  }
}

const michel = new Person();

// Reaction: log the profile info whenever it changes
autorun(() => console.log(person.nickName ? person.nickName : person.fullName));

// Example React component that observes state
const profileView = observer(props => {
  if (props.person.nickName)
    return <div>{props.person.nickName}</div>
  else
    return <div>{props.person.fullName}</div>
});

// Action:
setTimeout(() => michel.nickName = "mweststrate", 5000)

React.render(React.createElement(profileView, { person: michel }), document.body);
// This snippet is runnable in jsfiddle: https://jsfiddle.net/mweststrate/049r6jox/
view rawprofile.jsx hosted with ❤ by GitHub

我们可以画一张依赖图:

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/3/8/169595a55efe3011~tplv-t2oaga2asx-image.image
Figure 1:profileView组件的依赖关系树。fullName处于reactive mode,主动观察firstName和lastName

这个应用中,状态由可观察状态捕获(蓝色图标)。绿色的fullName是计算值,由可观察状态firstName和lastName自动衍生得出。同样,profileView由nickName和fullName衍生得出。profileView将通过产生副作用来响应状态更改——它更新React组件树。

当使用Mobx时,依赖关系树被最低限度定义。举个例子,一旦profileView的有了nickName,且渲染不再受fullName的值的影响,也不受firstName和lastName的影响,那么他们之间所有的观察者关系将被清除,Mobx将自动的简化依赖树,如下图:

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/3/8/169595a55e6d593d~tplv-t2oaga2asx-image.image
Figure 2:当profileView具有nickName时的依赖关系树,与Figure 1相反,fullName现在处于lazy mode,且不再观察firstName和lastName

Mobx总是使用最小化计算次数去产生状态。接下来,我将介绍用于实现此目标的几种策略,但在深入了解计算值和反应如何与状态同步之前,让我们首先描述Mobx背后的原理:

对状态变化做出反应总是比对状态变化做出动作要好。(Reacting to state changes is always better then acting on state changes.)

应用程序响应状态更改的必要操作是通常会创建或更新一些值,大多数的操作(actions)管理着本地缓存。dom更新、批量更新值、请求后端,这些都可以被认为变相地使缓存失效。要确保这些缓存保持同步,你需要订阅(subscribe)未来的状态更改,以便再次触发你的操作。

但是观察者模式有一个基本的问题:当你的应用变大时,你可能会犯错,比如依然订阅不再使用的值或忘记订阅一些值。

像flux风格的订阅很容易出现这种超额订阅的情况。使用React时,你可以通过在渲染中打印来判断你的组件是否被超额订阅了。Mobx会将打印出的超额订阅数减少到0。这个想法很简单但违反直觉:订阅越多,重新计算越少。Mobx为你管理数千个观察者,你可以有效地权衡内存的CPU周期。

超额订阅也以非常微妙的形式存在。如果你订阅了使用的数据,但并未在所有条件下订阅,那么你依然需要超额订阅。例如,如果profileView组件订阅了fullName,并且profileView有nickName,这就将超额订阅。因此Mobx设计背后的一个重要原则是:

在运行时才能实现最小、一致地订阅子集(A minimal, consistent set of subscriptions can only be achieved if subscriptions are determined at run-time)。

Mobx背后的第二个重要思想是,对于任何比TodoMVC更复杂的应用程序,通常需要一个数据图而不是规范化的树,以一种最佳方式存储状态。数据图可以实现参照一致性并避免数据重复,从而保证衍生值永远不会过时。

Mobx如何有效地将所有衍生保持在一个一致地状态?

答案是:不缓存,只有在需要时再重新计算衍生。这不是很昂贵吗?Mobx认为不是,反而这是很高效地。原因是Mobx不会运行所有衍生,但确保参与reaction的computed values与可观察状态保持同步。这些衍生被称为响应式的。再次以excel举例:只有当那些被观察着的当前可见或被间接使用的公式发生变化时,才会去重新计算值。

Lazy versus reactive evaluation

那么反应没有直接或间接使用的计算呢?你依然可以随时检查计算值的值,如fullName。答案是简单的:如果一个计算不是reactive的,它将被按需处理。就像一个普通的getter函数一样。懒衍生如果没有用了,将被简单的垃圾回收。记住computed values总是需要使用纯函数,因为对于纯函数而言,它是懒衍生还是直接使用并不重要。在相同的可观察状态下,computed values总是给出相同的结果。

运行计算

Reaction和Computed values在Mobx中都以相同的方式运行。当重新计算被触发时,该函数将被压入到衍生堆栈中。只要计算正在运行,每个被访问的observable都会将自身注册为衍生堆栈最顶层函数的依赖项。当computed value被需要了,如果该值处于reactive状态,则该值可以简单的是最后已知的值。否则它将push自己到衍生堆栈中,切换到reactive模式并开始计算。

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/3/8/169595a56a1bb8ef~tplv-t2oaga2asx-image.image
Figure 4:profileView执行期间,一些observable state和computed values被观察着。computed values可能重新计算,这会产生依赖关系树,如Figure 1所示。

当一个计算完成后,将得到在执行期间访问的可观察列表。在profileView的例子中,这个list将只包含nickName或nickName和fullName属性。任何被移除的属性都将不再观察(此时computed values可能会从反应模式返回到惰性模式),任何被添加的可观察属性将被观察,直到下一次计算。例如,将来将更改firstName的值,fullName将会知道自己该被重新计算,从而profileView会重新计算。接下来会详细解释这一过程。

Propagating state changes

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/3/8/169595a564c1e714~tplv-t2oaga2asx-image.image

Figure 5:更改值1对依赖关系树的影响。虚线表示将被标记为旧的观察者。数字表示计算的顺序。

衍生将自动对状态变化做出反应。所有反应同步发生,更重要的是无瑕疵。修改可观察值时,将执行以下算法:

  1. 可观察值向所有观察者发送过时通知,表明它已变得陈旧。任何受影响的computed values将以递归方式将通知传递给其观察者。因此,依赖关系树的一部分将被标记为陈旧。以Figure 5为例,当值1改变时观察者将变成陈旧的,并用橘色虚线标记。所有的衍生都可能被变化的值影响。

  2. 在发送陈旧通知并且存储新值之后,一个就绪通知将被发送。用于指示该值是否确实发生了变化。

  3. 一旦衍生收到步骤1中收到的每个陈旧通知的就绪通知,它就会知道所有的被观察值都稳定了,于是将开始重新计算。计算 就绪/陈旧 消息的数量将确保这一点。例如,计算值4将仅在计算值3变得稳定后重新计算。

  4. 如果没有就绪消息指出一个值变化了,衍生将直接告诉自己的观察者它已经准备好了且没有变化中的值,否则将重新计算并发送一个就绪消息给自己的观察者。执行顺序如Figure 5所示。注意,如果计算值4重新评估但没有产生新值,则最后一个“-”将永远不会执行。

前两段总结了如何在运行时跟踪可观察值和衍生之间的依赖关系以及变化在衍生中是如何传播的。此时你会发现reaction基本上就是一个始终处于反应模式的computed value。**重点:这个算法可以非常有效地实现而不需要闭包,只需要一堆指针数组。**另外,Mobx还应用了许多其他优化,这些优化超出了本文的范围。

同步执行

人们常惊讶于Mobx同步运行所有内容。这有两大好处:第一点是不可能观察陈旧的衍生。因此,在更改影响它的值后,可以立即使用衍生值。第二点是这让追踪堆栈和调试变得简单,它避免了Promise/Async库所特有的无用堆栈跟踪。

transaction(() => {
  michel.firstName = "Mich";
  michel.lastName = "W.";
});

(事务示例,它确保没有人能追踪到像Michaaa这样的中间值)

同步执行还引入了对事务的需求。如果立即连续应用几个突变,在应用所有更改后,最好重新评估所有衍生。在transaction中包装action能实现这个目的。事务推迟所有就绪通知,直到事务块执行完成。请注意,事务仍然同步运行和更新所有内容。

这总结了Mobx最基本的实现细节。这没有涵盖所有内容,但是很高兴你可以组合你的computed value了。通过组合reactive computations,甚至可以自动的将一张数据图转换为另一张数据图并用最少的补丁数保持最新的衍生,这使得实现复杂模式变得简单。

总结

  1. 复杂应用程序的状态最好用图表表示,以实现参考一致性,更接近问题核心的心理模型。

  2. 不应该使用手动定义的订阅或游标来强制更改状态,这将不可避免的导致由于订阅不足或超额订阅导致的错误。

  3. 使用运行时分析来确定最小的observer->observable的关系,这导致了一种计算模型,可以保证在没有观察过期值的情况下运行最小量的衍生。

  4. 任何不需要实现有效副作用的衍生都可以完全优化。

原文地址: hackernoon.com/becoming-fu…