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

20

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

=======================================

A. Story Time

最高警长看完执行官(MobX)的自动部署方案,对 “观察员” 这个基层人员工作比较感兴趣,自执行官拿给他部署方案的时候,他就注意到所有上层人员的功能都是基于该底层人员高效的工作机制;

第二天,他找上执行官(MobX)一起去视察“观察员”所在机构部门(下面简称为 ”观察局“),想更深入地了解 “观察员” 运行分配机制。

observable

当最高警长到达部门的时候,恰好遇到该部门恰好要开始执行 MobX 前不久新下发的任务,要求监控 parent 对象的一举一动:

var parent = {
  child: {
    name: 'tony',
    age: 15
  }
  name: 'john'
}

var bankUser = observable(parent);

任务达到观察局办公室后,相应的办公室文员会对任务进行分析,然后会依据对象类型交给相应科室进行处理,常见的有 object 科,另外还有 map 科和 array 科;

现在,办公室文员见传入的对象是 parent 是个对象,就将其传递给 object 科,让其组织起一起针对该 parent 对象的 ”观察小组“,组名为 bankUser

object 科接到任务,委派某位科长(以下称为 bankUser 科长)组成专项负责此 parent 对象的观察工作,bankUser 科长接手任务后发现有两个属性,其中 child 是对象类型,age 是原始值类型,就分别将任务委派给 child 小科长 和 name 观察员 O1,child 小科长接到任务后再委派给 name 观察员 O2 和 age 观察员 O3,最终执行该任务的人员结构如下:

structor

观察员的任务职责我们已经很熟悉了,当读写观察员对应的数据时将触发 reportObservedpropagateChanged 方法;

这里涉及到两位科长(bankUser 科长 和 child 小科长),那么科长的任务职责是什么呢?

科长的人物职责是起到 管理 作用,它负责统管在他名下的观察员。比如当我们读写 bankUser.child 对象的 name 属性时(比如执行语句 bankUser.child.name = 'Jack'),首先感知到读写操作的并非是 观察员 O2 而是bankUser科长bankUser科长会告知 child 小科长有数据变更,child 小科长然后再将信息传达给 name 观察员 O2 ,然后才是观察员 O2 对数据读写起反应,这才让观察员 O2 发挥作用。

transform

从代码层面看,我们看到仅仅是执行 bankUser.child.name = 'Jack'这一行语句,和我们平常修改对象属性并无二致。然而在这一行代码背后其实牵动了一系列的操作。这其实是 MobX 构建起的一套 ”镜像“ 系统,使用者仍旧按平时的方式读写对象,然而每个属性的读写操作实则都镜像到观察局 的某个小组具体的操作;非常类似于古代的 ”垂帘听政“ ,看似皇帝坐在文武百官前面,其实真正做出决策响应的是藏在帘后面的那个人。

前几章中我们只看到观察员在活动,然则背后离不开 科长 这一角色机制在背后暗暗的调度。对每项任务,最终都会落实到观察员采取“一对一”模式监控分配到给自己的观察项,而每个观察员肯定是隶属于某个 ”科长“ 带领。在 MobX 系统里,办公室、科长和观察员是密不可分,共同构建起 观察局 运行体制;

"分工明确,运转高效",这是最高警长在巡视完观察员培训基地后的第一印象,观察局运转的每一步的设计都有精细的考量;

B. Source Code Time

先罗列本文故事中人物与 MobX 源码概念映射关系:

故事人物 MobX 源码 解释
警署最高长官 (无) MobX 用户,没错,就是你
执行官 MobX MobX 整个 MobX 运行环境
观察局办公室(主任、文员) observableobservable.box 用于创建 Observable 的 API
object 科室、map 科室、array 科室 observable.objectobservable.mapobservable.array 将不同复合类型转换成观察值的方法
科长 ObservableObjectAdministration 主要给对象添加 $mobx 属性
观察员 ObservableValue 实例 ObservableValue 实例

1、总入口:observable

observable 对应上述故事中的 观察局办公室主任 角色,本身不提供转换功能,主要是起到统一调度作用 —— 这样 MobX 执行官只需要将命令发给办公室人员就行,至于内部具体的操作、具体由哪个科室处理,MobX 执行官不需要关心。

将与 observable 的源码 相关的源码稍微整理,就是如下的形式:

var observable = createObservable;
// 使用“奇怪”的方式来扩展 observable 函数的功能,就是将 observableFactories 的方法挨个拷贝给 observable
Object.keys(observableFactories).forEach(function(name) {
  return (observable[name] = observableFactories[name]);
});
  • 首先 observable 是函数,函数内容就是 createObservable
  • 其次 observable 是对象,对象属性和 observableFactories 一致

也就是说 observable 其实是 各种构造器的总和,整合了 createObservable(默认构造器) + observableFactories(其他构造器)

自己也可以在 console 控制台中打印来验证一番:

const { observable } = mobx;

console.log('observable name:', observable.name);
console.log(Object.getOwnPropertyNames(observable));

从以下控制台输出的结果来看,observable 的属性的确来自于createObservableobservableFactories 这两者:
ob value

文字比较枯燥,用图来表示就是下面那样子:

observable

这里我大致划分了一下,分成 4 部分内容来理解:

  • 第一部分:createObservable 方法刚才粗略讲过,是 MobX API 的 observable 的别名,是一个高度封装的方法,算是一个总入口,方便用户调用;该部分对应上述故事中的 观察局办公室主任 的角色
  • 第二部分:box 是一个转换函数,用于将 原值(primitive value) 直接转换成 ObservableValue 对象;shallowBoxbox 函数的非 deep 版本;该部分对应上述故事中的 观察局办公室文员 的角色;
  • 第三部分:针对 object、array 以及 map 这三种数据类型分别提供转换函数,同时也提供 shallow 的版本;该部分对应上述故事中的 科室 部分;
  • 第四部分:提供四种装饰器函数,装饰器的概念我们上一节课讲过,主要辅助提供装饰器语法糖作用;对普通 MobX 用户来讲这部分平时也是接触不到的;

如何理解这 4 部分的之前的关系呢?我个人的理解如下:

  • 第三部分属于 “基层建筑”,分别为 object、array 以及 map 这三种数据类型提供转换成可观察值的功能(默认是递归转换,shallow 表示非递归转换);这部分对应上述故事中的科室概念,不同的观察任务由不同的科室来处理;
  • 第一部分和第二部分属于 “上层建筑”,提供统一的接口,具体的转换功能都是调用第三部分中的某个转换函数来实现的;这两部分对应上述故事中的 观察局办公室 部分。
  • 第一部分我们最熟悉,不过第二部分的 box 函数转换能力反而比第一部分更广,支持将原始值转换成可观察值
  • 第四部分和另外三部分没有直接的关系,主要辅助提供装饰器函数;注意,没有直接的联系并不代表没有联系,第四部分中装饰器内的核心逻辑和另外三部分是一样的(比如都调用 decorator 方法)。

下面我们看两个具体的示例,来辅助消化上面的结论。

示例一observable.box(obj) 底层就是调用 observable.object(obj)实现的

var user = {
  income: 3,
  name: '张三'
};
var bankUser = observable.object(user);
var bankUser2 = observable.box(user);

console.log(bankUser);
console.log(bankUser2);

box and object
可以发现 bankUser2 中的 value 属性部分内容和 bankUser 是一模一样的。

示例二observable.box(primitive) 能行,observable(primitive) 却会报错

var pr1 = observable.box(2);
console.log(pr1);
console.log('--------华丽分割-----------')
var pr2 = observable(2);
console.log(pr2);

从报错信息来看,MobX 会友情提示你改用 observable.box 方法实现原始值转换:

box can

2、第一部分:createObservable

正如上面所言,该函数其实就是 MobX API 的 observable 的 “别名”。所以也是对应上述故事中的 观察局办公室主任 角色;

该函数本身不提供转换功能,只是起到 "转发" 作用,将传入的对象转发给对应具体的转换函数就行了;

看一下 源码

function createObservable(v, arg2, arg3) {
  // 走向 ①
  if (typeof arguments[1] === 'string') {
    return deepDecorator.apply(null, arguments);
  }
  
  // 走向 ②
  if (isObservable(v)) return v;
  
  var res = isPlainObject(v)
    ? observable.object(v, arg2, arg3) // 走向③
    : Array.isArray(v)
      ? observable.array(v, arg2)  // 走向 ④
      : isES6Map(v) ? observable.map(v, arg2) // 走向 ⑤
      : v;
  
  if (res !== v) return res;
  // 走向 ⑥
  fail(
        process.env.NODE_ENV !== "production" &&
            `The provided value could not be converted into an observable. If you want just create an observable reference to the object use 'observable.box(value)'`
    )
}

不难看出其实是典型的采用了 策略设计模式 ,将多种数据类型(Object、Array、Map)情况的转换封装起来,好让调用者不需要关心实现细节:

该设计模式参考可参考 深入理解JavaScript系列(33):设计模式之策略模式

用图来展示一下具体的走向:

trend

  • 走向 ① 是 装饰器语法所特有的,这是因为此时传给 createObservable 的第二个参数是 string 类型,这一点我们在上一篇文章有详细论述;
  • 走向 ② 很直观,如果传入的参数就已经是 观察值 了,不多废话直接返回传入的值,不需要转换;
  • 走向 ③、④ 、⑤ 是直根据传入参数的类型分别调用具针对具体类型的转换方法;
  • 走向 ⑥,在上面示例中我们已经看到过, 针对原始值会提示建议用户使用 observable.box 方法。

第一部分的 createObservable 的内容就那么些,总之只是起了 “向导” 作用。是不是比你想象中的要简单?

接下来我们继续看第二部分的 observable.box 方法。

3、第二部分:observable.box

这个方法对应上述故事中的 观察局办公室文员 角色,也是属于办公室部门的,所起到的作用和 主任 大同小异,只是平时我们用得并不多罢了。

当我第一次阅读 官网文档 中针对有关 observable.box 的描述时:

box

来回读了几次,“盒子”是个啥?它干嘛用的? “observable” 和 “盒子” 有半毛钱关系?

直到看完该函数的详细介绍 boxed values 后,方才有所感悟,这里这 box 方法就是将普通函数 “包装” 成可观察值,所以 box 是动词而非名词

准确地理解,observable.box 是一个转换函数,比如我们将普通的原始值 "Pekin"(北京)转换成可观察值,就可以使用:

const cityName = observable.box("Pekin");

原始值 "Pekin" 并不具备可观察属性,而经过 box 方法操作之后的 cityName 变量具有可观察性,比如:

console.log(cityName.get());
// 输出 'Pekin'

cityName.observe(function(change) {
    console.log(change.oldValue, "->", change.newValue);
});

cityName.set("Shanghai");
// 输出 'Pekin -> Shanghai'

从输入输出角度来看,这 box 其实就是将普通对象转换成可观察值的过程,转换过程中将一系列能力“添加”到对象上,从而获得 “自动响应数值变化” 的能力。

那么具体这 box 函数是如何实现的呢?直接看 源码

box: function(value, options) {
  if (arguments.length > 2) incorrectlyUsedAsDecorator('box');
  var o = asCreateObservableOptions(options);
  return new ObservableValue(
    value,
    getEnhancerFromOptions(o),
    o.name
  );
}

发现该方法仅仅是调用 ObservableValue 构造函数,所以 box 方法操作的结果是返回 ObservableValue 实例。

这里的 asCreateObservableOptions 方法仅仅是格式化入参 options 对象而已。

4、核心类:ObservableValue

总算是讲到这个 ObservableValue 类了,该类是理解可观察值的关键概念。这个类对应上述故事中的 观察员 角色,就是最基层的 name 观察员 O1、O2、O3 那些。

本篇文章的最终目的也就是为了讲清楚这个 ObservableValue 类,其他的概念反而是围绕它而创建起来的。

分析其源码,将这个类的属性和方法都拎出来瞧瞧,绘制成类图大致如下:

gene

你会发现该类 继承自 Atom 类,所以在理解 ObservableValue 之前必须理解 Atom

其实在 3.x 版本的时候,ObservableValue 继承自 BaseAtom
随着升级到 4.x 版本,官方以及废弃了 BaseAtom,直接继承自 Atom 这个类。

4.1、Atom

在 MobX 的世界中,任何能够 存储并管理 状态的对象都是 Atom,故事中的 观察员(ObservableValue 实例)本质上就是 Atom(准确的说,而 ObservableValue 是继承了 Atom 这个基类),Atom实例有两项重大的使命:

  1. 当它的值被使用的时候,就会触发 reportObserved 方法,在 第一篇文章 的讲解中可知,MobX 正是基于该方法,使得观察员和探长之间建立关联关系。
  2. 当它的值受到更改的时候,将会触发 reportChanged 方法,在第三篇文章 《【用故事解读 MobX源码(三)】 shouldCompute》中可知,基于该方法观察员就可以将 非稳态信息逐层上传,最终将让探长、会计员重新执行任务。

Atom 类图如下,从中我们看到前面几章中所涉及到的 onBecomeUnobservedonBecomeObservedreportObservedreportChanged 这几个核心方法,它们都来源于 Atom 这个类:
Atom

所以说 Atom 是整个 MobX 的基石并不为过,所有的自动化响应机制都是建立在这个最最基础类之上。正如在大自然中,万物都是由原子(atom)构成的,借此意义, MobX 中的 ”具备响应式的“ 对象都是由这个 Atom 类构成的。
ComputeValue类 也继承自 AtomReaction 类的实现得依靠 Atom,因此不难感知 Atom 基础重要性)

4.2、createAtom

理论上你只要创建一个 Atom 实例就能融入到 mobx 的响应式系统中,

如何自己创建一个 Atom 呢?

MobX 已经暴露了一个名为 createAtom 方法,
官方文档 创建 observable 数据结构和 reactions(反应) 给出了创建一个 闹钟 的例子,具体讲解了该 createAtom 方法的使用:

...
  // 创建 atom 就能和 MobX 核心算法交互
  this.atom = createAtom(
      // 第一个参数是 name 属性,方便后续 
      "Clock",
      // 第二个参数是回调函数,可选,当 atom 从 unoberved 状态转变到 observed 
      () => this.startTicking(),
      // 第三个参数也是回调函数,可选,与第二个参数对应,此回调是当 atom 从 oberved 状态转变到 unobserved 时会被调用
      // 注意到,同一个 atom 有可能会在 oberved 状态和 unobserved 之间多次转换,所以这两个回调有可能会多次被调用
      () => this.stopTicking()
  );
...

同时文中也给出了对应的最佳实践:

  • 最好给创建的 Atom 起一个名字,方便后续 debug
  • onBecomeObservedonBecomeUnobserved 和我们面向对象中构造函数与析构函数的作用相似,方便进行资源的申请和释放

不过 Atom 实例这个还是偏向底层实现层,除非需要强自定义的特殊场景中,平时我们推荐直接使用 observable 或者 observable.box 来创建观察值更为简单直接;

4.3、理解 ObservableValue

MobX 在 Atom 类基础上,泛化出一个名为 ObservableValue 类,就是我们耳熟能详的 观察值 了。从代码层面上来看,实现 ObservableValue 其实就是继承一下 Atom 这个类,然后再添加许多辅助的方法和属性就可以了。

理解完上述的 Atom 对象之后,你就已经理解 ObservableValue 的大部分。接下来就是去理解 ObservableValue 相比 Atom 多出来的属性和方法,我这里并不会全讲,太枯燥了。只挑选重要的两部分 —— Intercept & Observe 部分 和 enhancer 部分

4.3.1、Intercept & Observe 部分

ObservableValue 类图中除了常见的 toJSON()toString() 方法之外,有两个方法格外引人注目 —— intercept()observe 两个方法。

如果把 “对象变更” 作为事件,那么我们可以在 事件发生之前事件方法之后 这两个 “切面” 分别可以安插回调函数(callback),方便程序动态扩展,这属于 面向切面编程的思想

不了解 AOP 的,可以查阅 知乎问答-什么是面向切面编程AOP?

在 MobX 世界里,将安插在 事件发生之前 的回调函数称为 intercept,将安插在 事件发生之后 的回调函数称为 observe。理解这两个方法可以去看 官方中的示例,能快速体会其作用。

这里稍微进一步讲细致一些,有时候官方文档会中把 intercept 理解成 拦截器。 这是因为它作用于事件(数据变更)发生之前,因此可以操纵变更的数据内容,甚至可以通过返回 null 忽略某次数据变化而不让它生效。

其作用机制也很直接,该方法调用的最终都是调用实例的 intercept 方法,这样每次在值变更之前(以下 prepareNewValue 方法执行),都会触发观察值上所绑定的所有的 拦截器

ObservableValue.prototype.prepareNewValue = function(newValue) {
  ...
  if (hasInterceptors(this)) {
    var change = interceptChange(this, {
      object: this,
      type: 'update',
      newValue: newValue
    });
    if (!change) return UNCHANGED;
    newValue = change.newValue;
  }
  // apply modifier
  ...
};

着重里面的那行语句 if (!change) return UNCHANGED; ,如果你在 intercept 安插的回调中返回 null 的话,相当于告知 MobX 数值没有变更(UNCHANGED),既然值没有变更,后续的逻辑就不会触发了。

observe 的作用是将回调函数安插在值变更之后(以下 setNewValue 方法调用),同样是通过调用 notifyListeners 通知所有的监听器

ObservableValue.prototype.setNewValue = function(newValue) {
  ...
  this.reportChanged();
  if (hasListeners(this)) {
    notifyListeners(this, {
      type: 'update',
      object: this,
      newValue: newValue,
      oldValue: oldValue
    });
  }
};

==========【以下是额外的知识内容,可跳过,不影响主线讲解】===========

如何解除安插的回调函数?

Intercept & Observe 这两个函数返回一个 disposer 函数,这个函数是 解绑函数,调用该函数就可以取消拦截器或者监听器 了。这里有一个最佳实践,如果不需要某个拦截器或者监听器了,记得要及时清理自己绑定的监听函数 永远要清理 reaction —— 即调用 disposer 函数。

那么如何实现 disposer 解绑函数这套机制?

以拦截器(intercept)为例,注册的时候调用 registerInterceptor 方法:

function registerInterceptor(interceptable, handler) {
  var interceptors =
    interceptable.interceptors || (interceptable.interceptors = []);
  interceptors.push(handler);
  return once(function() {
    var idx = interceptors.indexOf(handler);
    if (idx !== -1) interceptors.splice(idx, 1);
  });
}

整体的逻辑比较清晰,就是将传入的 handler(拦截器)添加到 interceptors 数组属性中。关键是在于返回值,返回的是一个闭包 —— once 函数调用的结果值。

所以我们简化一下 disposer 解绑函数的定义:

disposer = once(function() {
  var idx = interceptors.indexOf(handler);
  if (idx !== -1) interceptors.splice(idx, 1);
});

恰是这个 once 函数是实现解绑功能的核心

查看这个 once 函数源码只有寥寥几行,却将闭包的精髓运用到恰到好处。

function once(func) {
  var invoked = false;
  return function() {
    if (invoked) return;
    invoked = true;
    return func.apply(this, arguments);
  };
}

once 方法其实通过 invoked 变量,控制传入的 func 函数只调用一次。

回过头来 disposer 解绑函数,调用一次就会从 interceptors 数组中移除当前拦截器。使用 once 函数后,你无论调用多少次 disposer 方法,最终都只会解绑一次。

由于 once 是纯函数,因此大伙儿可以提取出来运用到自己的代码库中 —— 这也是源码阅读的益处之一,借鉴源码中优秀部分,然后学习吸收,引以为用。

=======================================================

4.3.2、enhancer 部分

这部分是在 ObservableValue 构造函数中发挥作用的,其影响的恰恰是最核心的数据属性:

function ObservableValue(value, enhancer, name, notifySpy) {
      ...
      _this.enhancer = enhancer;
      _this.value = enhancer(value, undefined, name);
      ...
    }

在上一篇文章《【用故事解读 MobX 源码(四)】装饰器 和 Enhancer》中有提及过 enhance,在那里我们说起过 enhance 其实就是装饰器(decorator)的有效成分,该有效成分影响的正是本节所讲的 ObservableValue 对象。结合 types/modifier.ts 中有各种 Enhancer 的具体内容,就能大致了解 enhancer 是如何起到 转换数值 的作用的,以常见的 deepEnhancer 为例,当在构造函数中执行 _this.value = enhancer(value, undefined, name); 的时候会进入到 deepEnhance 函数体内:

function deepEnhancer(v, _, name) {
  // it is an observable already, done
  if (isObservable(v)) return v;
  // something that can be converted and mutated?
  if (Array.isArray(v))
    return observable.array(v, {
      name: name
    });
  if (isPlainObject(v))
    return observable.object(v, undefined, {
      name: name
    });
  if (isES6Map(v))
    return observable.map(v, {
      name: name
    });
  return v;
}

这段代码是否似曾相识?!没错,和上一节所述 createObservable 方法几乎一样,采用 策略设计模式 调用不同具体转换函数(比如 observable.object 等)。

现在应该能够明白,第一部分的 createObservable 和 第二部分的 observable.box 都是建立在第三部分之上,而且通过第一部分、第二部分以及第三部分获得的观察值对象都是属于观察值对象(ObservableValue),大同小异,顶多只是“外形”有略微的差别。

通过该 enhancer 部分的讲解,我们发现所有待分析的重要部分都聚焦到第三部分的 observable.object 等这些个转换方法身上了。

5、第三部分:observable.object

因为结构的原因,上面先讲了最基层的 ObservableValue 部分,现在回来讲的 observable.object 方法。从这里你能大概体会到 MobX 体系中递归现象new ObservableValue 里面会调用 observable.object 方法,从后面的讲解里你将会看到 observable.object 方法里面也会调用 new ObservableValue 的操作,所以 递归地将对象转换成可观察值 就很顺理成章。

阅读官方文档 Observable.object,该 observable.object 方法就是把一个普通的 JavaScript 对象的所有属性都将被拷贝至一个克隆对象并将克隆对象转变成可观察的,而且 observable 是 递归应用 的。

observable.object 等方法对应于上述故事中的 科室 部分,用于执行具体的操作。常见的 object 科室是将 plan object 类型数据转换成可观察值,map 科室是将 map 类型数据转换成可观察值....

我们查阅 observable.object(object) 源码,其实就 2 行有效代码:

object: function(props, decorators, options) {
  if (typeof arguments[1] === 'string')
    incorrectlyUsedAsDecorator('object');
  var o = asCreateObservableOptions(options);
  return extendObservable({}, props, decorators, o);
},

可以说 observable.object(object) 实际上是 extendObservable({}, object) 的别名,从这里 extendObservable 方法的第一个参数是 {} 可以看到,最终产生的观察值对象是基于全新的对象,不影响原始传入的对象内容

5.1、extendObservable 方法

讲到这里,会有一种恍然大悟,原来 extendObservable 方法才是最终大 boss,一切观察值的创建终归走到这个函数。查看该方法的 源码,函数签名如下:

extendObservable(target, properties, decorators, options)
  • 必须接收 2 ~ 4 个参数
  • 第一个参数必须是对象,比如 bankUser
  • 第二个参数是属性名,比如 name
  • 第三个参数是 装饰器 配置项,这一知识点在上一篇章已经讲解。
  • 第四个参数是配置选项对象

方法具体的使用说明参考 官方文档 extendObservable

deco

将该方法的主干找出来:

function extendObservable(target, properties, decorators, options) {
  ...
  
  // 第一步 调用 asObservableObject 方法给 target 添加 $mobx 属性
  options = asCreateObservableOptions(options);
  var defaultDecorator =
    options.defaultDecorator ||
    (options.deep === false ? refDecorator : deepDecorator);
  asObservableObject(
    target,
    options.name,
    defaultDecorator.enhancer
  ); 
  
  // 第二步 循环遍历,将属性经过 decorator(装饰器) 改造后添加到 target 上
  startBatch();
  for (var key in properties) {
    var descriptor = Object.getOwnPropertyDescriptor(
      properties,
      key
    );
    var decorator =
      decorators && key in decorators
        ? decorators[key]
        : descriptor.get
          ? computedDecorator
          : defaultDecorator;
    var resultDescriptor = decorator(
      target,
      key,
      descriptor,
      true
    );
    if (resultDescriptor){
      Object.defineProperty(target, key, resultDescriptor);
    }
  }
  endBatch();
  return target;

这方法看上去块头很大,不过分析起来就 2 大步:

  • 首先调用 asObservableObject 方法,给 target 生成 $mobx 属性
  • 其次挨个让每个属性经过 decorator 改造后重新安装到 target 上,默认的 decorator 是 deepDecorator,装饰器的含义和作用在上一篇文章已讲过,点击 这里 复习

5.2、第一步:调用 asObservableObject

asObservableObject 方法,主要是给目标对象生成 $mobx 属性;该 $mobx 属性对应上述故事中的 科长 角色,用于管理对象的读写操作。

为什么要添加 $mobx 属性?其具体作用又是什么?

通过阅读源码,我无从获知作者添加 $mobx 属性的理由,但可以知道 $mobx 的作用是什么。

首先,$mobx 属性是一个 ObservableObjectAdministration 对象,类图如下:
class

用例子来看看 $mobx 属性:

var bankUser = observable({
    income: 3,
    name: '张三'
});

console.table(bankUser);

下图红框处标示出来的就是 bankUser.$mobx 属性:
$mobx 属性

我们进一步通过以下两行代码输出 $mobx 属性中具体的数据成员和拥有的方法成员:

console.log(`bankUser.$mobx:`, bankUser.$mobx);
console.log(`bankUser.$mobx.__proto__:`, bankUser.$mobx.__proto__);

$mobx

在这么多属性中,格外需要注意的是 writeread 这两个方法,这两个方法算是 $mobx 属性的灵魂,下面即将会讲到,这里先点名一下。

除此之外还需要关注 $mobx 对象中的 values 属性,刚初始化的时候该属性是 {} 空对象,不过注意上面截图中看到 $mobx.values 是有内容的,这其实不是在这一步完成,而是在接下来要讲的第二步中所形成的。

你可以这么理解,这一步仅仅是找到担任科长的人选,还是光杆司令;下一步才是正式委派科长到某个科室,那个时候新上任的科长才有权力管束其下属的观察员。

5.3、第二步:每个属性都经过一遍 decorator 的 “洗礼”

这部分就是应用 装饰器 操作了,默认是使用 deepDecorator 这个装饰器。装饰器的应用流程在 上一篇文章 中有详细讲解,直接拿结论过来:

flow

你会发现应用装饰器的最后一步是在调用 defineObservableProperty 方法时创建 ObservableValue 属性,对应在 defineObservableProperty 源码 中以下语句:

var observable = (adm.values[propName] = new ObservableValue(
  newValue,
  enhancer,
  adm.name + '.' + propName,
  false
));

这里的 adm 就是 $mobx 属性,这样新生成的 ObservableValue 实例就挂载在 $mobx.values[propName] 属性下。

这样的设定很巧妙,值得我们深挖。先看一下下面的示例:

var user = {
  income: 3,
  name: '张三'
};
var bankUser = observable(user);

bankUser.income = 5;

console.log(bankUser.income);
console.table(bankUser.$mobx.values.income);

在这个案例中,我们直接修改 bankUserincome 属性为 5,一旦修改,此时 bankUser.$mobx.values.income 也会同步修改:
values

这是怎么做到的呢?

答案是:通过 generateObservablePropConfig 方法

function generateObservablePropConfig(propName) {
  return (
    observablePropertyConfigs[propName] ||
    (observablePropertyConfigs[propName] = {
      configurable: true,
      enumerable: true,
      get: function() {
        return this.$mobx.read(this, propName);
      },
      set: function(v) {
        this.$mobx.write(this, propName, v);
      }
    })
  );
}

该方法是作用在 decorator 装饰器其作用期间,用 generateObservablePropConfig 生成的描述符重写原始对象的描述符,仔细看描述符里的 getset 方法,对象属性的 读写分别映射到 $mobx.read$mobx.write这两个方法中

在这里,我们就能知道挂载 $mobx 属性的意图:MobX 为我们创建了原对象属性的 镜像 操作,所有针对原有属性的读写操作都将镜像复刻到 $mobx.values 对应 Observable 实例对象上,从而将复杂的操作隐藏起来,给用户提供直观简单的,提高用户体验

以赋值语句 bankUser.income = 5 为例,这样的赋值语句我们平时经常写,只不过这里的 bankUser 是我们 observable.object 操作得到的,所以 MobX 会同步修改 bankUser.$mobx.values.income 这个 ObservableValue 实例对象,从而触发 reportChanged 或者 reportObserved 等方法,开启 响应式链 的第一步。

你所做的操作和以往一样,书写 bankUser.income = 5 这样的语句就可以。而实际上 mobx 在背后默默地做了很多工作,这样就将简单的操作留给用户,而把绝大多数复杂的处理都隐藏给 MobX 框架来处理了。

5.4、递归实现观察值

本小节开始已经提及过递归传递观察值,这里再从代码层面看一下 递归实现观察值 的原理。这一步是在 decorator 装饰器应用过程中,通过 $mobx 挂载对应属性的 ObservableValue 实例达到的。

对应的操作在刚才的 5.3 已经讲过,还是在 defineObservableProperty 源码 那行代码:

var observable = (adm.values[propName] = new ObservableValue(
  newValue,
  enhancer,
  adm.name + '.' + propName,
  false
));

以下述的 parent 对象为例:

var parent = {
  child: {
    name: 'tony'
  }
}

当我们执行 observable(parent)(或者 new ObservableValue(parent)observable.box(parent) 等创建观察值的方法),其执行路径如下:

step

从上图就可以看到,在 decorator 那一步将属性转换成 ObservableValue 实例,这样在整体上看就是递归完成了观察值的转换 —— 把 child 和它下属的属性也转换成可观察值。

6、小测试

请分析 observable.mapobservable.array 的源码,看看它们和 observable.object 方法之间的差别在哪儿。

7、总结

本文重点是讲 Observable 类,与之相关的类图整理如下:

class

  • ObservableValue 继承自 Atom,并实现一系列的 接口
  • ObservableObjectAdministration镜像操作管理者,它主要通过 $mobx 属性来操控管理每个观察值 ObservableValue
  • 比较重要的方法是 interceptobserve ,用“面向切口”编程的术语来讲,这两个方法就是两个 切口,分别作用于数值更改前后,方便针对数据状态做一系列的响应;

本文中出现很多 observable 相关的单词,稍作总结:

  • ObservableValue 是一个普通的 class,用于表示 观察值 这个概念。
  • observable 是一个函数,也是 mobx 提供的 API,等于 createObservable,代表操作,该操作过程中会根据情况调用 observable.object(或者 observable.arrayobservable.map)等方法,最终目的是为了创建 ObservableValue 对象。
  • extendObservable,这是一个工具函数,算是比较底层的方法,该方法用来向已存在的目标对象添加 observable 属性;上述的 createObservable 方法其实也是借用该方法实现的;

MobX 默认会递归将对象转换成可观察属性,这主要是得益于 enhancer 在其中发挥的作用,因为每一次 Observable 构造函数会对传入的值经过 enhancer 处理;

有人不禁会问,既然提供 observable 方法了,那么 observable.box 方法存在的意义是什么?答案是,由于它直接返回的是 ObservableValue,它相比普通的 observable 创建的观察值,提供更加细粒度(底层)的操作;

比如它除了能像正常观察值一样和 autorun 搭配使用之外,创建的对象还直接拥有 interceptobserve 方法:

var pr1 = observable.box(2);
autorun(() => {
  console.log('value:', pr1.get());
});
pr1.observe(change => {
  console.log('change from', change.oldValue, 'to', change.newValue);
});

pr1.set(3);

// 以下是输出结果:
// value: 2
// value: 3
// change from 2 to 3

当然 MobX 考虑也很周全,还单独提供 Intercept & Observe 两个工具函数,以函数调用的方式给观察值新增这两种回调函数。

因此下述两种方式是等同的,可以自己试验一下:

// 调用 observe 属性方法
pr1.observe(change => {
  console.log('change from', change.oldValue, 'to', change.newValue);
});

// 使用 observe 工具函数可以达到相同的目的
observe(pr1, change => {
    console.log('change from', change.oldValue, 'to', change.newValue);
}):


本文针对 MobX 4 源码讲解,而在 MobX 5 版本中的 Observable 类则是采用 proxy 来实现 Observable,整体思路和上述的并无二致,只是在细节方面将 Object.defineProperty 替换成 new Proxy 的写法而已,感兴趣的同学建议先阅读 《抱歉,学会 Proxy 真的可以为所欲为》了解 Proxy 的写法,然后去看一下 MobX 5 中的 observable.object 方法已经改用 createDynamicObservableObject 来创建 proxy,所创建的 proxy 模型来自于 objectProxyTraps 方法;如有机会将在后续的文章中更新这方面的知识。

用故事讲解 MobX 源码的系列文章至此告一段落,后续以散篇的形式发布跟 MobX 相关的文章。

你可能感兴趣的

载入中...