[译] MobX 背后的基础原理

2020-06-16 17:12:05 浏览数 (1)

本文由 MobX 作者 Michel Weststrate 所著,原文: https://hackernoon.com/the-fundamental-principles-behind-mobx-7a725f71f3e8

不久之前 Bertalan Miklos 写了一篇很好的博文,比较了 MobX 和基于 proxy 的 NX-framework。这篇博文不仅证明了 proxy 的可行性,更好之处在于其触及了 MobX 中一些非常基础但通常又被隐藏的概念。迄今为止我还尚未详细阐述过这些概念,所以本文将分享一些 MobX 特性背后的心路历程。

为什么 MobX 同步的运行所有派生过程

那篇文章触及了 MobX 一个非常显著的特性(恕我直言):在 MobX 中,所有派生(derivation)都是同步运行的。这十分不寻常,因为如果也有派生,大部分 UI 框架并不这样做(像 RxJS 那种反应式/流式的库默认也是同步运行的,但它们缺少透明的跟踪,所以这种情形不完全有可比性)。

在开发 MobX 之前,我花了好些个工夫研究开发者如何看待现有的库。像 Meteor、Knockout、Angular、Ember 和 Vue 这样的框架都显露了与 MobX 类似的反应式行为,且都已经存在很久了。那为什么我要建立 MobX 呢?当翻遍了人们关于这些库的不满 issues 和评论后,我发现了一个重复出现的主题,造成了对反应式的预期和实践中不得不应对的糟糕问题之间的分歧。

那个频现的主题就是“可预测性”。如果框架运行了你的代码两次,或者延迟一下再运行,就变得难以调试了。或者可能的原因是,即便如 Promise 这样“简单的”抽象,也因为其天然的异步性而众所周知的难以调试。

我接受不可预测性的存在,挺正常的,对于 Flux 模式特别是 Redux 来说之所以流行的最重要的原因之一便是:它精确处理了规模变大时的可预测性问题,除此之外并无任何神奇之处。

MobX 则另辟蹊径;与停留在整个自动化追踪并运行函数的概念背后不同的是,尝试去定位根本的问题,以便我们始终能从这种模式中收益。透明的反应式是声明式、高阶和简洁的。为此增加了两个约束:

  1. 确保对于给定的突变集合,任何受影响的派生都只运行一次。
  2. 保证派生是新鲜的,其效果对任何观察者立即可见。

约束1:所谓的 “双执行”。 确保如果一个派生值依赖于另一个派生值的时候,这些派生以正确的顺序进行,以杜绝其中任何一个偶然读取到过时的值。这种机制如何运行的细节在此前一篇 博文 中描述过。

约束2:派生不能陈旧,就更有意思一些。不只是其提供了所谓 “glitches” (暂时的不一致),还因为其引入了一种不同的调度派生的基础手段。

迄今为止的 UI 库往往采用省事的办法调度派生:给派生做脏标记,并在所有状态都被更新后的下一个 tick 再次运行之。

这样简单又粗暴。如果只考虑更新 DOM,这是种不错的方法。DOM 总是有点“迟钝”,难以程序性的读取其数据,所以暂时的陈旧不是个事。然而暂时性陈旧会破坏反应式库的适用性。以下面的代码为例:

代码语言:javascript复制
const user = observable({
 firstName: “Michel”,
 lastName: “Weststrate”,
 // MobX computed attribute
 fullName: computed(function() {
   return this.firstName   " "   this.lastName
 })
})
user.lastName = “Vaillant”
sendLetterToUser(user)

当前有趣的问题在于:当 sendLetterToUser(user) 运行时,它会得到更新后的还是陈旧版本的 fullName 呢?在 MobX 中答案永远是“更新过的”:因为 MobX 保证了任何派生都是同步的。这不仅避免了一些意外,同时因为派生总是有在其执行栈内引起的突变,使得调试也更简单了。

所以如果你对为什么一个派生会运行抱有疑问,只要回溯执行栈找到引发派生无效的 action 即可。如果 MobX 对派生使用了异步调度/执行,则这些优点就不存在了,这个库也就不会像现在一样普遍适用了。

当我启动 MobX 项目时,要达到对派生树排序并对每个突变运行派生,存在大量是否充分可行的怀疑。

但正如我们现在所见,借助于这个系统,比手工优化代码有效得多。

事务 和 Actions

应该稍稍花费精力的是,突变应该被打包在事务中,以使得多个改变的执行是原子性的。派生的执行被推迟到事务结束时,但依然是同步执行了它们。更酷的是,如果在事务结束之前使用了一个计算值,MobX 将会保证你得到一个更新后的值!

实际上几乎没人明确的使用事务,在 MobX 3 中,事务甚至被弃用了。因为 action 自动应用了事务。action 在概念上更优雅了;一个 action 表示了一个用来更新状态的函数。而 reaction 正相反,被用来响应状态的改变。

actions、state、computed values 和 reactions 之间的概念关系

计算值 和 reactions

MobX 强烈聚焦的另一件事,是可以被推导的值(计算值)之间的分离,以及如果状态改变后(reactions)应该被自动触发的副作用。这些概念的分离是 MobX 非常重要的基础。

一个派生的例子:蓝色为可观察的状态,绿色为计算值,红色为 reactions。 浅绿色表示,如果计算值未被 reaction 观察(间接的),就会被延迟。MobX 确保在突变之后,每个派生只以最优的顺序执行一次。

计算值应该总是优于 reactions

原因有这么几个:

  1. 它们在概念上提供了很大的清晰度。计算值应该总是单纯的依据其他可观察的值表示。这导致了一个干净的计算派生图,好过一个不清晰的互相触发的 reactions 链。
  2. 换句话说,reaction 触发更多 reactions,或者 reactions 更新状态:在 MobX 中这些都被认为是反模式的。链式 reactions 将导致一个难以跟踪的事件链,应该杜绝。
  3. 对于计算值,MobX 可以感知它们是否在某处被使用。这意味着计算值可以被自动延迟并被垃圾回收。这节省了大量的引用,并对性能有显著的积极影响。
  4. 计算值被强制执行为无副作用的。因为其不被允许有副作用,MobX 就可以安全的对其执行先后重新排序,以保证重新运行次数的最小化。可以简单的认为,如果计算值未被观察,就懒运行其计算。
  5. 计算值会被自动缓存。这意味着读取一个计算值时,只要相关的可观察属性不变,就不会重新运行计算。

话说回来,每个软件系统都需要副作用,例如发起网络请求或刷新 DOM。因此我们总是需要将反应式带到命令式代码中去,不过借助 React 观察者组件这类干净的抽象可以很好的封装此类 reactions。

所以 MobX 拿捏了很好的分寸,以确保陈旧值不会被观察,且派生不会超过预期的频繁运行。事实上,如果没有活跃的监听,计算压根不会运行。实践中可能有所区别,对于 MobX 存在一些初始的阻力,因为人们习惯于 MVVM 框架的不可预测性。但是,语义清晰的 actions、计算值和 reactions,没有陈旧值可以被观察,所有派生运行在同一个栈中 -- 我相信这些事实将对一切做出改变。

Proxies 和 MobX

MobX 被广泛用于产品中,因此要承诺能在每种 ES5 环境中运行。这使得在实际浏览器中使用 MobX 成为可能,但也使得在此时支持 Proxy 无法实现。基于这个原因,MobX 有一些不完善之处,比如不完全支持 可扩展对象的动态属性(Expando properties) 并且使用了 类数组元素(faux-arrays)。一直计划最终迁移到基于 Proxy 的实现也不是个秘密了。MobX 3 已经有一些为使用 Proxy 做出的改变了,首个可选的基于 Proxy 的特性指日可待。但核心部分将保持非 Proxy,直到绝大多数设备和浏览器支持它。

浅数据结构的情况

不管以后是否要迁移到 Proxy 的实现, modifiers / shallow observable 这些概念都会以某种形式保留在 MobX 中。

保留 modifiers(译注:即 observable.deep、observable.ref、observable.shallow、observable.struct 这些修饰符) 机制的原因并非考虑性能,而是互操作性。

当应用状态中的所有数据都在控制中的时候,自动可观察性是非常方便的,MobX 也是基于此开始开发的。但有时你会发现世界不如你期望的那么理想。每个应用中都有若干个库,每个库按自己的规则行事、执行自己的设想。modifiers 和 shallow collections 被 MobX 引入,以便清晰的区分哪些数据可以被 MobX 管理。

比如,有时需要存储对外部概念的引用。但是,将外部库管理的对象(如 JSX 或 DOM 元素)自动转换为可观察对象经常是不符合期望的,这很容易将内部假设引入外部库。可以轻易的在 MobX 问题追踪器中找出一些无意间将对象转为可观察对象引起的非预期行为的问题。modifiers 不是“尽快把这个弄好”的意思,而是表示“只观察对象的引用,将对象本身视为超出控制的黑盒子”。

这种概念在处理不可变数据类型的时候也非常合适。一个可行的例子是,创建一个可观察的消息 map,消息本身是不可变数据结构的。

第二个问题是自动可观察集合总是创建“克隆”,这并不总是可以接受的。Proxy 总是产生一个新对象,并只以“一个方向”工作。如果由最初的库改变了一个 proxy 对象的原始对象值,则 proxy 无法知道这个改变。如下:

代码语言:javascript复制
const target = { x: 3 }
const proxy = createObservableProxy(target)
observe(() => {
console.log(proxy.x)
})
target.x = 4
// proxy.x 现在是 4, 但是没有log被打印,就是说 proxy 的 setter 没有被调用!

modifiers 提供了应对这些情形的必要灵活性。因为 MobX 当前使用属性描述符(property descriptors),也就能实际的影响既有对象,所以的确需要的话,数据突变可以双向工作。

也就是说,NX 在读取期间即时生成可观察 proxy 的方式超级有趣。我还不太确定它是如何处理引用透明性的,但目前看上去做的非常聪明。借助读写 $row 避免 modifiers 是非常有趣的做法。我拿不准这样是否能清楚的读写,但无疑这省去了介绍类似 shallow 这些概念时的成本。

untracked 做了什么?

关于 untracked 的语义有一个必要的小提示,就是不像推断那样,它和 NX 的升级 $row 并不相干。在 MobX 中不通知观察者就无法升级数据,也会引入在应用中存在过期数据的可能性,这就违背了 MobX 的理念。人们有时希望有这种机制,但我还没遇到过概念上无法解决的实际用例。

untracked 反其道而行之:不关心无法探测的写操作,而是只将读操作变为不跟踪的。换句话说,这种方式意味着我们毫不关心所用数据在未来的更新。和 transaction 一样,很少在实际中用这个 API,但是这种 action 中的处理机制在概念上非常有意义:action 运行以响应用户事件,而非状态改变,所以它们不应跟踪其使用的数据 -- 那些事是 reaction 要做的。

总结

MobX 被设计为一种通用应用反应式库,而不只是用来重新渲染 UI 的工具集。

相反,它推广了一种有效工作(兼具性能和效果)的概念,那就是数据应该尽量由其他数据推断出来。MobX 用在后端进程中也游刃有余。同步运行推断,以及将计算值和 reaction 分离开来是 MobX 的基础,这引导了应用状态解构变得更清晰。

最后,nx-observe 证明了 proxy 是透明反应式编程库非常可行的基础,概念上和性能上都是如此。

0 人点赞