MobX-快速启动指南-全-

MobX 快速启动指南(全)

原文:zh.annas-archive.org/md5/ac898efa7699227dc4bedcb64bab44d7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

多年来,响应式编程一直吸引着程序员的想象力。自从四人组标准化了观察者设计模式以来,这个术语已经成为每个程序员标准词汇的一部分:

观察者:定义对象之间的一对多依赖关系,以便当一个对象改变状态时,所有依赖者都会被通知并自动更新。-《设计模式》,Erich Gamma,Richard Helm,Ralph Johnson,John Vlissides,1995

尽管如此,有各种各样的技术、库和框架实现了观察者模式。然而,MobX 在应用这种模式到状态管理方面是独一无二的。它有一个非常友好的语法,一个小的核心 API,使初学者很容易学习,并且可以应用在任何 JavaScript 项目中。此外,该库已被证明是可扩展的,不仅在 Mendix 首次应用该项目时,而且在著名项目中,如 Microsoft Outlook,DICE 的战地 1,Jenkins,Coinbase 等等。

这本书不仅会指导您了解基础知识;它还会让您沉浸在 MobX 的哲学中:任何可以从应用程序状态中派生出来的东西,都应该自动派生出来。

MobX 并不是第一个这样的库,但它站在巨人的肩膀上,推动了透明响应式编程范例的可能性边界。例如,据作者所知,它是第一个将反应性与同步事务结合起来的主要库,也是第一个明确区分派生值和自动副作用(反应)概念的库。

与许多学习材料不同,本书将指导您了解 MobX 及其许多扩展点的内部工作原理。这本书希望留下一个持久的印象,即一个基本简单(而且非常可读!)的范例可以用来完成非常具有挑战性的任务,不仅在领域复杂性方面,而且在性能方面也是如此。

这本书适合谁

状态管理在任何状态在代码库的不同位置相关的应用程序中起着至关重要的作用。这要么是因为有多个数据的使用者或多个数据的生产者。在实践中,这意味着 MobX 在任何具有大量数据输入或数据可视化的应用程序中都是有用的。

MobX 官方支持 React.js、Preact 和 Angular。然而,许多人将该库与 jQuery、konva.js、Next.js、Vue.js 甚至 Backbone 等库和框架结合使用。在阅读本书时,您将发现使用类似 MobX 这样的工具所需的概念在任何环境中都是通用的。

本书涵盖内容

第一章,“状态管理简介”,从概念上介绍了状态管理及其许多细微之处。它介绍了副作用模型,并为您准备了理解 MobX 所需的哲学。最后,它快速介绍了 MobX 及其一些核心构建模块。

第二章,“可观察对象、操作和反应”,深入探讨了 MobX 的核心构建模块。它向您展示了创建可观察对象的各种方法,使用操作对可观察对象进行变化,并最终使用反应来对可观察对象上发生的任何变化做出反应。这三者构成了 MobX 的核心三部曲。

第三章,“使用 MobX 构建 React 应用”,结合到目前为止所获得的知识,为 React 应用提供动力。它解决了在线商店搜索图书的使用案例。该应用首先通过识别核心可观察状态来构建,使用操作来改变状态,并使用 mobx-react 中的observer()实用程序来通过反应。React 组件是观察者,它们对可观察状态的变化做出反应,并自动呈现新状态。本章将让您提前体验 MobX 在 React 应用中进行状态管理的简单性。

第四章,“设计可观察状态树”,着重设计可观察状态,并介绍了 MobX 中的各种选项。我们将解决如何限制 MobX 中的可观察性,并学习如何创建一个仅观察必要内容的紧密可观察状态。除了限制可观察性,我们还将看到如何使用extendObservable()扩展可观察性。最后,我们将研究计算属性,并研究使用 ES2015 类来建模可观察状态。

第五章《派生、操作和反应》进一步探讨了 MobX 的核心构建块,并更详细地探索了 API。它还涉及了统治这些构建块的哲学。通过本章结束时,您将巩固对 MobX 的理解和核心直觉。

第六章《处理真实世界的用例》是我们将 MobX 应用于两个重要的真实世界用例的地方:表单处理和页面路由。这两者在本质上都是非常直观的,但我们会认为,当以可观察的状态、操作和反应的形式表示时,它们可以更容易地处理。这种表示使得 React 组件(观察者)成为状态的自然视觉扩展。我们还将发展我们对使用 MobX 进行状态建模的核心直觉。

第七章《特殊情况的特殊 API》是对低级别且功能强大但隐藏在顶级 API 阴影中的 API 的调查,例如observable()、action()、computed()和reaction()。我们将探索这些低级别的 API,然后简要介绍 MobX 开发人员可用的调试工具。令人欣慰的是,即使在那些罕见的奇怪情况下,MobX 也会全方位地支持您。

第八章《探索 mobx-utils 和 mobx-state-tree》为您提供了一些有用的包的味道,这些包可以简化 MobX 驱动开发中遇到的日常用例。顾名思义,mobx-utils 是一个实用工具包,其中包含各种函数。另一方面是强大的 mobx-state-tree,通常简称为 MST,它规定了一种可扩展的 MobX 应用程序方法,内置了一些模式,一旦您采用了 MST 思维方式,这些模式就会免费提供给您。这是对 MobX 的一个值得的升级,对于严肃的用户来说是必不可少的。

第九章,MobX 内部,在这里我们通过剥离层并窥探 MobX 的内部工作方式来达到高潮。核心抽象非常简单和明确定义,它们清晰地分离了责任。如果术语透明函数式响应式编程听起来像是一门黑魔法,这一章将揭开魔法,揭示 MobX 如何拥抱它。这一章也是对 MobX 代码库的入门,对于希望成为 MobX 项目核心贡献者的任何人来说都是值得一读的。

充分利用本书

MobX 通常用于长期存储在内存中起重要作用的编程环境,尤其是 Web、移动和桌面应用程序。本书需要对 JavaScript 编程语言有基本的了解,并且在示例中将使用现代的ES2015语法。前端示例基于 ReactJS 框架,因此对它的一些了解将会有所帮助,但并非必需。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 在www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载完成后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/MobX-Quick-Start-Guide。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。 您可以在这里下载:www.packtpub.com/sites/default/files/downloads/MobXQuickStartGuide_ColorImages.pdf。

代码演示

访问以下链接查看代码运行的视频:

bit.ly/2NEww85

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。 例如:"将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。"

代码块设置如下:

connect(mapStateToProps, mapDispatchToProps, mergeProps, options)(Component)

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

import { observable, autorun, action } from 'mobx';

let cart = observable({
    itemCount: 0,
    modified: new Date(),
});

任何命令行输入或输出都以以下形式编写:

$ mkdir css
$ cd css

粗体:表示新术语,重要单词或屏幕上看到的单词。 例如,菜单中的单词或对话框中的单词会以这种形式出现在文本中。 例如:"从管理面板中选择系统信息。"

警告或重要说明会出现在这样的形式中。提示和技巧会出现在这样的形式中。

第一章:状态管理介绍

您的 React 应用的核心位于客户端状态(数据)中,并通过 React 组件呈现。随着您处理用户交互(UI)、执行异步操作和处理领域逻辑,管理这种状态可能变得棘手。在本章中,我们将从 UI 中的状态管理的概念模型、副作用的作用和数据流开始。

然后,我们将快速了解 MobX 并介绍其核心概念。这些概念将有助于与 Redux 进行一些比较。您会发现 MobX 实际上是 Redux 的更声明性形式!

本章涵盖的主题如下:

  • 什么是客户端状态?

  • 副作用模型

  • MobX 的快速介绍

客户端状态

您在屏幕上看到并可以操作的 UI 是将数据的视觉表示呈现出来的结果。数据的形状暗示了您提供用于可视化和操作这些数据的控件的类型。例如,如果您有一个项目列表,您可能会显示一个具有ListItems数组的List控件。操作可能包括搜索、分页、过滤、排序或分组列表中的项目。这些操作的状态也被捕获为数据,并通知了视觉表示。

以下图表显示了数组与List控件之间的直接关系:

简而言之,描述 UI 的关键角色是数据。处理结构和管理可能发生在这些数据上的变化通常被称为状态管理。状态只是在 UI 上呈现的客户端数据的同义词。

状态管理是定义数据形状和用于操作数据的操作的行为。在 UI 的上下文中,它被称为客户端状态管理。

随着 UI 的复杂性增加,客户端上积累了更多的状态。它达到了一个点,状态成为我们在屏幕上看到的一切的最终真相。在 UI 开发中,我们提升了客户端状态的重要性,这是前端世界中最大的转变之一。有一个有趣的方程式捕捉了 UI 和状态之间的关系:

fn 是一个应用在状态(数据)上的转换函数,它产生相应的 UI。事实上,这里隐藏的微妙含义是,给定相同的状态,fn 总是产生相同的 UI。

在 React 的上下文中,前述等式可以写成如下形式:

唯一的区别在于 fn 接受两个输入,props 和 state,这是 React 组件的约定契约。

处理状态变化

然而,前述等式只是 UI 故事的一半。的确,视觉表示是从状态(通过转换函数 fn)派生出来的,但它并没有考虑到在 UI 上发生的 用户操作。就好像我们在等式中完全忽略了 用户。毕竟,界面不仅用于视觉表示数据(状态),还允许对数据进行操作。

这就是我们需要介绍代表这些用户操作的 actions 的概念,这些操作会导致状态的改变。Actions 是您根据触发的各种输入事件而调用的命令。这些 actions 导致状态的改变,然后反映在 UI 上。

我们可以在下图中可视化 State、UI 和 Actions 的三元组:

值得注意的是,UI 不会直接改变状态,而是通过 消息传递 系统来触发 actions 来实现状态的改变。Action 封装了触发适当状态改变所需的参数。UI 负责捕获各种用户事件(点击、键盘按键、触摸、语音等),并将其 转换 为一个或多个 actions,然后触发这些 actions 来改变状态。

当 State 改变时,它会通知所有观察者(订阅者)状态的改变。UI 也是其中一个最重要的订阅者,会收到通知。当发生这种情况时,UI 会重新渲染并更新到新的状态。从 State 流向 UI 的数据流始终是单向的,已成为现代 UI 开发中状态管理的基石。

这种方法的最大好处之一是很容易理解 UI 如何与变化的数据保持同步。它还清晰地分离了渲染和数据变化之间的责任。React 框架确实拥抱了这种单向数据流,并且你也会看到这种方法在MobX中得到了采纳和扩展。

副作用模型

现在我们了解了 UI、状态和操作的角色,我们可以将其扩展到构建 UI 操作的思维模型。回顾操作 --> 状态 --> UI的三元组,我们可以做一些有趣的观察,这些观察并不明确。让我们思考一下如何处理以下操作:

  • 从服务器下载数据

  • 将数据持久化到服务器

  • 运行定时器并定期执行某些操作

  • 当某个状态发生变化时执行一些验证逻辑

这些事情并不完全适合我们的数据流三元组。显然,我们在这里缺少了一些东西,对吧?你可能会争辩说,你可以将这些操作放在 UI 本身内部,并在特定时间触发操作。然而,这将给 UI 增加额外的责任,使其操作复杂化,并且也使其难以测试。从更学术的角度来看,这也会违反单一责任原则(SRP)。SRP 规定一个类或模块应该只有一个变化的原因。如果我们开始在 UI 中处理额外的操作,它将有多个变化的原因。

因此,看起来我们在这里有一些相互对立的力量。我们希望保持数据流三元组的纯度,处理诸如前面列表中提到的辅助操作,并且不向 UI 添加额外的责任。为了平衡所有这些力量,我们需要将辅助操作视为数据流三元组之外的东西。我们称这些为副作用。

副作用是某种状态变化的结果,并且是通过响应来自状态的通知来调用的。就像 UI 一样,有一个处理程序,我们可以称之为副作用处理程序,它观察(订阅)状态变化通知。当发生匹配的状态变化时,相应的副作用被调用:

系统中可能有许多副作用处理程序,每个处理程序都是状态的观察者。当它们观察的状态的一部分发生变化时,它们将调用相应的副作用。现在,这些副作用也可以通过触发额外的动作来导致状态的改变。

举例来说,你可以从 UI 触发一个动作来下载一些数据。这会导致某个标志的状态改变,从而通知所有观察者。观察标志的副作用处理程序会看到这种改变,并触发网络调用来下载数据。当下载完成时,它会触发一个动作来使用新数据更新状态。

副作用也可以触发动作来更新状态,这是一个重要的细节,有助于完成管理状态的循环。因此,不仅 UI 可以引起状态改变,而且外部操作(通过副作用)也可以影响状态改变。这就是副作用的心智模型,它可以用来开发 UI 并管理其呈现的状态。这个模型非常强大,随着时间的推移,它的扩展性也非常好。在本章以及整本书中,您将看到 MobX 如何使这个副作用模型成为现实并且使用起来很有趣。

有了这些概念,我们现在准备进入 MobX 的世界。

MobX 的快速介绍

MobX 是一个反应式状态管理库,它使得采用副作用模型变得容易。MobX 中的许多概念直接反映了我们之前遇到的术语。让我们快速浏览一下这些构建块。

一个 observable 状态

状态是 UI 中发生的所有事情的中心。MobX 提供了一个核心构建块,称为observable,它代表了应用程序的反应式状态。任何 JavaScript 对象都可以用来创建一个 observable。我们可以使用名副其实的observable() API,如下所示:

import {observable} from 'mobx';

let cart = observable({
    itemCount: 0,
    modified: new Date()
});

在前面的例子中,我们创建了一个简单的cart对象,它也是一个observable。observable() API 来自于mobx NPM 包。通过这个简单的observable声明,我们现在有了一个反应灵敏的cart,它可以跟踪其任何属性的变化:itemCount和modified。

观察状态变化

仅仅使用可观察对象并不能构建一个有趣的系统。我们还需要它们的对应物,观察者。MobX 为您提供了三种不同类型的观察者,每一种都专为您在应用程序中遇到的用例量身定制。核心观察者是autorun,reaction和when。我们将在下一章更详细地介绍它们,但现在让我们先介绍autorun。

autorun API 接受一个函数作为输入并立即执行它。它还跟踪传入函数中使用的可观察对象。当这些被跟踪的可观察对象发生变化时,函数会被重新执行。这个简单的设置真正美丽和优雅的地方在于,不需要额外的工作来跟踪可观察对象并订阅任何变化。这一切都是自动发生的。这并不是魔术,但绝对是一个智能的系统在运作,我们将在后面的章节中介绍。

import {observable, autorun} from 'mobx';

let cart = observable({
    itemCount: 0,
    modified: new Date()
});

autorun(() => {
    console.log(`The Cart contains ${cart.itemCount} item(s).`);
});

cart.itemCount++;

// Console output:
The Cart contains 0 item(s).
The Cart contains 1 item(s).

在前面的例子中,传递给autorun的arrow-function在第一次执行时,也在itemCount增加时执行。这导致打印了两个控制台日志。autorun使传入的函数(tracking-function)成为其引用的observables的observer。在我们的例子中,cart.itemCount被观察到,当它增加时,tracking函数会自动收到通知,导致打印控制台日志。

是时候采取行动了

尽管我们直接改变了cart.itemCount,但这绝对不是推荐的方法。记住,状态不应该直接改变,而应该通过actions来完成。使用action还为可观察状态的操作增加了词汇。

在我们的例子中,我们可以将我们正在进行的状态变化称为incrementCount操作。让我们使用 MobX 的action API 来封装这个变化:

import { observable, autorun, action } from 'mobx';

let cart = observable({
    itemCount: 0,
    modified: new Date(),
});

autorun(() => {
    console.log(`The Cart contains ${cart.itemCount} item(s).`);
});

const incrementCount = action(() => {
 cart.itemCount++;
});

incrementCount();

action API 接受一个函数作为参数,每当调用该操作时都会调用该函数。当我们可以将变异包装在普通函数中并调用普通函数而不是将函数传递给action时,可能会显得多余。这是一个敏锐的想法。好吧,这样做是有充分理由的。在内部,action做的远不止是简单的包装。它确保所有状态变化的通知都被触发,但只在action函数完成后才触发。

当您在动作中修改大量的可观察对象时,您不希望立即收到每一个小改变的通知。相反,您希望能够等待所有改变完成,然后触发通知。这使系统更加高效,也减少了过多通知的噪音。

回到我们的例子,我们可以看到将其包装在一个动作中也提高了代码的可读性。通过给动作(incrementCount)一个具体的名称,我们为我们的领域增加了词汇。这样做,我们可以抽象出实际增加计数所需的细节。

可观察对象、观察者和动作是 MobX 的核心。有了这些基本概念,我们可以构建一些最强大和复杂的 React 应用程序。

在 MobX 的文献中,副作用也被称为反应。与导致状态改变的动作不同,反应是对状态改变做出响应的。

请注意与之前看到的单向数据流的惊人相似之处。可观察对象捕获应用程序的状态。观察者(也称为反应)包括副作用处理程序以及 UI。动作是,嗯,导致可观察状态改变的动作:

与 Redux 的比较

如果我们谈论 React 中的状态管理,却没有提到 Redux,那就是完全的疏忽。Redux 是一个非常流行的状态管理库,它之所以流行,是因为它简化了 Facebook 提出的原始 Flux 架构。它摒弃了 Flux 中的某些角色,比如调度器,这导致将所有存储器合并为一个,通常称为单一状态树。

在这一部分,我们将与另一个称为Redux的状态管理库进行正面比较。如果您以前没有使用过 Redux,可以跳过这一部分,继续阅读本章的总结。

就数据流而言,MobX 在概念上与 Redux 有一些相似之处,但这也是相似之处的尽头。MobX 采用的机制与 Redux 采用的机制截然不同。在我们深入比较之前,让我们简要了解一下 Redux。

在简言之中的 Redux

我们之前看到的数据流三角也适用于整个 Redux。Redux 在状态更新机制中添加了自己的特色。可以在下图中看到:

当 UI 触发动作时,它会在存储上分派。在存储内部,动作首先经过一个或多个中间件,在那里可以对其进行操作并在不进一步传播的情况下被吞噬。如果动作通过中间件,它将被发送到一个或多个reducers,在那里可以被处理以产生存储的新状态。

存储的新状态通知给所有订阅者,其中UI是其中之一。如果状态与 UI 之前的值不同,UI 将被重新渲染,并与新状态同步。

这里有几件值得强调的事情:

  • 从动作进入存储的那一刻起,直到计算出新状态,整个过程都是同步的。

  • Reducers 是纯函数,接受动作和先前状态,并产生新状态。由于它们是纯函数,您不能在 reducer 中放置副作用,例如网络调用。

  • 中间件是唯一可以执行副作用的地方,最终导致动作在存储上分派。

如果您正在使用 Redux 与 React,这是最有可能的组合,有一个名为react-redux的实用库,它可以将存储与 React 组件粘合在一起。它通过一个名为connect()的函数来实现这一点,该函数将存储与传入的 React 组件绑定。在connect()内部,React 组件订阅存储以接收状态更改通知。通过connect()绑定到存储意味着每个状态更改都会通知到每个组件。这需要添加额外的抽象,例如state-selector(使用mapStateToProps)或实现shouldComponentUpdate()来仅接收相关的状态更新:

connect(mapStateToProps, mapDispatchToProps, mergeProps, options)(Component)

我们故意跳过了一些其他细节,这些细节对于完整的 React-Redux 设置是必需的,但基本要素已经就位,可以更深入地比较 Redux 和 MobX。

MobX 与 Redux

原则上,MobX 和 Redux 实现了提供单向数据流的相同目标。store是管理所有状态更改并通知 UI 和其他观察者状态更改的中心角色。MobX 和 Redux 之间实现这一目标的机制是完全不同的。

Redux 依赖于immutable状态快照和两个状态快照之间的引用比较来检查更改。相比之下,MobX 依赖于mutable状态,并使用细粒度的通知系统来跟踪状态更改。这种方法上的根本差异对使用每个框架的开发者体验(DX)有影响。我们将使用构建单个功能的 DX 来执行 MobX 与 Redux 的比较。

让我们先从 Redux 开始。在使用 Redux 时,您需要做的事情如下:

  • 定义将封装在存储中的状态树的形状。这通常被称为initialState。

  • 识别可以执行以更改此状态树的所有操作。每个操作以{ type: string, payload: any }的形式定义。type属性用于标识操作,payload是随操作一起携带的附加数据。操作类型通常作为string常量创建并从模块导出。

  • 每次需要分派它们时定义原始操作变得非常冗长。相反,惯例是有一个包装操作类型细节并将有效负载作为参数传入的action-creator函数。

  • 使用connect方法将 React 组件与存储连接起来。由于每个状态更改都会通知到每个组件,因此您必须小心,不要不必要地重新渲染组件。只有当组件实际呈现的状态部分发生变化时(通过mapStateToProps),渲染才应该发生。由于每个状态更改都会通知到所有连接的组件,因此每次计算mapStateToProps可能会很昂贵。为了最小化这些计算,建议使用诸如reselect之类的状态选择器库。这增加了正确设置高性能 React 组件所需的工作量。如果您不使用这些库,您必须承担编写高效的shouldComponentUpdate钩子的责任。

  • 在每个 reducer 中,您必须确保在发生更改时始终返回状态的新实例。请注意,通常将 reducers 与initialState定义分开,并且需要来回确保在每个 reducer 操作中正确更改状态。

  • 您想执行的任何副作用都必须包装在中间件中。对于涉及异步操作的更复杂的副作用,最好依赖于专用中间件库,如redux-thunk,redux-saga或redux-observables。请注意,这也使副作用的构建和执行变得更加复杂。先前提到的每个中间件都有自己的约定和术语。此外,分派动作的位置与处理实际副作用的位置不是共同位置。这导致需要在文件之间跳转,以构建功能如何组合的思维模型。

  • 随着功能的复杂性增加,actions,action-creators,middlewares,reducers和initialState之间的碎片化也越来越多。不共同位置也增加了开发清晰的功能组合思维模型所需的工作量。

在 MobX 世界中,开发者体验是完全不同的。随着我们在本书中探索 MobX,您将看到更多,但这是顶层信息:

  • 在存储类中为功能定义可观察状态。可以更改并应该被观察的各种属性都标有observable API。

  • 定义需要改变可观察状态的actions。

  • 在同一功能类中定义所有的副作用(autorun,reaction和when)。动作、反应和可观察状态的共同位置使思维模型清晰。MobX 还原生支持异步状态更新,因此不需要额外的中间件库来管理它。

  • 使用包含observer API 的mobx-react包,允许 React 组件连接到可观察存储。您可以在 React 组件树中随处添加observer组件,这实际上是调整组件更新的推荐方法。

  • 使用observer的优势在于不需要额外的工作来使组件高效。在内部,observer API 确保组件仅在呈现的可观察状态发生变化时才会更新。

MobX 将您的思维转向可观察状态和相应的 React 组件。您不必过多关注实现这一点所需的连接。它被简单而优雅的 API 所抽象,如observable,action,autorun和observer。

我们甚至可以说,MobX 实现了一种更具声明性的 Redux 形式。没有动作创建者、减速器或中间件来处理动作并产生新状态。动作、副作用(反应)和可观察状态都位于类或模块内。没有复杂的connect()方法将 React 组件粘合到存储中。一个简单的observer()就能完成工作,不需要额外的连接。

MobX 是声明性的 Redux。它接管了与 Redux 相关的工作流程,并大大简化了它。不再需要一些显式的设置,比如在容器组件中使用connect(),为记忆化状态选择使用 reselect,动作、减速器,当然还有中间件。

摘要

UI 是数据(状态)的视觉等价物,以及交互控件来改变该状态。UI 触发动作,导致状态的改变。副作用是由于某种状态改变而触发的外部操作。系统中有观察者,它们寻找特定的状态改变并执行相应的副作用。

动作 --> 状态 --> UI的数据流三元组,加上副作用,构成了 UI 的简单心智模型。MobX 强烈遵循这个心智模型,你可以在它的 API 中看到这一点,包括可观察对象、动作、反应和观察者。这个 API 的简单性使得它很容易处理 UI 中的一些复杂交互。

如果你以前使用过 Redux,你会发现 MobX 减少了引起状态改变和处理副作用所需的仪式。MobX 努力提供一种声明性和反应性的状态管理 API,而不会牺牲简单性。在本书中,将探讨 MobX 的这种哲学,深入了解其 API 和实际用例。

在下一章中,我们将深入了解 MobX 的核心构建模块。

第二章:Observables、Actions 和 Reactions

描述客户端状态的结构是 UI 开发的第一步。使用 MobX,您可以通过创建observables树来实现这一点。当用户与应用程序交互时,在 observable 状态上调用操作,这将引起反应(也称为副作用)。继续阅读第一章,状态管理简介,我们现在将更深入地了解 MobX 的核心概念。

本章涵盖的主题包括:

  • 创建各种类型的 observables

  • 设置改变 observable 的操作

  • 使用反应来处理外部变化

技术要求

您将需要使用 JavaScript 编程语言。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/MobX-Quick-Start-Guide/tree/master/src/Chapter02

查看以下视频以查看代码的运行情况:

bit.ly/2NEww85

Observables

数据是 UI 的命脉。回到定义数据和 UI 之间关系的方程式,我们知道以下是真的:

因此,专注于定义将驱动 UI 的数据结构是有意义的。在 MobX 中,我们使用 observable 来做到这一点。看一下这个图表:

Observables,顾名思义,是可以被观察的实体。它们跟踪其值发生的变化并通知所有观察者。当您开始设计客户端状态的结构时,这种看似简单的行为具有强大的影响。在前面的图表中,每个圆代表一个Observable,每个菱形代表一个Observer。观察者可以观察一个或多个 observable,并在它们中任何一个值发生变化时得到通知。

创建 observables

创建 observable 的最简单方法是使用observable()函数。看一下以下内容:

const item = observable({
    name: 'Party Balloons',
    itemId: '1234',
    quantity: 2,
    price: 10,
    coupon: {
        code: 'BIGPARTY',
        discountPercent: 50
  }
});

item现在是一个observable对象,并将开始跟踪其属性的变化。您可以将此对象用作常规 JavaScript 对象,而无需任何特殊的 API 来获取或设置其值。在前面的片段中,您还可以使用observable.object()创建一个 observable item。

在下面的片段中,我们可以看到对可观察对象进行的简单变化,就像任何常规的 JavaScript 代码一样:

// Set values
item.quantity += 3;
item.name = 'Small Balloons';

// Get values
console.log(`Buying ${item.quantity} of ${item.name}`);

可观察对象只会跟踪在observable()或observable.object()中提供的初始值中提供的属性。这意味着如果以后添加新属性,它们不会自动变为可观察的。这是关于可观察对象需要记住的一个重要特性。它们就像具有固定属性集的记录或类。如果你确实需要动态跟踪属性,你应该考虑使用可观察映射;这将在本章后面进一步介绍。

在内部,MobX 会透明地跟踪属性的变化并通知相应的观察者。我们将在后面的章节中探讨这种内部行为。

observable()函数会自动将对象、数组或映射转换为可观察实体。这种自动转换不适用于其他类型的数据,比如 JavaScript 原始类型(数字、字符串、布尔值、null、undefined)、函数,或者类实例(带有原型的对象)。因此,如果你调用observable(20),它将会失败并显示错误,如下所示:

Error: [mobx] 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)'

如错误中所建议的,我们必须使用更专门的observable.box()将原始值转换为可观察值。包装原始值、函数或类实例的可观察值被称为包装的可观察值。看一下这个:

const count = observable.box(20);

// Get the count console.log(`Count is ${count.get()}`);

// Change count count.set(22);

我们必须使用包装的可观察对象的get()和set()方法,而不是直接读取或分配给它。这些方法给了我们 MobX 固有的可观察性。

除了对象和单一值,你还可以将数组和映射转换为可观察对象。它们有相应的 API,可以在这个表格中看到:

对象 observable.object({ })
数组 observable.array([ ])
映射 observable.map(value)
原始值、函数、类实例 observable.box(value)

正如我们之前提到的,observable()会自动将对象、数组或映射转换为可观察对象。它是observable.object()、observable.array()或observable.map()的简写。对于原始值、函数和类实例,你应该使用observable.box()API。尽管在实践中,使用observable.box()相当罕见。更常见的是使用observable.object()、observable.array()或observable.map()。

MobX 在创建 observable 时应用深度可观察性。这意味着 MobX 将自动观察对象树、数组或映射中的每个级别的每个属性。它还会跟踪数组和映射的添加或删除。这种行为对大多数情况都很有效,但在某些情况下可能过于严格。有一些特殊的装饰器可以应用于控制这种可观察性。我们将在第四章中进行探讨,构建可观察树。

Observable arrays

使用observable.array()与使用observable()非常相似。您可以将数组作为初始值传递,或者从空数组开始。在以下代码示例中,我们从一个空数组开始:

const items = observable.array(); // Start with empty array

console.log(items.length); // Prints: 0
 items.push({
    name: 'hats', quantity: 40,
});

// Add one in the front items.unshift({ name: 'Ribbons', quantity: 2 });

// Add at the back items.push({ name: 'balloons', quantity: 1 });

console.log(items.length); // Prints: 3

请注意,observable 数组不是真正的 JavaScript 数组,尽管它具有与 JS 数组相同的 API。当您将此数组传递给其他库或 API 时,可以通过调用toJS()将其转换为 JS 数组,如下所示:

import { observable, **toJS** } from 'mobx';

const items = observable.array();

/* Add/remove items*/  const plainArray = toJS(items);
console.log(plainArray);

MobX 将对 observable 数组应用深度可观察性,这意味着它将跟踪数组中项目的添加和删除,还将跟踪数组中每个项目发生的属性更改。

Observable maps

您可以使用observable.map() API 创建一个 observable map。原则上,它的工作方式与observable.array()和observable.object()相同,但它适用于 ES6 Maps。observable map 实例与常规的 ES6 Map 共享相同的 API。Observable maps 非常适合跟踪键和值的动态变化。这与 observable objects 形成鲜明对比,后者不会跟踪在创建后添加的属性。

在以下代码示例中,我们正在创建一个动态的 Twitter 句柄到名称的字典。这非常适合使用 observable map,因为我们在创建后添加键。看一下这段代码:

import { observable } from 'mobx';

// Create an Observable Map const twitterUserMap = observable.map();

console.log(twitterUserMap.size); // Prints: 0   // Add keys twitterUserMap.set('pavanpodila', 'Pavan Podila');
twitterUserMap.set('mweststrate', 'Michel Weststrate');

console.log(twitterUserMap.get('pavanpodila')); // Prints: Pavan Podila console.log(twitterUserMap.has('mweststrate')); // Prints: Michel Weststrate   twitterUserMap.forEach((value, key) => console.log(`${key}: ${value}`));

// Prints: // pavanpodila: Pavan Podila // mweststrate: Michel Weststrate 

关于可观察性的说明

当您使用observable() API 时,MobX 将对 observable 实例应用深度可观察性。这意味着它将跟踪发生在 observable 对象、数组或映射上的更改,并且会对每个级别的每个属性进行跟踪。在数组和映射的情况下,它还将跟踪条目的添加和删除。数组或映射中的任何新条目也将成为深度可观察的。这绝对是一个很好的合理默认值,并且适用于大多数情况。但是,在某些情况下,您可能不希望使用这个默认值。

你可以在创建可观察性时改变这种行为。你可以使用兄弟 API(observable.object(),observable.array(),observable.map())来创建可观察性,而不是使用observable()。每个 API 都接受一个额外的参数来设置可观察实例的选项。看一下这个:

observable.object(value, decorators, { deep: false });
observable.map(values, { deep: false });
observable.array(values, { deep: false });

通过将{ deep: false }作为选项传递进去,你可以有效地修剪可观察性,只到第一级。这意味着以下内容:

对于可观察对象,MobX 只观察初始属性集。如果属性的值是对象、数组或映射,它不会进行进一步的观察。

请注意,{ deep: false }选项是observable.object()的第三个参数。第二个参数称为装饰器,可以更精细地控制可观察性。我们将在后面的章节中进行介绍。现在,你可以将一个空对象作为第二个参数传递。

对于可观察数组,MobX 只观察数组中项目的添加和移除。如果一个项目是对象、数组或映射,它不会进行进一步的观察。

对于可观察映射,MobX 只观察映射中项目的添加和移除。如果键的值是对象、数组或映射,它不会进行进一步的观察。

现在值得一提的是,observable()在内部调用前面的 API 之一,并将选项设置为{ deep: true }。这就是observable()具有深层可观察性的原因。

计算可观察性

到目前为止,我们所见过的可观察性与客户端状态的形状直接对应。如果你要表示一个项目列表,你会在客户端状态中使用一个可观察数组。同样,列表中的每个项目可以是一个可观察对象或可观察映射。故事并不止于此。MobX 还给你另一种可观察性,称为计算属性或计算可观察性。

计算属性不是客户端状态固有的可观察性。相反,它是一个从其他可观察性派生其值的可观察性。现在,为什么会有用?你可能会问。让我们举个例子来看看好处。

考虑跟踪项目列表的cart可观察性。看一下这个:

import { observable } from 'mobx';

const cart = observable.object({
    items: [],
    modified: new Date(),
});

假设你想要一个描述cart的description属性,格式如下:购物车中有{no, one, n}个项目。

对于零个项目,描述如下:购物车中没有项目。

当只有一个项目时,描述变为:购物车中有一个项目。

对于两个或更多个项目(n),描述应该是:购物车中有 n 个项目。

让我们思考一下如何对这个属性进行建模。考虑以下内容:

  • 显然,description不是购物车的固有属性。它的值取决于items.length。

  • 我们可以添加一个名为description的可观察属性,但是我们必须在items或items.length发生变化时更新它。这是额外的工作,容易忘记。而且,我们有可能会有人从外部修改描述。

  • 描述应该只是一个没有 setter 的 getter。如果有人观察描述,他们应该在任何时候都会收到通知。

从前面的分析可以看出,我们似乎无法将这种行为归类为先前讨论过的任何可观察类型。我们需要的是计算属性。我们可以通过简单地向cart可观察对象添加get-property来定义一个computed描述属性。它将从items.length派生其值。看一下这段代码:

const cart = observable.object({
    items: [],
    modified: new Date(),

    get description() {
        switch (this.items.length) {
            case 0:
                return 'There are no items in the cart';
            case 1:
                return 'There is one item in the cart';
            default:
                return `There are ${this.items.length} items in the 
                 cart`;
        }
    },
});

现在,您只需读取cart.description,就可以始终获得最新的描述。任何观察此属性的人在cart.description发生变化时都会自动收到通知,如果您向购物车中添加或删除商品,这种情况就会发生。以下是如何使用这个计算属性的示例:

cart.items.push({ name: 'Shoes', quantity: 1 });
console.log(cart.description);

请注意,它还满足了先前对description属性的所有标准的所有标准。我会让您,读者,确认这是否属实。

Computed properties,也称为derivations,是 MobX 工具箱中最强大的工具之一。通过将客户端状态视为一组最小的可观察对象,并用派生(计算属性)来增强它,您可以轻松地对各种情况进行建模。计算属性的值取决于其他可观察对象。如果其中任何一个依赖的可观察对象发生变化,计算属性也会发生变化。

您还可以使用其他计算属性构建计算属性。MobX 在内部构建依赖树以跟踪可观察对象。它还缓存计算属性的值,以避免不必要的计算。这是一个重要的特性,极大地提高了 MobX 反应性系统的性能。与 JavaScript 的 get 属性不同,后者总是急切地评估,计算属性会记忆(又名缓存)值,并且只在相关的可观察对象发生变化时进行评估。

随着使用 MobX 的经验的积累,您会意识到计算属性可能是您最好的可观察对象朋友。

更好的装饰器语法

到目前为止,我们所有的示例都使用了 MobX 的ES5 API。然而,API 的特殊形式给了我们一种非常方便的表达可观察对象的方式。这是通过@decorator语法实现的。

装饰器语法仍然是 JavaScript 语言标准的一个待定提案(截至目前为止)。但这并不妨碍我们使用它,因为我们有Babel来帮助我们。通过使用 Babel 插件transform-decorators-legacy,我们可以将装饰器语法转译为常规的 ES5 代码。如果您使用 TypeScript,还可以通过在tsconfig.json中设置{ experimentalDecorators: true}编译器选项来启用装饰器支持。

装饰器语法仅适用于类,可用于类声明、属性和方法。以下是使用装饰器表达的等效Cart可观察对象:

class Cart {
    @observable.shallow items = [];
    @observable modified = new Date();

    @computed get description() {
        switch (this.items.length) {
            case 0:
                return 'There are no items in the cart';
            case 1:
                return 'There is one item in the cart';
            default:
                return `There are ${this.items.length} items in the 
                cart`;
        }
    }
}

请注意使用装饰器来装饰可观察属性。默认的@observable装饰器对值的所有属性进行深度观察。实际上,它是使用@observable.deep的简写。

同样,我们有@observable.shallow装饰器,它是在可观察对象上设置{ deep: false }选项的粗略等效。它适用于对象、数组和映射。我们将在第四章中介绍observable.shallow的更技术上正确的 ES5 等效。

下面的片段显示了items和metadata属性,标记为浅观察对象:

class Cart {
    // Using decorators
    @observable.shallow items = [];
    @observable.shallow metadata = {};
}

我们将在后面的章节中介绍更多的装饰器,但我们不想等到那时才讨论装饰器语法。我们认为你应该首选装饰器来声明可观察对象。请注意,它们只在类内部可用。然而,绝大多数情况下,您将使用类来建模您的可观察树,所以装饰器在使其更可读方面非常有帮助。

行动

虽然您可以直接更改可观察对象,但强烈建议您使用actions来执行。如果您还记得,在上一章中,我们看到动作是导致状态变化的原因。UI 只是触发动作,并期望一些可观察对象被改变。动作隐藏了变异应该如何发生或哪些可观察对象应该受到影响的细节。

下面的图表提醒我们,UI只能通过Action来修改State:

行动在 UI 中引入了词汇,并为改变状态的操作提供了声明性的名称。MobX 完全接受了这个想法,并将行动作为一流的概念。要创建一个动作,我们只需在action()API 中包装变异函数。这会给我们一个可以像原始传入的函数一样调用的函数。看一下这段代码:

import { observable, action } from 'mobx';

const cart = observable({
    items: [],
    modified: new Date(),
});

// Create the actions const addItem = action((name, quantity) => {
    const item = cart.items.find(x => x.name === name);
    if (item) {
        item.quantity += 1;
    } else {
        cart.items.push({ name, quantity });
    }

    cart.modified = new Date();
});

const removeItem = action(name => {
    const item = cart.items.find(x => x.name === name);
    if (item) {
        item.quantity -= 1;

        if (item.quantity <= 0) {
            cart.items.remove(item);
        }

        cart.modified = new Date();
    }
});

// Invoke actions addItem('balloons', 2);
addItem('paint', 2);
removeItem('paint');

在前面的片段中,我们介绍了两个动作:addItem()和removeItem(),它们向cart可观察对象添加和移除项目。由于action()返回一个将参数转发给传入函数的函数,我们可以使用所需的参数调用addItem()和removeItem()。

除了改善代码的可读性外,动作还提高了 MobX 的性能。默认情况下,当您修改一个可观察对象时,MobX 会立即发出更改的通知。如果您一起修改一堆可观察对象,您可能希望在所有这些对象都被修改后再发出更改通知。这将减少太多通知的噪音,并将一组更改视为一个原子事务。这实质上是一个action()的核心责任。

强制使用动作

毫不奇怪,MobX 强烈建议使用actions来修改可观察对象。事实上,通过配置 MobX 始终强制执行此策略,也称为strict mode,可以使此操作成为强制性的。configure()函数可用于将enforceActions选项设置为 true。如果尝试在动作之外修改可观察对象,MobX 现在将抛出错误。

回到我们之前关于cart的例子,如果我们尝试在动作之外修改它,MobX 将会出现错误,如下例所示:

import { observable, configure } from 'mobx';

configure({
 enforceActions: true,
});

// Modifying outside of an action
cart.items.push({ name: 'test', quantity: 1 });
cart.modified = new Date();

Error: [mobx] Since strict-mode is enabled, changing observed observable values outside actions is not allowed. Please wrap the code in an `action` if this change is intended. Tried to modify: ObservableObject@1.items

关于使用configure({ enforceActions: true })有一件小事需要记住:它只会在有观察者观察您尝试改变的可观察对象时才会抛出错误。如果没有观察者观察这些可观察对象,MobX 将安全地忽略它。这是因为没有触发反应过早的风险。但是,如果您确实想严格执行此操作,还可以设置{ enforceActions: 'strict' }。即使没有观察者附加到变异的可观察对象,这也会抛出错误。

装饰动作

装饰器在 MobX 中是无处不在的。动作也通过@action装饰器获得特殊处理,以将类方法标记为动作。使用装饰器,Cart类可以编写如下所示:

class Cart {
    @observable modified = new Date();
    @observable.shallow items = [];

 @action  addItem(name, quantity) {
        this.items.push({ name, quantity });
        this.modified = new Date();
    }

    **@action.bound**
  removeItem(name) {
        const item = this.items.find(x => x.name === name);
        if (item) {
            item.quantity -= 1;

            if (item.quantity <= 0) {
                this.items.remove(item);
            }
        }
    }
}

在前面的片段中,我们为removeItem()动作使用了@action.bound。这是一种特殊形式,可以预先绑定类的实例到该方法。这意味着您可以传递对removeItem()的引用,并确保this值始终指向 Cart 的实例。

使用类属性和箭头函数预先绑定this声明removeItem动作的另一种方式是。以下代码中可以看到这一点:

class Cart {
    /* ... */
    **@action** removeItem = (name) => {
        const item = this.items.find(x => x.name === name);
        if (item) {
            item.quantity -= 1;

            if (item.quantity <= 0) {
                this.items.remove(item);
            }
        }
    }
}

在这里,removeItem是一个类属性,其值是一个箭头函数。由于箭头函数,它绑定到词法this,即Cart的实例。

反应

Reactions确实可以改变您的应用程序世界。它们是对可观察对象变化做出反应的副作用行为。反应完成了 MobX 的核心三部曲,并充当可观察对象的观察者。看一下这个图表:

MobX 为您提供了三种不同的方式来表达您的反应或副作用。这些是autorun(),reaction()和when()。让我们依次看看每一个。

autorun()

autorun() 是一个长时间运行的副作用,它接受一个函数(effect-function)作为参数。effect-function 函数是你应用所有副作用的地方。现在,这些副作用可能依赖于一个或多个 observables。MobX 将自动跟踪这些 dependent observables 的任何变化,并重新执行此函数以应用副作用。在代码中更容易看到这一点,如下所示:

import { observable, action, autorun } from 'mobx';

class Cart {
    @observable modified = new Date();
    @observable.shallow items = [];

    constructor() {
        autorun(() => {
            console.log(`Items in Cart: ${this.items.length}`);
        });
    }

    @action
  addItem(name, quantity) {
        this.items.push({ name, quantity });
        this.modified = new Date();
    }
}

const cart = new Cart();
cart.addItem('Power Cable', 1);
cart.addItem('Shoes', 1);

// Prints:
// Items in Cart: 0 // Items in Cart: 1 // Items in Cart: 2

在上面的例子中,我们将一个 observable(this.items.length)记录到控制台。记录会立即发生,也会在 observable 变化时发生。这是 autorun() 的定义特征;它立即运行,并且在 dependent observables 变化时也会运行。

我们之前提到 autorun() 是一个长时间运行的副作用,只要你不明确停止它,它就会继续。但是,你如何实际停止它呢?嗯,autorun() 的返回值实际上是一个函数,它实际上是一个 disposer-function。通过调用它,你可以取消 autorun() 的副作用。看一下这个:

import { observable, action, autorun } from 'mobx';

class Cart {
    /* ... */

    cancelAutorun = null;

    constructor() {
        this.cancelAutorun = autorun(() => {
            console.log(`Items in Cart: ${this.items.length}`);
        });
    }

    /* ... */
}

const cart = new Cart();
// 1\. Cancel the autorun side-effect
cart.cancelAutorun();

// 2\. The following will not cause any logging to happen
cart.addItem('Power Cable', 1);
cart.addItem('Shoes', 1);

// Prints:
// Items in Cart: 0

在上面的片段中,我们将 autorun() 的返回值(一个 disposer-function)存储在一个类属性中:cancelAutorun。在实例化 Cart 后立即调用它,我们取消了副作用。现在 autorun() 只打印一次,再也不会打印了。

快速阅读者问题:为什么它只打印一次?因为我们立即取消了,autorun() 不应该完全跳过打印吗?对此的答案是刷新 autorun 的核心特征。

reaction()

reaction() 是 MobX 中另一种反应的方式。是的,API 名称的选择是有意的。reaction() 类似于 autorun(),但在执行 effect-function 之前等待 observables 的变化。reaction() 实际上接受两个参数,如下所示:

reaction(tracker-function, effect-function): disposer-function

tracker-function: () => data, effect-function: (data) => {}

tracker-function 是跟踪所有 observables 的地方。任何时候跟踪的 observables 发生变化,它都会重新执行。它应该返回一个值,用于与上一次运行的 tracker-function 进行比较。如果这些返回值不同,就会执行 effect-function。

通过将反应的活动分解为一个检测变化的函数(tracker函数)和effect函数,reaction()使我们对何时引起副作用有了更精细的控制。它不再仅仅依赖于tracker函数内部跟踪的可观察对象。相反,它现在取决于tracker函数返回的数据。effect函数接收这些数据作为输入。在效果函数中使用的任何可观察对象都不会被跟踪。

就像autorun()一样,你还会得到一个disposer函数作为reaction()的返回值。这可以用来随时取消副作用。

我们可以通过一个例子来实践这一点。假设你想在你的购物车中的任何物品价格变化时得到通知。毕竟,你不想购买突然涨价的东西。与此同时,你也不想错过一个好的交易。因此,当价格变化时得到通知是一个有用的功能。我们可以通过使用reaction()来实现这一点,如下所示:

import { observable, action, reaction } from 'mobx';

class Cart {
    @observable modified = new Date();
    @observable items = [];

    cancelPriceTracker = null;

    trackPriceChangeForItem(name) {
        if (this.cancelPriceTracker) {
            this.cancelPriceTracker();
        }

 // 1\. Reaction to track price changes
        this.cancelPriceTracker = reaction(
            () => {
                const item = this.items.find(x => x.name === name);
                return item ? item.price : null;
            },
            price => {
                console.log(`Price changed for ${name}: ${price !== 
                null ? price : 0}`);
            },
        );
    }

    @action
  addItem(name, price) {
        this.items.push({ name, price });
        this.modified = new Date();
    }

    @action
  changePrice(name, price) {
        const item = this.items.find(x => x.name === name);
        if (item) {
            item.price = price;
        }
    }
}

const cart = new Cart();

cart.addItem('Shoes', 20);

// 2\. Now track price for "Shoes"
cart.trackPriceChangeForItem('Shoes');

// 3\. Change the price
cart.changePrice('Shoes', 100);
cart.changePrice('Shoes', 50);

// Prints:
// Price changed for Shoes: 100
// Price changed for Shoes: 50

在上面的片段中,我们在注释 1中设置了一个价格跟踪器,作为跟踪价格变化的反应。请注意,它接受两个函数作为输入。第一个函数(tracker-function)找到具有给定name的物品,并将其价格作为tracker函数的输出返回。每当它变化时,相应的effect函数就会被执行。

控制台日志也只在价格变化时打印。这正是我们想要的行为,并通过reaction()实现了。现在你已经被通知价格变化,你可以做出更好的购买决策。

响应式 UI

在谈到反应时,值得一提的是 UI 是应用程序中最辉煌的反应(或副作用)之一。正如我们在前一章中看到的那样,UI依赖于数据,并应用转换函数来生成视觉表示。在 MobX 世界中,这个 UI 也是响应式的,它对数据的变化做出反应,并自动重新渲染自己。

MobX 提供了一个名为mobx-react的伴侣库,它与 React 绑定。通过使用来自mobx-react的装饰器函数(observer()***),您可以将 React 组件转换为观察render()函数中使用的可观察对象。当它们发生变化时,会触发 React 组件的重新渲染。在内部,observer()创建一个包装组件,该组件使用普通的reaction()来监视可观察对象并重新渲染为副作用。这就是为什么我们将 UI 视为另一个副作用,尽管是一个非常显而易见的副作用。

下面展示了使用observer()的简短示例。我们使用了一个无状态函数组件,将其传递给 observer。由于我们正在读取item可观察对象,因此组件现在将对item的更改做出反应。两秒后,当我们更新item时,ItemComponent将自动重新渲染。看一下这个:

import { observer } from 'mobx-react';
import { observable } from 'mobx';
import ReactDOM from 'react-dom';
import React from 'react';

const item = observable.box(30);

// 1\. Create the component with observer
const ItemComponent = observer(() => {
    // 2\. Read an observable: item
    return <h1>Current Item Value = {item.get()}</h1>;
});

ReactDOM.render(<ItemComponent />, document.getElementById('root'));

// 3\. Update item
setTimeout(() => item.set(50), 2000);

我们将在第三章中涵盖mobx-react,使用 MobX 的 React 应用程序,并且在整本书中都会涉及。

when()

正如其名称所示,when()仅在满足条件时执行effect-function,并在此之后自动处置副作用。因此,与autorun()和reaction()相比,when()是一次性副作用。predicate函数通常依赖于一些可观察对象来进行条件检查。如果可观察对象发生变化,predicate函数将被重新评估。

when()接受两个参数,如下所示:

when(predicate-function, effect-function): disposer-function

predicate-function: () => boolean, effect-function: ()=>{}

predicate函数预计返回一个布尔值。当它变为true时,执行effect函数,并且when()会自动处置。请注意,when()还会返回一个disposer函数,您可以调用它来提前取消副作用。

在下面的代码块中,我们正在监视物品的可用性,并在其重新上架时通知用户。这是一次性效果,您不必持续监视。只有当库存中的物品数量超过零时,您才会执行通知用户的副作用。看一下这个:

import { observable, action, when } from 'mobx';

class Inventory {
    @observable items = [];

    cancelTracker = null;

    trackAvailability(name) {

 // 1\. Establish the tracker with when
        this.cancelTracker = when(
            () => {
                const item = this.items.find(x => x.name === name);
                return item ? item.quantity > 0 : false;
            },
            () => {
                console.log(`${name} is now available`);
            },
        );
    }

    @action
  addItem(name, quantity) {
        const item = this.items.find(x => x.name === name);
        if (item) {
            item.quantity += quantity;
        } else {
            this.items.push({ name, quantity });
        }
    }
}

const inventory = new Inventory();

inventory.addItem('Shoes', 0);
inventory.trackAvailability('Shoes');

// 2\. Add two pairs
inventory.addItem('Shoes', 2);

// 3\. Add one more pair
inventory.addItem('Shoes', 1);

// Prints:
// Shoes is now available

这里的when()接受两个参数。predicate函数在item.quantity大于零时返回 true。effect函数只是通过console.log通知物品在商店中可用。当 predicate 变为 true 时,when()执行副作用并自动处理自身。因此,当我们将两双鞋子添加到库存时,when()执行并记录可用性。

注意,当我们将一双鞋子添加到库存中时,不会打印任何日志。这是因为此时when()已被处理并且不再监视Shoes的可用性。这是when()的一次性效果。

带有 promise 的 when()

when()还有一个特殊版本,只接受一个参数(predicate函数),并返回一个 promise 而不是disposer函数。这是一个很好的技巧,您可以跳过使用effect函数,而是等待when()解析后再执行效果。在代码中更容易看到,如下所示:

class Inventory {
    /* ... */    async trackAvailability(name) {
 // 1\. Wait for availability
        await when(() => {
            const item = this.items.find(x => x.name === name);
            return item ? item.quantity > 0 : false;
        });

 // 2\. Execute side-effect
        console.log(`${name} is now available`);
    }

    /* ... */ }

在注释 1中,我们正在使用只接受predicate函数的when()来等待物品的可用性。通过使用async-await操作符等待 promise,我们可以得到清晰、可读的代码。在await语句后面的任何代码都会在 promise 解析后自动安排执行。如果您不想传递一个效果回调,这是使用when()的更好方式。

when()也非常高效,不会轮询predicate函数以检查更改。相反,它依赖于 MobX 反应性系统在基础可观察对象发生变化时重新评估predicate函数。

关于反应的快速回顾

MobX 提供了几种执行副作用的方式,但您必须确定哪种适合您的需求。以下是一个快速总结,可以帮助您做出正确的选择。

我们有三种运行副作用的方式:

  1. autorun( effect-function: () => {} ):对于长时间运行的副作用很有用。effect函数立即执行,也会在其中使用的依赖可观察对象(在其内部使用)发生变化时执行。它返回一个disposer函数,可以随时用于取消。

  2. reaction( tracker-function: () => data, effect-function: (data) => {} ): 也用于长时间运行的副作用。只有当tracker函数返回的数据不同时,才执行effect函数。换句话说,reaction()在可观察对象发生变化之前等待。它还返回一个disposer函数,以提前取消效果。

  3. when( predicate-function: () => boolean, effect-function: () => {} ): 用于一次性效果。predicate函数在其依赖的可观察对象发生变化时进行评估。只有当predicate函数返回true时,才执行effect函数。when()在运行effect函数后会自动处理自身。还有一种特殊形式的when(),只接受predicate函数并返回一个 promise。可以与async-await一起使用以简化when()。

总结

MobX 的故事围绕着可观察对象展开。操作改变这些可观察对象。派生和反应观察并对这些可观察对象的变化做出反应。可观察对象、操作和反应构成了核心三元组。

我们已经看到了几种用对象、数组、映射和包装可观察对象来塑造你的可观察对象的方法。操作是修改可观察对象的推荐方式。它们增加了操作的词汇量,并通过最小化变更通知来提高性能。反应是观察者,它们对可观察对象的变化做出反应。它们是导致应用程序产生副作用的原因。

反应有三种形式,autorun()、reaction()和when(),它们以长时间运行或一次性运行的方式区分自己。when()是唯一的一次性效果器,它有一个更简单的形式,可以在给定predicate函数的情况下返回一个 promise。

第三章:一个带有 MobX 的 React 应用

使用 React 很有趣。现在,再加上 MobX 来满足所有你的状态管理需求,你就有了一个超级组合。基本的 MobX 已经完成,我们现在可以进入使用之前讨论过的想法来构建一个简单的 React 应用。我们将处理定义可观察状态的过程,可以在该状态上调用的操作,以及观察和呈现变化状态的 React UI。

本章涵盖的主题包括以下内容:

  • 书籍搜索用例

  • 创建可观察状态和操作

  • 构建响应式 UI

技术要求

你需要有 JavaScript 编程语言。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/MobX-Quick-Start-Guide/tree/master/src/Chapter03

查看以下视频,看看代码是如何运行的:

bit.ly/2v0HnkW

书籍搜索

我们简单的 React 应用的用例是传统电子商务应用程序之一,即在巨大的库存中搜索产品。在我们的案例中,搜索的是书籍。我们将使用Goodreads API 来按标题或作者搜索书籍。Goodreads 要求我们注册一个帐户来使用他们的 API。

通过访问此 URL 创建一个 Goodreads 帐户:www.goodreads.com/api/keys。你可以使用你的亚马逊或 Facebook 帐户登录。一旦你有了帐户,你需要生成一个 API 密钥来进行 API 调用。

Goodreads 公开了一组端点,以 XML 格式返回结果。同意,这并不理想,但他们有大量的书籍,将 XML 转换为 JSON 对象是一个小小的代价。事实上,我们将使用一个npm包进行此转换。我们将使用的端点是 search-books (www.goodreads.com/search/index.xml?key=API_KEY&q=SEARCH_TERM)。

我们应用的 UI 将如下所示:

即使在这个看起来相当简单的界面中,也有一些非常规的用例。由于我们正在进行网络调用来获取结果,所以在显示“结果列表”之前,我们有一个等待结果的中间状态。此外,现实世界是严酷的,你的网络调用可能会失败或返回零结果。所有这些状态将在我们的 React UI 中通过 MobX 来处理。

可观察状态和操作

UI 只是数据的宏伟转换。它也是这些数据的观察者,并触发操作来改变它。由于数据(又名状态)对 UI 非常重要,因此我们首先从对这种状态进行建模开始是有意义的。使用 MobX,可观察对象表示该状态。回顾之前的 UI 设计,我们可以识别可观察状态的各个部分:

  • 用户输入的搜索文本。这是一个字符串类型的可观察字段。

  • 有一个可观察的结果数组。

  • 有关结果的元信息,例如当前子集和总结果计数。

  • 有一些状态来捕获我们将要调用的“async search()”操作。操作的初始“状态”是“空”。一旦用户调用搜索,我们就处于“挂起”状态。当搜索完成时,我们可能处于“完成”或“失败”状态。这更像是<empty>,pending,completed或failed的枚举,并且可以用可观察字段来捕获。

由于所有这些状态属性都相关,我们可以将它们放在一个可观察对象下:

const searchState = observable({
    term: '',
    state: '',
    results: [],
    totalCount: 0,
});

这肯定是一个很好的开始,似乎捕捉到了我们需要在 UI 上显示的大部分内容。除了状态,我们还需要确定可以在 UI 上执行的操作。对于我们简单的 UI,这包括调用搜索和在用户在文本框中输入字符时更新术语。在 MobX 中,操作被建模为动作,它们在内部改变可观察状态。我们可以将这些作为searchState可观察对象上的操作添加:

const searchState = observable({
    term: '',
    status: '',
    results: [],
    totalCount: 0,

    search: action(function() {
        // invoke search API
  }),

    setTerm: action(function(value) {
        this.term = value;
    }),
});

searchState可观察对象正在慢慢增长,并且在定义可观察状态时也积累了一些语法噪音。随着我们添加更多的可观察字段、计算属性和操作,这肯定会变得更加难以控制。更好的建模方式是使用类和装饰器。

关于我们为searchState可观察定义操作的方式有一个小注意事项。请注意,我们故意避免使用箭头函数来定义操作。这是因为箭头函数在定义操作时捕获词法 this。然而,observable() API 返回一个新对象,这当然与在action()调用中捕获的词法 this不同。这意味着您正在改变的this不会是从observable()返回的对象。您可以尝试通过将箭头函数传递给action()调用来验证这一点。

通过将一个普通函数传递给action(),我们可以确保this指向可观察的正确实例。

让我们看看使用类和装饰器是什么样子的:

class BookSearchStore {
    @observable term = '';
    @observable status = '';
    @observable.shallow results = [];

    @observable totalCount = 0;

    @action.bound
  setTerm(value) {
        this.term = value;
    }

    @action.bound
  async search() {
        // invoke search API
    }
}

export const store = new BookSearchStore();

使用装饰器使得很容易看到类的可观察字段。事实上,我们有灵活性来混合和匹配可观察字段和常规字段。装饰器还使得调整可观察性的级别变得容易(例如:为结果使用shallow可观察)。BookSearchStore类利用装饰器捕获可观察字段和操作。由于我们只需要这个类的一个实例,我们将单例实例导出为store。

管理异步操作

使用async search()操作更有趣。我们的 UI 需要在任何时间点知道操作的确切状态。为此,我们有可观察字段:status,用于跟踪操作状态。它最初处于empty状态,并在操作开始时变为pending。一旦操作完成,它可以处于completed或failed状态。您可以在代码中看到这一点,如下所示:

class BookSearchStore {
    @observable term = '';
    @observable status = '';
    @observable.shallow results = [];

    @observable totalCount = 0;

    /* ... */

    @action.bound
  async search() {
        try {
            this.status = 'pending';
            const result = await searchBooks(this.term);

            runInAction(() => {
                this.totalCount = result.total;
                this.results = result.items;
                this.status = 'completed';
            });
        } catch (e) {
            runInAction(() => (this.status = 'failed'));
            console.log(e);
        }
    }
}

在前面的代码中有一些值得注意的地方:

  • async操作与sync操作并没有太大不同。事实上,async-action 只是在不同时间点上的 sync-actions。

  • 设置可观察状态只是一个赋值的问题。我们在await之后的代码中使用runInAction()来确保所有可观察值都在一个操作内被改变。当我们为 MobX 打开enforceActions配置时,这变得至关重要。

  • 因为我们使用了async-await,我们在一个地方处理了两种未来的可能性。

  • searchBooks()函数只是一个调用 Goodreads API 并获取结果的服务方法。它返回一个 promise,我们在async操作中await它。

此时,我们已经准备好应用程序的可观察状态,以及可以对这些可观察对象执行的一组操作。我们将创建的 UI 只是简单地绘制这个可观察状态,并公开控件来调用这些操作。让我们直接进入 UI 的观察者领域。

刚刚看到的async search()方法中的一个观察是将状态变化包装在runInAction()中。如果您在这些调用之间有多个await调用并且有状态变化,这可能会变得很繁琐。认真地包装这些状态变化中的每一个可能会很麻烦,甚至可能会忘记包装!

为了避免这种繁琐的仪式,您可以使用一个名为flow()的实用函数,它接受一个generator函数,而不是await,使用yield操作符。flow()实用程序正确地在yield后包装了状态变化,而无需您自己去做。我们将在后面的章节中使用这种方法。

响应式 UI

在 MobX 的核心三部曲中,反应起着影响外部世界的作用。在第二章中,可观察对象、动作和反应,我们已经看到了一些这些反应的形式,如autorun()、reaction()和when():

observer()是另一种类型的反应,有助于将 React 世界与 MobX 绑定在一起。observer()是mobx-react NPM 包的一部分,这是一个用于 MobX 和 React 的绑定库。它创建了一个高阶组件(HOC),用于自动更新可观察状态的变化。在内部,observer()跟踪在组件的render方法中取消引用的可观察对象。当它们中的任何一个发生变化时,会触发组件的重新渲染。

在 UI 组件树中随处可以添加observer()组件是非常常见的。无论何时需要一个可观察对象来渲染组件,都可以使用observer()。

我们要构建的 UI 将把BookSearchStore的可观察状态映射到各种组件。让我们将 UI 分解为其结构组件,如下图所示。这里的观察者组件包括SearchTextField和ResultsList:

当您开始将可观察状态映射到 React 组件时,您应该从一个单片组件开始,该组件读取所有必要的状态并将其呈现出来。然后,您可以开始拆分观察者组件,并逐渐创建组件层次结构。建议您尽可能细化观察者组件。这可以确保当只有一小部分组件发生变化时,React 不会不必要地渲染整个组件。

在最高级别上,我们有App组件,它组合了SearchTextField和ResultsList。在代码中,这看起来如下:

import {**inject**, observer} from '**mobx-react**'; @inject('store')
@observer class App extends React.Component {
    render() {
        const { store } = this.props;

        return (
            <Fragment>
                <Header />

                <Grid container>
                    <Grid item xs={12}>
                      <Paper elevation={2}  style={{ padding: '1rem' }}>
                            <**SearchTextField**
  onChange={this.updateSearchText}   onEnter={store.search}  />
                        </Paper>
                    </Grid>

                    <ResultsList style={{ marginTop: '2rem' }} />
                </Grid>
            </Fragment>
        );
    }

    updateSearchText = event => {
        this.props.store.setTerm(event.target.value);
    };
}

如果您已经注意到了,App类上有一个我们以前没有见过的新装饰器:inject('store'),也是mobx-react包的一部分。这创建了一个将store可观察对象绑定到 React 组件的 HOC。这意味着,在App组件的render()中,我们可以期望在props上有一个store属性可用。

我们正在使用material-ui NPM 包来使用各种 UI 组件。这个组件库为我们的 UI 提供了 Material Design 外观,并提供了许多实用组件,如TextField、LinearProgress、Grid等。

到达 store

使用inject(),您可以将可观察的BookSearchStore连接到您的任何 React 组件。然而,神秘的问题是:inject()如何知道我们的BookSearchStore?这就是您需要查看App组件上一级发生的事情的地方,我们在那里渲染整个 React 应用程序:

import { store } from './BookStore';
import React, { Fragment } from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'mobx-react';

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root'),
);

来自mobx-react的Provider组件与BookSearchStore可观察对象建立了真正的连接粘合剂。导出的BookSearchStore(名为store)的单例实例作为名为store的 prop 传递到Provider中。在内部,它使用 React Context 将store传播到由inject()装饰器包装的任何组件。因此,Provider提供了store可观察对象,而inject()连接到React Context(由Provider公开),并将store注入到包装的组件中:

值得注意的是,命名 propstore并没有什么特别之处。您可以选择任何您喜欢的名称,甚至可以将多个可观察实例传递给Provider。如果我们的简单应用程序需要一个单独的用户偏好存储,我们可以这样传递它:

import { store } from './BookStore';
import { preferences } from 'PreferencesStore;

<Provider store={store} userPreferences={preferences}>
    <App />
</Provider>

当然,这意味着inject()也将将其引用为userPreferences:

@inject('userPreferences')
@observer class PreferencesViewer extends React.Component {
    render() {
        const { userPreferences } = this.props;

        /* ... */
  }
}

SearchTextField组件

回到我们最初的例子,我们可以利用Provider和inject()的功能,在组件树的任何级别访问store(BookSearchStore的一个实例)。SearchTextField组件利用它来成为store的观察者:

@inject('store')
@observer export class SearchTextField extends React.Component {
    render() {
 const { store, onChange } = this.props;
 const { term } = store;

        return (
            <Fragment>
                <TextField
  placeholder={'Search Books...'}   InputProps={{
                        startAdornment: (
                            <InputAdornment position="start">
                                <Search />
                            </InputAdornment>
                        ),
                    }}   fullWidth={true}  value={term}   onChange={onChange}   onKeyUp={this.onKeyUp}  />

                <SearchStatus />
            </Fragment>
        );
    }

    onKeyUp = event => {
        if (event.keyCode !== 13) {
            return;
        }

        this.props.onEnter();
    };
}

SearchTextField观察store的term属性,并在其更改时更新自身。对term的更改作为TextField的onChange处理程序的一部分进行处理。实际的onChange处理程序作为一个 prop 传递到SearchTextField中,由App组件传递。在App组件中,我们触发setTerm()动作来更新store.term属性。

@inject('store')
@observer class App extends React.Component {
    render() {
        const { store } = this.props;

        return (
            <Fragment>
                <Header />

                <Grid container>
                    <Grid item xs={12}>
                      <Paper elevation={2}  style={{ padding: '1rem' }}>
                            <SearchTextField
 onChange={this.updateSearchText}  onEnter={store.search}  />
                        </Paper>
                    </Grid>

                    <ResultsList style={{ marginTop: '2rem' }} />
                </Grid>
            </Fragment>
        );
    }

 updateSearchText = event => {
 this.props.store.setTerm(event.target.value);
 };
}

现在,SearchTextField不仅处理对store.term可观察对象的更新,还显示了SearchStatus组件的搜索操作状态。我们将这个组件直接包含在SearchTextField中,但没有传递任何 props。起初这可能有点不安。SearchStatus如何知道当前的store.status?嗯,一旦你看到SearchStatus的定义,这就显而易见了:

import React, { Fragment } from 'react';
import { inject, observer } from 'mobx-react';

export const SearchStatus = inject('store')(
    observer(({ store }) => {
        const { status, term } = store;

        return (
            <Fragment>
                {status === 'pending' ? (
                    <LinearProgress variant={'query'} />
                ) : null}

                {status === 'failed' ? (
                    <Typography
  variant={'subheading'}   style={{ color: 'red', marginTop: '1rem' }}  >
                        {`Failed to fetch results for "${term}"`}
                    </Typography>
                ) : null}
            </Fragment>
        );
    }),
);

使用inject(),我们可以访问store可观察对象,并通过使用observer()包装组件,我们可以对可观察状态(term,status)的变化做出反应。注意嵌套调用inject('store')(observer( () => {} ))的使用。这里的顺序很重要。首先调用inject()请求要注入的 Provider-prop。这将返回一个以组件为输入的函数。在这里,我们使用observer()创建一个高阶组件,并将其传递给inject()。

由于SearchStatus组件基本上是独立的,SearchTextField可以简单地包含它并期望它能正常工作。

当store.status改变时,只有SearchStatus的虚拟 DOM 发生变化,重新渲染了该组件。SearchTextField的其余部分保持不变。这种渲染效率内置在observer()中,你不需要额外的工作。在内部,observer()会仔细跟踪在render()中使用的可观察对象,并设置一个reaction()来在任何被跟踪的可观察对象发生变化时更新组件。

ResultsList 组件

使用SearchTextField,当您输入一些文本并按下Enter时,搜索操作将被调用。这会改变可观察状态,部分由SearchTextField渲染。然而,当结果到达时,与搜索词匹配的书籍列表将由ResultsList组件显示。正如预期的那样,它是一个观察者组件,通过inject()连接到store可观察对象。但这一次,它使用了稍微不同的方法连接到store:

import { inject, observer } from 'mobx-react';

@inject(({ store }) => ({ searchStore: store }))
@observer
export class ResultsList extends React.Component {
    render() {
        const { searchStore, style } = this.props;
        const { isEmpty, results, totalCount, status } = searchStore;

        return (
            <Grid spacing={16} container style={style}>
                {isEmpty && status === 'completed' ? (
                    <Grid item xs={12}>
 <EmptyResults />
                    </Grid>
                ) : null}

                {!isEmpty && status === 'completed' ? (
                    <Grid item xs={12}>
                        <Typography>
                            Showing <strong>{results.length}</strong> 
                             of{' '}
                            {totalCount} results.
                        </Typography>
                        <Divider />
                    </Grid>
                ) : null}

                {results.map(x => (
                    <Grid item xs={12} key={x.id}>
 <BookItem book={x} />
                        <Divider />
                    </Grid>
                ))}
            </Grid>
        );
    }
}

请注意使用@inject装饰器,该装饰器接受一个函数来提取store可观察对象。这为您提供了一种更加类型安全的方法,而不是使用字符串属性。您还会看到我们在提取函数中将store重命名为searchStore。因此,store可观察对象将以searchStore的名称注入。

在ResultsList的渲染方法中,我们还在做一些值得注意的其他事情:

  • 使用isEmpty属性检查搜索结果是否为空。这之前没有声明,但实际上是一个computed属性,检查结果数组的长度,如果为零则返回true:
class BookSearchStore {
    @observable term = 'javascript';
    @observable status = '';
    @observable.shallow results = [];

    @observable totalCount = 0;

 @computed
  get isEmpty() {
 return this.results.length === 0;
 }

    /* ... */
}

如果搜索操作已完成并且没有返回结果(isEmpty = true),我们将显示EmptyResults组件。

  • 如果搜索完成并且我们得到了一些结果,我们将显示计数以及结果列表,每个结果都使用BookItem组件渲染。

因此,我们应用程序的组件树如下所示:

Provider实际上是可观察状态的提供者。它依赖于 React Context 来在组件子树中传播store可观察对象。通过使用inject()和observer()装饰组件,您可以连接到可观察状态并对更改做出反应。SearchTextField、SearchStatus和ResultsList组件依赖于observer()和inject()为您提供响应式 UI。

随着在 React 16.3+中引入React.createContext(),您可以自己创建Provider组件。这可能有点冗长,但它实现了相同的目的——在组件子树中传播存储。如果您感到有点冒险,可以尝试一下。

总结

mobx和mobx-react是两个广泛用于构建响应式 UI 的 NPM 包。mobx包提供了构建可观察状态、动作和反应的 API。另一方面,mobx-react提供了将 React 组件与可观察状态连接并对任何更改做出反应的绑定粘合剂。在我们的示例中,我们利用这些 API 构建了一个图书搜索应用程序。在创建基于observer的组件树时,确保使用观察者进行细粒度操作。这样你就可以对你需要渲染 UI 的可观察对象做出反应。

SearchTextField、SearchStatus和ResultsList组件旨在细粒度并对焦点可观察表面做出反应。这是在 React 中使用 MobX 的推荐方式。

在下一章中,我们将深入探讨 MobX,探索可观察对象。

第四章:创建可观察树

定义应用程序的响应模型通常是使用 MobX 和 React 时的第一步。我们非常清楚,这都属于以下领域:

  • Observables, which represent the application state

  • 操作,改变它

  • Reactions, which produce side effects by observing the changing observables

在定义可观察状态时,MobX 为您提供了各种工具来精确控制可观察性。在本章中,我们将探讨 MobX 的这一方面,并深入研究创建可观察树。

本章将涵盖以下主题:

  • 数据的形状

  • 使用各种装饰器控制可观察性

  • 创建计算属性

  • 使用类建模 MobX 存储

技术要求

您需要掌握 JavaScript 编程语言。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/MobX-Quick-Start-Guide/tree/master/src/Chapter04

查看以下视频以查看代码的实际操作:

bit.ly/2uYmln9

数据的形状

我们在应用程序中处理的数据以各种形状和大小出现。然而,这些不同的形状相当有限,可以列举如下:

  • Singular values: These include primitives like numbers, booleans, strings, null, undefined, dates, and so on.

  • 列表: 您典型的项目列表,其中每个项目都是独一无二的。通常最好避免将不同数据类型的项目放在同一个列表中。这样可以创建易于理解的同质列表。

  • 层次结构: 我们在 UI 中看到的许多结构都是分层的,比如文件和文件夹的层次结构,父子关系,组和项目等等。

  • 组合: 一些或所有前述形状的组合。大多数现实世界的数据都是这种形式。

MobX 给了我们 API 来模拟每个形状,我们已经在之前的章节中看到了一些例子。然而,MobX 在单一值和其他类型(如数组和映射)之间做了一个区分。这也反映在 API 中,observable()只能用来创建对象、数组和映射。将单一值创建为 observable 需要我们使用observable.box()API 来包装它。

控制可观察性

默认情况下,MobX 对您的对象、数组和映射应用深度可观察性。这使您可以看到可观察树中任何级别的变化。虽然这是一个很好的默认值,但在某些时候,您将不得不更加关注限制可观察性。减少可观察性也可以提高性能,因为 MobX 需要跟踪的内容更少。

有两种不同的方式可以控制可观察性:

  • 通过在类内部使用各种@decorators

  • 通过使用decorate() API

使用@decorators

装饰器是一种语法特性,允许您将行为附加到类及其字段上。我们已经在第三章中看到了这一点,使用 MobX 创建 React 应用,因此以下代码应该非常熟悉:

class BookSearchStore {
    @observable term = 'javascript';
    @observable status = '';
    @observable.shallow results = [];

    @observable totalCount = 0;
}

使用@observable装饰器,您可以将类的属性变成可观察的。这是开始建模可观察对象的推荐方法。默认情况下,@observable应用深度可观察性,但还有一些专门的装饰器可以让您更好地控制。

@observable是@observable.deep的缩写形式或别名,这是默认的装饰器。它在对象、数组和映射的所有级别上应用深度可观察性。然而,深度观察在对象具有构造函数或原型的地方停止。这样的对象通常是类的实例,并且预计具有自己的可观察属性。MobX 选择在深度观察期间跳过这样的对象。

使用@observable.shallow 创建浅观察对象

这个装饰器将可观察性修剪到数据的第一层,也称为一级深度观察,对于可观察数组和映射特别有用。对于数组,它将监视数组本身的引用更改(例如,分配一个新数组),以及数组中项目的添加和删除。如果数组中有具有属性的项目,则这些属性不会被视为浅观察。同样,对于映射,只考虑键的添加和删除,以及映射本身的引用更改。可观察映射中键的值保持不变,不被视为观察对象。

以下代码片段展示了@observable.shallow装饰器的应用。

class BookSearchStore {
    @observable term = 'javascript';
    @observable status = '';
 @observable.shallow results = [];

    @observable totalCount = 0;
}

我们选择将这个装饰器应用到BookSearchStore的results属性上。很明显,我们并不特别观察每个单独结果的属性。事实上,它们是只读对象,永远不会改变值,因此我们只需要将可观察性修剪到项目的添加和移除以及results数组中的引用更改。因此,observable.shallow在这里是正确的选择。

这里需要记住的一个微妙的点是数组的length属性(在地图的情况下是size)也是可观察的。你能想出它为什么是可观察的吗?

使用@observable.ref 创建仅引用的可观察对象

如果您不对数据结构(对象、数组、地图)内发生的任何更改感兴趣,而只对值的更改感兴趣,那么@observable.ref就是您要找的东西。它只会监视可观察对象的引用更改。

import { observable, action } from 'mobx';

class FormData {
 @observable.ref validations = null;

    @observable username = '';
    @observable password = '';

    @action
  validate() {
        const { username, password } = this;
 this.validations = applyValidations({ username, password });
    }
}

在前面的例子中,validations可观察性总是被分配一个新值。由于我们从未修改此对象的属性,最好将其标记为@observable.ref。这样,我们只跟踪validations的引用更改,而不跟踪其他任何东西。

使用@observable.struct 创建结构可观察对象

MobX 具有内置行为来跟踪值的更改,并且对于诸如字符串、数字、布尔值等基元类型非常有效。但是,在处理对象时,它变得不太理想。每当将新对象分配给可观察对象时,它都将被视为更改,并且反应将触发。您真正需要的是结构检查,其中比较对象的属性而不是对象引用,然后决定是否有更改。这就是@observable.struct的目的。

它基于属性值进行深度比较,而不是依赖顶层引用。您可以将其视为对observable.ref装饰器的改进。

让我们看一下以下代码,我们为location属性创建一个@observable.struct:

class Sphere {
 @observable.struct location = { x: 0, y: 0 };

    constructor() {
 autorun(() => {
 console.log(
 `Current location: (${this.location.x}, ${this.location.y})`,
 );
 });
    }

    @action
  moveTo(x, y) {
        this.location = { x, y };
    }
}

let x = new Sphere();

x.moveTo(0, 0);
x.moveTo(20, 30); // Prints
Current location: (0, 0)
Current location: (20, 30)

请注意,autorun()立即触发一次,然后不会对下一个位置({ x: 0, y: 0})做出反应。由于结构值相同(0, 0),它不被视为更改,因此不会触发通知。只有当我们将位置设置为不同的(x, y)值时,autorun()才会被触发。

现在我们可以表示装饰器的可观察性级别,如下图所示。@observable(在这种情况下,@observable.deep)是最强大的,其次是@observable.shallow,@observable.ref,最后是@observable.struct。随着可观察装饰器的细化,您可以修剪可观察树中的表面积。这用橙色形状表示。可观察的越多,MobX 的跟踪区域就越大:

使用 decorate() API

使用@decorators绝对非常方便和可读,但它确实需要一些 Babel 设置(使用babel-plugin-transform-decorators-legacy)或在 TypeScript 的编译器选项中打开experimentalDecorators标志。MobX 在版本 4 中引入了用于装饰对象或类的可观察属性的ES5 API。

使用decorate() API,您可以有选择地针对属性并指定可观察性。以下代码片段应该可以说明这一点:

import { action, computed, decorate, observable } from 'mobx';
 class BookSearchStore {
 term = 'javascript';
 status = '';
 results = [];

 totalCount = 0;

 get isEmpty() {
 return this.results.length === 0;
 }

 setTerm(value) {
 this.term = value;
 }

 async search() {}
}

decorate(BookSearchStore, {
 term: observable,
 status: observable,
 results: observable.shallow,
 totalCount: observable,

 isEmpty: computed,
 setTerm: action.bound,
 search: action.bound,
});
decorate(target, decorator-object)

target可以是对象原型或类类型。第二个参数是一个包含要装饰的目标属性的对象。

在前面的示例中,请注意我们将装饰器应用于类类型的方式。从开发人员的角度来看,在没有@decorators语法支持时使用它们感觉很自然。事实上,decorate() API 也可以用于其他类型的装饰器,如action,action.bound和computed。

使用 observable()进行装饰

使用decorate() API 时,声明可观察性也适用于observable() API。

observable(properties, decorators, options):它的参数如下:

  • properties: 声明可观察对象的属性

  • decorators: 定义属性装饰器的对象

  • options: 用于设置默认可观察性和调试友好名称的选项 ({ deep: false|true, name: string })

observable()的第二个参数是您在对象中为各种属性指定装饰器的地方。这与decorate()调用的工作方式完全相同,如下面的代码片段所示:

import { action, computed, observable } from 'mobx';

const cart = observable(
    {
        items: [],
        modified: new Date(),
        get hasItems() {
            return this.items.length > 0;
        },
        addItem(name, quantity) {
            /* ... */
  },
        removeItem(name) {
            /* ... */
  },
    },
 {
 items: observable.shallow,
 modified: observable,

 hasItems: computed,
 addItem: action.bound,
 removeItem: action.bound,
 },
);

在第二个参数中,我们已经应用了各种装饰器来控制可观察性,应用操作,并标记计算属性。

在使用observable()API 时,不需要显式标记计算属性。MobX 将把传入对象的任何getter属性转换为计算属性。

同样,对于modified属性,实际上没有必要进行装饰,因为observable()默认会使所有内容深度可观察。我们只需要指定需要不同处理的属性。换句话说,只为特殊属性指定装饰器。

扩展可观察性

在建模客户端状态时,最好预先定义我们在响应式系统中需要的可观察性。这样可以将领域中的可观察数据的所有约束和范围都固定下来。然而,现实世界总是不可饶恕的,有时您需要在运行时扩展可观察性。这就是extendObservable()API 的用武之地。它允许您在运行时混入额外的属性,并使它们也可观察。

在下面的例子中,我们正在扩展cart的可观察性以适应节日优惠:

import { observable, action, extendObservable } from 'mobx';

const cart = observable({
    /* ... */ });

function applyFestiveOffer(cart) {
    extendObservable(
        cart,
        {
            coupons: ['OFF50FORU'],
            get hasCoupons() {
                return this.coupons && this.coupons.length > 0;
            },
            addCoupon(coupon) {
                this.coupons.push(coupon);
            },
        },
        {
            coupons: observable.shallow,
            addCoupon: action,
        },
    );
}
extendObservable(target, object, decorators)

extendObservable()的第一个参数是我们要扩展的目标对象。第二个参数是将混入目标对象的可观察属性和操作的列表。第三个参数是将应用于属性的装饰器的列表。

在前面的例子中,我们想要为购物车添加更多可观察的内容,以跟踪节日优惠。这只能在运行时根据活动的节日季节来完成。当满足条件时,将调用applyFestiveOffers()函数。

extendObservable()实际上是observable()和observable.object()的超集。observable()实际上是extendObservable({}, object)。这看起来与decorate()相似并非巧合。MobX 努力保持 API 一致和直观。虽然extendObservable()的第一个参数是实际对象,但decorate()要求它是类和对象原型。

[趣闻]在引入decorate()之前,extendObservable()被用来在类构造函数内部扩展this:extendObservable(this, { })。当然,现在推荐的方法是使用decorate(),它可以直接应用于类或对象原型。

值得思考的一点是,observable Map也可以用于动态添加可观察属性。但是,它们只能是状态承载属性,而不是操作或计算属性。当您想要动态添加操作和计算属性时,可以使用extendObservable()。

使用@computed 派生状态

MobX 的一个核心理念是可观察状态应尽可能简化。其他一切都应该通过计算属性派生出来。当我们谈论 UI 中的状态管理时,这种观点是有道理的。UI 始终对相同的可观察状态进行微妙的处理,并根据上下文和任务的不同需要状态的不同视图。这意味着在同一个 UI 中有许多可能性来派生基于视图的状态(或表示)。

这种基于视图的状态的一个例子是相同可观察对象列表的表视图和图表视图。两者都在相同的状态上操作,但需要不同的表示来满足 UI(视图)的需求。这样的表示是状态派生的主要候选对象。MobX 认识到了这一核心需求,并提供了计算属性,这些计算属性是从其他依赖的可观察对象派生其值的专门的可观察对象。

计算属性非常高效并且缓存计算结果。虽然计算属性在依赖的可观察对象发生变化时会重新评估,但如果新值与先前缓存的值匹配,则不会触发通知。此外,如果没有计算属性的观察者,计算属性也会被垃圾回收。这种自动清理也增加了效率。缓存和自动清理是 MobX 建议大量使用计算属性的主要原因。

使用计算属性,我们可以根据 UI 的需要创建单独的可观察对象。随着应用程序规模的增长,您可能需要更多依赖于核心状态的派生。这些派生(计算属性)可以在需要时使用extendObservable()混合进来。

MobX 提供了三种不同的方式来创建计算属性:使用@computed装饰器,decorate() API,或者使用computed()函数。这些可以在以下代码片段中看到:

import { observable, computed, decorate } from 'mobx';

// 1\. Using @computed class Cart {
    @observable.shallow items = [];

 @computed
  get hasItems() {
 return this.items.length > 0;
 }
}

// 2\. Using decorate() class Cart2 {
    items = [];

    get hasItems() {
        return this.items.length > 0;
    }
}
decorate(Cart2, {
    items: observable.shallow,
 hasItems: computed,
});

// 3\. Using computed() const cart = new Cart();

const isCartEmpty = computed(() => {
 return cart.items.length === 0;
});

console.log(isCartEmpty.get());

const disposer = isCartEmpty.observe(change => console.log(change.newValue));

直接使用computed()函数的感觉就像是在使用包装的可观察对象。您必须使用返回的计算函数上的get()方法来检索值。

您还可以使用computed()函数的observe()方法。通过附加观察者,您可以获得更改后的值。这种技术也可以用于处理副作用或反应。

这两个 API 都可以在前面的代码片段中看到。这种用法并不是很常见,但在直接处理装箱可观察对象时可以利用。

结构相等

如果计算属性的返回值是一个原始值,那么很容易知道是否有新值。MobX 会将计算属性的先前值与新计算的值进行比较,然后在它们不同时触发通知。因此,值比较变得重要,以确保通知只在真正的改变时触发。

对于对象来说,这并不是一件简单的事情。默认比较是基于引用检查进行的(使用===运算符)。这会导致对象被视为不同,即使它们内部的值完全相同。

在下面的示例中,metrics计算属性每次start或end属性更改时都会生成一个新对象。由于autorun(在构造函数中定义)依赖于metrics,它会在每次metrics更改时运行副作用:

import { observable, computed, action, autorun } from 'mobx';

class DailyPrice {
    @observable start = 0;
    @observable end = 0;

 @computed
  get metrics() {
 const { start, end } = this;
 return {
 delta: end - start,
 };
 }

    @action
  update(start, end) {
        this.start = start;
        this.end = end;
    }

    constructor() {
        autorun(() => {
            const { delta } = this.metrics;
            console.log(`Price Delta = ${delta}`);
        });
    }
}

const price = new DailyPrice();

// Changing start and end, but metrics don't change
price.update(0, 10);
price.update(10, 20);
price.update(20, 30);

但是,请注意,即使start和end属性在更改,metrics实际上并没有改变。这可以通过autorun副作用来看出,它一直打印相同的增量值。这是因为metrics计算属性在每次评估时都返回一个新对象:

Price Delta = 0;
Price Delta = 10;
Price Delta = 10;
Price Delta = 10;

修复这个问题的方法是使用@computed.struct装饰器,它会对对象结构进行深度比较。这确保在重新评估metrics属性时返回相同结构时不会触发任何通知。

这是一种保护依赖于这样一个计算可观察对象的昂贵反应的方法。使用computed.struct装饰它,以确保只有对象结构的真正改变被视为通知。在概念上,这与我们在本章前一节中看到的observable.struct装饰器非常相似:

class DailyPrice {
    @observable start = 0;
    @observable end = 0;

 @computed.struct  get metrics() {
        const { start, end } = this;
        return {
            delta: end - start,
        };
    }
    // ... 
}

在实践中,很少使用computed.struct可观察对象。计算值只有在依赖的可观察对象发生变化时才会改变。当任何依赖的可观察对象发生变化时,必须创建一个新的计算值,在大多数真实世界的应用程序中,它在大多数情况下是不同的。因此,你不需要真的使用computed.struct修饰,因为大多数计算值在连续评估中都会非常不同。

建模存储

当你开始使用 MobX 为你的 React 应用程序建模客户端状态时,这似乎是一项艰巨的任务。一个可以帮助你在这个过程中的想法是简单地意识到你的应用程序只是一组特性,组合在一起形成一个连贯的单元。通过从最简单的特性开始,你可以逐个特性地串联整个应用程序。

这种思维方式指导你首先对特性级别的存储进行建模。应用级别的存储(也称为根存储)只是这些特性存储的组合,具有共享的通信渠道。在 MobX 世界中,你首先使用一个类来描述特性存储。根据复杂程度,你可以将特性存储分解为许多子存储。特性存储充当所有子存储的协调者。这是对软件建模的经典分而治之方法:

让我们举个例子来说明这种建模响应式客户端状态的方法。在我们之前构建的图书搜索应用中,我们想要添加创建愿望清单的功能。愿望清单可以包含你将来想要购买的物品。你应该能够创建任意多个愿望清单。让我们使用 MobX 来建模愿望清单功能。我们不会担心 React 方面的事情,而是专注于使用 MobX 来建模客户端状态。

愿望清单功能

这增加了创建愿望清单的能力。愿望清单有一个名称,并包含一个将来要购买的物品列表。可以根据需要创建任意多个愿望清单。愿望清单项具有物品的标题和一个标志来跟踪是否已购买。

使用 MobX 进行建模的第一步是确定可观察状态和可以改变它的操作。我们现在不会担心反应(或观察者)。

可观察状态

我们将从一个类WishListStore开始,来跟踪愿望清单功能的所有细节。这是我们的特性级存储,其中包含整个特性的可观察状态。根据我们之前看到的描述,让我们提炼核心可观察状态:

  • 一个愿望清单数组,其中每个项目都是WishList类的一个实例

  • WishList有一个名称,并包含WishListItem实例的数组

  • 每个WishListItem都有一个标题和一个布尔值purchased属性

这里值得注意的一件事是,我们从之前的描述中提取了一些词汇。这包括WishListStore,WishList和WishListItem,它们构成了我们特性的支柱。识别这些词汇是困难的部分,可能需要几次迭代才能找到正确的术语。难怪“命名事物”被归类为计算机科学中的两个难题之一!

在代码中,我们现在可以这样捕获这个可观察状态:

import { observable } from 'mobx';

class WishListStore {
    @observable.shallow lists = [];
}

class WishList {
    @observable name = '';
    @observable.shallow items = [];
}

class WishListItem {
    @observable title = '';
    @observable purchased = false;
}

const store = new WishListStore();

注意数组的observable.shallow装饰器的使用。我们不需要对它们进行深层观察。单独的项目(WishListItem)有它们自己的可观察属性。愿望清单功能由WishListStore(store)的单例实例表示。由于我们将创建WishList和WishListItem的实例,我们可以添加构造函数来使这更容易:

class WishList {
    @observable name = '';
    @observable.shallow items = [];

 constructor(name) {
 this.name = name;
 }
}

class WishListItem {
    @observable title = '';
    @observable purchased = false;

 constructor(title) {
 this.title = title;
 }
}

派生状态

现在核心可观察状态已经建立,我们可以考虑一下派生状态。派生状态(推导)是依赖于其他可观察属性的计算属性。在消费核心可观察状态的上下文中考虑派生状态是有帮助的。

当你有数组时,一个常见的用例是考虑空状态。通常有一些视觉指示列表是空的。与其测试array.length,这是相当低级的,不如暴露一个名为isEmpty的计算属性。这样的计算属性关注我们存储的语义,而不是直接处理核心可观察状态:

class WishListStore {
    @observable.shallow lists = [];

 @computed
  get isEmpty() {
 return this.lists.length === 0;
 }
}

class WishList {
    @observable name = '';
    @observable.shallow items = [];

 @computed
  get isEmpty() {
 return this.items.length === 0;
 }

    /* ... */
}

同样,如果我们想知道从WishList中购买的物品,就不需要定义任何新的可观察状态。它可以从items通过过滤purchased属性来派生。这就是purchasedItems的定义计算属性。我将把定义这个计算属性留给读者作为练习。

您应该始终将observable state视为最小core state和derived state的组合。请考虑以下方程式,以确保您没有将太多内容放入核心状态中。可以派生的内容应始终位于derived state中:

在现实世界的应用程序中,很可能由于重构而将在一个存储中跟踪的属性移动到另一个存储中。例如,WishListItem的purchased属性可以由一个单独的存储(例如ShoppingCartStore)跟踪。在这种情况下,WishListItem可以将其设置为computed property并依赖外部存储来跟踪它。这样做不会改变 UI 上的任何内容,因为您读取purchased的方式仍然保持不变。此外,由于计算属性隐式创建的依赖关系,MobX 使得保持purchased属性始终保持最新变得简单。

操作

一旦确定了 observable state,就自然而然地包括可以改变它的actions。这些是用户将调用的操作,并由 React 接口公开。在愿望清单功能的情况下,这包括:

  • 创建新的WishList

  • 删除愿望清单

  • 重命名愿望清单

  • 将项目(WishListItem)添加到愿望清单

  • 从愿望清单中删除项目

将添加或删除愿望清单的操作放入顶层的WishListStore中,而涉及愿望清单中项目的操作将放在WishList类中。愿望清单的重命名也可以放在WishList类中:

import { observable, action } from 'mobx';

class WishListStore {
    @observable.shallow lists = [];

    /* ... */

    @action
  addWishList(name) {
        this.lists.push(new WishList(name));
    }

    @action
  removeWishList(list) {
        this.lists.remove(list);
    }
}

class WishList {
    @observable name = '';
    @observable.shallow items = [];

    /* ... */ 
    @action
  renameWishList(newName) {
        this.name = newName;
    }

    @action
  addItem(title) {
        this.items.push(new WishListItem(title));
    }

    @action
  removeItem(item) {
        this.items.remove(item);
    }
}

MobX 为observable arrays提供了方便的 API 来移除项目。使用remove()方法,您可以删除与值或引用匹配的项目。如果找到并删除了该项目,则该方法将返回true。

摘要

一旦对 observable state 进行了广泛的切割,就可以使用 observable decorators 进一步定制它。这样可以更好地控制可观察性,并改善 MobX 响应性系统的性能。我们已经看到了两种不同的方法:一种是使用@decorator语法,另一种是使用decorate()API。

还可以使用extendObservable()动态添加新的observable properties。实际上,您甚至可以使用extendObservable()添加新的actions和computed properties。

Observable State = Core State + Derived State

核心状态和派生状态是 MobX 中可观察状态的两个方面。这很容易用类和装饰器来建模,就像前面的章节中所示的那样。一旦你确定了你的功能的词汇,它们就成为封装可观察状态的类名。为了处理功能的复杂性,你可以将其分解为较小的类,并将它们组合在功能存储中。然后这些功能存储再组合在顶层的根存储中。

现在我们对定义和构建可观察对象有了更深入的理解,是时候我们来看看 MobX 的其他支柱:actions和reactions。这就是我们下一章要讨论的内容。

第五章:派生、操作和反应

现在,MobX 的基础已经奠定了可观察、操作和反应这三大支柱,是时候深入了解更精妙的细节了。在本章中,我们将探索 MobX API 的核心理念和微妙之处,以及一些特殊的 API 来简化 MobX 中的异步编程。

本章涵盖的主题包括:

  • 计算属性(也称为派生)及其各种选项

  • 操作,特别关注异步操作

  • 反应和规则,控制 MobX 反应的时机

技术要求

您需要在系统上安装 Node.js。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/MobX-Quick-Start-Guide/tree/master/src/Chapter05

查看以下视频,了解代码的运行情况:

bit.ly/2mAvXk9

派生(计算属性)

派生是 MobX 术语中经常使用的一个词。在客户端状态建模中特别强调。正如我们在上一章中看到的,可观察状态可以由核心可变状态和派生只读状态的组合确定:

可观察状态 = (核心可变状态) + (派生只读状态)

尽量保持核心状态尽可能精简。这部分预计在应用程序的生命周期内保持稳定并缓慢增长。只有核心状态实际上是可变的,操作总是只改变核心状态。派生状态取决于核心状态,并由 MobX 反应性系统保持最新。我们知道 计算属性 在 MobX 中充当派生状态。它们不仅可以依赖于核心状态,还可以依赖于其他派生状态,从而创建一个由 MobX 保持活跃的依赖树:

派生状态的一个关键特征是它是只读的。它的工作是生成一个计算值(使用核心状态),但永远不会改变核心状态。MobX 很聪明地缓存这些计算值,并且在没有计算值的观察者时不执行任何不必要的计算。强烈建议尽可能利用计算属性,并不用担心性能影响。

让我们举一个例子,你可以拥有一个最小的核心状态和一个派生状态来满足 UI 的需求。考虑一下Todo,TodoList和TodoManager。你可能猜到这些类是做什么的。它们构成了Todos应用程序的可观察状态:

import { computed, decorate, observable, autorun, action } from 'mobx';

class Todo {
    @observable title = '';
    @observable done = false;

    constructor(title) {
        this.title = title;
    }
}

class TodoList {
    @observable.shallow todos = [];

    **@computed**
  get pendingTodos() {
        return this.todos.filter(x => x.done === false);
    }

    **@computed**
  get completedTodos() {
        return this.todos.filter(x => x.done);
    }

 @computed
    get pendingTodosDescription() {
        const count = this.pendingTodos.length; return `${count} ${count === 1 ? 'todo' : 'todos'} remaining`;
    }

 @action  addTodo(title) {
 const todo = new Todo(title);
 this.todos.push(todo);
    }
}

class TodoManager {
    list = null;

    @observable filter = 'all'; // all, pending, completed
  @observable title = ''; // user-editable title when creating a new 
    todo

    constructor(list) {
        this.list = list;

        autorun(() => {
            console.log(this.list.pendingTodos.length);
        });
    }

    **@computed**
  get visibleTodos() {
        switch (this.filter) {
            case 'pending':
                return this.list.pendingTodos;
            case 'completed':
                return this.list.completedTodos;
            default:
                return this.list.todos;
        }
    }
}

从上面的代码中可以看出,核心状态由使用@observable标记的属性定义。它们是这些类的可变属性。对于Todos应用程序,核心状态主要是Todo项目的列表。

派生状态主要是为了满足 UI 的过滤需求,其中包括使用@computed标记的属性。特别感兴趣的是TodoList类,它只有一个@observable:一个todos数组。其余的是由@computed标记的pendingTodos,pendingTodosDescription和completedTodos组成的派生状态。

通过保持精简的核心状态,我们可以根据 UI 的需要产生许多派生状态的变化。这样的派生状态也有助于保持语义模型的清晰和简单。这也给了你一个机会来强制执行领域的词汇,而不是直接暴露原始的核心状态。

这是一个副作用吗?

在第一章 状态管理简介中,我们谈到了副作用的作用。这些是应用程序的响应性方面,根据状态(也称为数据)的变化产生外部效果。如果我们现在通过副作用的角度来看computed 属性,你会发现它与 MobX 中的反应非常相似。毕竟,在 MobX 中,反应会查看可观察对象并产生副作用。计算属性也是这样做的!它依赖于可观察对象并产生可观察值作为副作用。那么,computed 属性应该被视为副作用吗?

确实是一个非常有力的论点。它可能会出现作为它派生的一种副作用,但它生成可观察值的事实将其带回到客户端状态的世界,而不是成为外部影响。实际上,计算属性是 UI 和其他状态管理方面的数据。与 MobX 引起副作用的函数(如autorun()、reaction()和when())不同,计算属性不会引起任何外部副作用,并且保持在客户端状态的范围内。

MobX 反应和计算属性之间的另一个明显区别是,计算属性有一个隐含的期望会返回一个值,而反应是即时反应,没有期望得到一个值。此外,对于计算属性,重新评估(计算属性的副作用部分)可以在没有更多观察者时停止。然而,对于反应,何时停止它们并不总是清楚。例如,何时停止记录或网络请求并不总是清楚。

因此,让我们通过说计算属性只是部分副作用而不是 MobX 的全面、即时反应来结束这个案例。

computed()还有更多内容

到目前为止,我们已经看到了@computed装饰器与@computed.struct的使用,其中结构相等非常重要。当然,computed函数还有更多内容,还可以采用多个选项进行精细的定制。在使用decorate()函数、@computed装饰器或创建boxed-computed observables时,这些选项都是可用的。

在下面的片段中,我们看到了在decorate()函数中的使用,这更常见:

class TodoList {
    @observable.shallow todos = [];
    get pendingTodos() {
        return this.todos.filter(x => x.done === false);
    }

    get completedTodos() {
        return this.todos.filter(x => x.done);
    }

    @action
  addTodo(title) {
        const todo = new Todo(title);
        this.todos.push(todo);
    }
}

decorate(TodoList, {
 pendingTodos: computed({ name: 'pending-todos', /* other options */ }),
});

可以将computed()的选项作为对象参数传递,具有多个属性:

  • name:与 MobX DevTools(mobx-react-devtools NPM 包的一部分)结合使用时很有用。在日志中使用此处指定的名称,并且在检查呈现的 React 组件的observables时也会使用。

  • context:计算函数内部的值“this”。一般情况下,您不需要指定,因为它将默认为装饰实例。

  • set:计算属性最常用作getter。但是,你也可以提供一个 setter。这不是为了替换计算属性的值,而是作为反向。考虑以下示例,其中fullName的 setter 将其拆分为firstName和lastName:

class Contact {
    @observable firstName = '';
    @observable lastName = '';

 get fullName() {
 return `${this.firstName} ${this.lastName}`;
 }

}

decorate(Contact, {
    fullName: computed({
        // extract firstName and lastName
 set: function(value) {
 const [firstName, lastName] = value.split(' ');

 this.firstName = firstName;
 this.last = lastName;
 },
    }),
});

要在类内部执行相同的操作,而不使用decorate(),只需添加一个 setter,如下面的代码所示:

class Contact {
    @observable firstName = '';
    @observable lastName = '';

    @computed
  get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }

 set fullName(value) {
 const [firstName, lastName] = value.split(' ');

 this.firstName = firstName;
 this.lastName = lastName;
 }
}

const c = new Contact();

c.firstName = 'Pavan';
c.lastName = 'Podila';

console.log(c.fullName); // Prints: Pavan Podila

c.fullName = 'Michel Weststrate';
console.log(c.firstName, c.lastName); // Prints: Michel Weststrate
  • keepAlive:有时候你需要一个计算值始终可用,即使没有跟踪观察者。这个选项保持计算值的“热度”并始终更新。但要注意的是,这个选项会始终缓存计算值,可能会导致内存泄漏和昂贵的计算。具有{ keepAlive: true }的计算属性的对象只有在所有依赖的观察者都被垃圾回收时才能被垃圾回收。因此,请谨慎使用此选项。

  • requiresReaction:这是一个旨在防止昂贵的计算运行频率超出预期的属性。默认值设置为false,这意味着即使没有观察者(也称为反应),它也会在第一次评估。当设置为true时,如果没有观察者,它不会执行计算。相反,它会抛出一个错误,告诉您需要一个观察者。可以通过调用configure({ computedRequiresReaction: Boolean })来更改全局行为。

  • equals:这设置了计算属性的相等检查器。相等检查确定是否需要发出通知以通知所有观察者(也称为反应)。我们知道,只有当新计算值与先前缓存的值不同时,才会发出通知。默认值为comparer.identity,它执行===检查。换句话说,值和引用检查。另一种相等检查是使用comparer.structural,它执行值的深度比较以确定它们是否相等。在概念上,它类似于observable.struct装饰器。这也是computed.struct装饰器使用的比较器:

import { observable, computed, decorate, comparer } from 'mobx';

class Contact {
    @observable firstName = '';
    @observable lastName = '';

  get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }

}

decorate(Contact, {
    fullName: computed({
  set: function(value) {
            const [firstName, lastName] = value.split(' ');

            this.firstName = firstName;
            this.last = lastName;
        },
 equals: comparer.identity,
    }),

});

计算内部的错误处理

计算属性具有在计算过程中捕获错误并从中恢复的特殊能力。它们不会立即退出,而是捕获并保留错误。只有当您尝试从计算属性中读取时,它才会重新抛出错误。这使您有机会通过重置一些状态并返回到一些默认状态来恢复。

以下示例直接来自 MobX 文档,并恰当地演示了错误恢复:

import { observable, computed } from 'mobx';

const x = observable.box(3);
const y = observable.box(1);

const divided = computed(() => {
    if (y.get() === 0) {
        throw new Error('Division by zero');
    }

    return x.get() / y.get();
});

divided.get(); // returns 3   y.set(0); **// OK**   try {
    divided.get(); // Throws: Division by zero
        } catch (ex) {
    // Recover to a safe state
 y.set(2);
}

divided.get(); // Recovered; Returns 1.5 

操作

操作是改变应用程序核心状态的方式。事实上,MobX 强烈建议您始终使用操作,永远不要在操作之外进行任何变化。如果您使用configure配置 MobX 为{ enforceActions: true },它甚至会在整个应用程序中强制执行此要求:

import { configure } from 'mobx';

configure({ enforceActions: true });

让上述代码行成为您的MobX 驱动React 应用程序的起点。显然,对于所有状态变异使用操作有一些好处。但到目前为止,这还不太清楚。让我们深入挖掘一下,揭示这些隐藏的好处。

configure({ enforceActions: true })并不是保护状态变异的唯一选项。还有一种更严格的形式,即{ enforceActions: 'strict' }。差异微妙但值得注意。当设置为true时,仍允许在操作之外进行偶发变异,如果没有观察者跟踪变异的可观察对象。这可能看起来像是 MobX 的一个疏忽。但是,允许这样做是可以的,因为尚未发生任何副作用,因为没有观察者。这不会对 MobX 反应性系统的一致性造成任何伤害。就像古话说的那样,如果树倒在森林里,没有人在附近,它会发出声音吗?也许有点哲学,但要点是:没有观察者,没有人跟踪可观察对象并引起副作用,因此您可以安全地应用变异。

但是,如果您确实想要走纯粹的路线,您可以使用{ enforceActions: 'strict' },即使在没有观察者的情况下也可以进行操作。这真的是个人选择。

为什么要使用操作?

当可观察对象发生变化时,MobX 立即发出通知,通知每个观察者发生了变化。因此,如果您改变了 10 个可观察对象,将发送 10 个通知。有时,这只是过多的。您不希望一个过于急切地通知的嘈杂系统。最好将通知批量处理并一次性发送。这样可以节省 CPU 周期,使您移动设备上的电池更加愉快,并且通常会导致一个平衡、更健康的应用程序。

当您将所有的变化放在action()中时,这正是action()所实现的。它用untracked()和transaction()包装了变异函数,这两个是 MobX 中的特殊用途的低级实用程序。untracked()阻止在变异函数内跟踪可观察对象(也称为创建新的可观察-观察者关系);而transaction()批处理通知,强制在同一可观察对象上的通知,然后在action结束时发送最小的通知集。

有一个核心实用功能被操作使用,即allowStateChanges(true)。这确保状态变化确实发生在可观察对象上,并且它们获得它们的新值。untracked、transaction和allowStateChanges的组合构成了一个动作:

action = untracked(transaction(allowStateChanges(true, ) ) )

这种组合具有以下预期效果:

  • 减少过多的通知

  • 通过批量处理最小的通知来提高效率

  • 通过批量处理最小的通知来最小化在动作中多次改变的可观察对象的副作用执行

事实上,动作可以嵌套在彼此之内,这确保通知只在最外层动作执行完成后才发出。

动作还有助于展现领域的语义,并使您的应用程序变得更具声明性。通过包装可观察对象如何被改变的细节,您为改变状态的操作赋予了一个独特的名称。这强调了您领域的词汇,并将其编码为您的状态管理的一部分。这是对领域驱动设计原则的一种赞同,它将普遍语言(您领域的术语)引入客户端代码。

动作有助于弥合领域词汇和实际代码中使用的名称之间的差距。除了效率上的好处,您还可以获得保持代码更可读的语义上的好处。

我们之前在派生(计算属性)部分看到,您也可以有设置器。这些设置器会被 MobX 自动包装在action()中。计算属性的设置器实际上并不直接改变计算属性。相反,它是改变组成计算属性的依赖可观察对象的逆过程。由于我们正在改变可观察对象,将它们包装在一个动作中是有意义的。MobX 足够聪明,可以为您做到这一点。

异步操作

JavaScript 中的异步编程无处不在,MobX 完全拥抱了这个想法,而没有增加太多的仪式感。这里有一个小片段展示了一些异步代码与 MobX 状态变化交织在一起:

class ShoppingCart {
    @observable asyncState = '';

    @observable.shallow items = [];

    **@action**
  async submit() {
        this.asyncState = 'pending';
        try {
            const response = **await** this.purchaseItems(this.items);

            this.asyncState = 'completed'; // modified outside of 
            action
        } catch (ex) {
            console.error(ex);
            this.asyncState = 'failed'; // modified outside of action
        }
    }

    purchaseItems(items) {
        /* ... */
  return Promise.resolve({});
    }
}

看起来很正常,就像任何其他异步代码一样。这正是重点所在。默认情况下,MobX 简单地让步,让您按预期改变可观察对象。然而,如果您将 MobX 配置为{ enforceActions: 'strict' },您将在控制台上得到一个热烈的红色欢迎:

Unhandled Rejection (Error): [mobx] Since strict-mode is enabled, changing observed observable values outside actions is not allowed. Please wrap the code in an `action` if this change is intended. Tried to modify: **ShoppingCart@14.asyncState**

你可能会问,这里有什么问题?这与我们对async-await操作符的使用有关。您看,跟在await后面的代码不会同步执行。它会在await承诺实现之后执行。现在,action()装饰器只能保护在其块内同步执行的代码。异步运行的代码不被考虑,因此在action()之外运行。因此,跟在await后面的代码不再是action的一部分,导致 MobX 抱怨。

使用 runInAction()进行包装

解决这个问题的方法是使用 MobX 提供的一个实用函数,称为runInAction()。这是一个方便的函数,它接受一个变异函数并在action()内执行它。在下面的代码中,您可以看到使用runInAction()来包装这些变化:

import { action, observable, configure, runInAction } from 'mobx';

configure({ enforceActions: 'strict' });

class ShoppingCart {
    @observable asyncState = '';

    @observable.shallow items = [];

    @action
  async submit() {
        this.asyncState = 'pending';
        try {
            const response = await this.purchaseItems(this.items);

 runInAction(() => {
 this.asyncState = 'completed';
 });
        } catch (ex) {
            console.error(ex);

 runInAction(() => {
 this.asyncState = 'failed';
 });
        }
    }

    purchaseItems(items) {
        /* ... */
  return Promise.resolve({});
    }
}

const cart = new ShoppingCart();

cart.submit();

请注意,我们已经在跟在await后面的代码中应用了runInAction(),无论是在try 块还是在catch 块中。

runInAction(fn)只是一个方便的实用程序,相当于action(fn)()。

虽然async-await提供了一种美观简洁的语法来编写async代码,但要注意那些不是同步的代码部分。action()块中代码的共同位置可能会让人产生误解。在运行时,并非所有语句都是同步执行的。跟在await后面的代码总是以async方式运行,等待的 promise 完成后才会执行。将那些async部分用runInAction()包装起来,可以让我们重新获得action()装饰器的好处。现在,当你配置({ enforceActions: 'strict' })时,MobX 不再抱怨了。

flow()

在之前的简单示例中,我们只需要将代码的两个部分用runInAction()包装起来。这是相当直接的,不需要太多的努力。然而,有些情况下你会在一个函数中有多个await语句。考虑下面展示的login()方法,它执行涉及多个await的操作:

import { observable, action } from 'mobx';

class AuthStore {
    @observable loginState = '';

    @action.bound
  async login(username, password) {
        this.loginState = 'pending';

        **await** this.initializeEnvironment();

        this.loginState = 'initialized';

        **await** this.serverLogin(username, password);

        this.loginState = 'completed';

        **await** this.sendAnalytics();

        this.loginState = 'reported';
    }

    async initializeEnvironment() {}

    async serverLogin(username, password) {}

    async sendAnalytics() {}
}

在每个await后用runInAction()包装状态变化会很快变得繁琐。如果涉及更多条件或者变化分散在多个函数中,甚至可能会忘记包装一些部分。如果有一种方法可以自动将代码的异步部分包装在action()中会怎么样呢?

MobX 也为这种情况提供了解决方案。有一个名为flow()的实用函数,它以生成器函数作为输入。你可以使用yield操作符,而不是await。在概念上,它与async-await类型的代码非常相似,但是使用生成器函数和yield语句来实现相同的效果。让我们使用flow()实用程序重写前面示例中的代码:

import { observable, action, flow, configure } from 'mobx';

configure({ enforceActions: 'strict' });

class AuthStore {
    @observable loginState = '';

    login = flow(function*(username, password) {
        this.loginState = 'pending';

        **yield** this.initializeEnvironment();

        this.loginState = 'initialized';

        **yield** this.serverLogin(username, password);

        this.loginState = 'completed';

        **yield** this.sendAnalytics();

        this.loginState = 'reported';

        **yield** this.delay(3000);
    });

}

new AuthStore().login();

注意使用生成器function*()而不是传递给flow()的常规函数。结构上,它与async-await风格的代码没有什么不同,但它有一个额外的好处,就是自动将yield后面的代码部分用action()包装起来。有了flow(),你可以更加声明式地编写异步代码。

flow()给你另一个好处。它可以取消异步代码的执行。

flow()的返回值是一个函数,你可以调用它来执行异步代码。这是前面示例中AuthStore的login方法。当你调用new AuthStore().login()时,你会得到一个由 MobX 增强的带有cancel()方法的 promise:

const promise = new AuthStore().login2();
promise.cancel(); // prematurely cancel the async code

这对于通过给予用户级别的控制来取消长时间运行的操作非常有用。

反应

可观察对象和操作使事物保持在 MobX 反应系统的范围内。操作改变可观察对象,并通过通知的力量,MobX 系统的其余部分会调整以保持状态一致。要开始在 MobX 系统之外进行更改,您需要反应。它是连接 MobX 世界内部发生的状态变化与外部世界的桥梁。

将反应视为 MobX 和外部世界之间的反应桥梁。这也是您的应用程序的副作用产生者。

我们知道反应有三种类型:autorun,reaction和when。这三种类型具有不同的特征,可以处理应用程序中的各种情况。

确定选择哪一个时,您可以应用这个简单的决策过程:

每个反应都会返回一个清除函数,可以用来提前清除反应,如下所示:

import { autorun, reaction, when } from 'mobx';

const disposer1 = autorun(() => {
    /* effect function */ });

const disposer2 = reaction(
    () => {
        /* tracking function returning data */
  },
    data => {
        /* effect function */
  },
);

const disposer3 = when(
    () => {
        /* predicate function */
  },
    predicate => {
        /* effect function */
  },
);

// Dispose pre-maturely
disposer1();
disposer2();
disposer3();

回到决策树上的前面图表,我们现在可以定义什么是“长时间运行”:反应在第一次执行后不会自动清除。它会继续存在,直到使用“清除函数”明确清除为止。autorun()和reaction()属于长时间运行的反应,而when()是一次性的。请注意,when()也会返回一个“清除函数”,可以提前取消when()的效果。然而,“一次性”的行为意味着在效果执行后,when()将自动清除自身,无需任何清理。

决策树中涵盖的第二个定义特征是关于选择要跟踪的可观察对象。这是执行效果函数的保护条件。reaction()和when()有能力决定要用于跟踪的可观察对象,而autorun()隐式选择其效果函数中的所有可观察对象。在reaction()中,这是跟踪函数,而在when()中,这是谓词函数。这些函数应该产生一个值,当它改变时,效果函数就会被执行。

reaction()和when()的选择器函数是可观察跟踪发生的地方。效果函数仅用于引起没有跟踪的副作用。autorun()隐式地将选择器函数和效果函数合并为一个函数。

使用决策树,您可以对应用程序中的不同副作用进行分类。在第六章中,处理真实用例,我们将看一些示例,这将使选择过程更加自然。

配置 autorun()和 reaction()

autorun()和reaction()都提供了一个额外的参数来自定义行为。让我们看看可以作为选项传递的最常见属性。

autorun()的选项

autorun()的第二个参数是一个携带选项的对象:

autorun(() => { /* side effects */}, options)

它具有以下属性:

  • name:这对于调试目的非常有用,特别是在 MobX DevTools 的上下文中,其中name在日志中打印出来。名称也与 MobX 提供的spy()实用程序函数一起使用。这两者将在以后的章节中介绍。

  • delay:这充当频繁更改的可观察对象的去抖器。效果函数将在delay期间(以毫秒为单位指定)等待重新执行。在接下来的示例中,我们要小心,不要在每次更改profile.couponsUsed时都发出网络请求。使用delay选项是一个简单的防护措施:

import { autorun } from 'mobx';

const profile = observable({
    name: 'Pavan Podila',
    id: 123,
    couponsUsed: 3,
});

function sendCouponTrackingAnalytics(id, couponsUsed) {
    /* Make network request */ }

autorun(
    () => {
        sendCouponTrackingAnalytics(profile.id, profile.couponsUsed);
    },
    { delay: 1000 },
);
  • onError:在效果函数执行期间抛出的错误可以通过提供onError处理程序来安全处理。错误作为输入提供给onError处理程序,然后可以用于恢复,并防止效果函数的后续运行出现异常状态。请注意,通过提供此处理程序,MobX 即使在发生错误后也会继续跟踪。这使系统保持运行,并允许其他已安排的副作用按预期运行,这些副作用可能是不相关的。

在以下示例中,我们有一个onError处理程序,用于处理优惠券数量大于两的情况。通过提供此处理程序,保持autorun()的运行,而不会干扰 MobX 反应性系统的其余部分。我们还删除多余的优惠券,以防止再次发生这种情况:

autorun(
    () => {
        if (profile.couponsUsed > 2) {
            throw new Error('No more than 2 Coupons allowed');
        }
    },
    {
        onError(ex) {
            console.error(ex);
            removeExcessCoupons(profile.id);
        },
    },
);

function removeExcessCoupons(id) {}

reaction()的选项

与autorun()类似,我们可以传递一个额外的参数给reaction(),其中包含选项:

*reaction(() => {/* tracking data */}, (data) => { /* side effects */}, options)*

一些选项如下所示,与autorun完全相同,保持一致:

  • name

  • delay

  • onError

但是,特别针对reaction(),还有其他选项:

  • fireImmediately:这是一个布尔值,指示在跟踪函数第一次调用后是否立即触发效果函数。请注意,这种行为使我们更接近autorun(),它也会立即运行。默认情况下,它被设置为false。

  • equals:请注意,reaction()中的跟踪函数返回的data将与先前产生的值进行比较。对于原始值,默认的相等比较(comparer.default)基于值的比较效果很好。但是,您可以自由提供结构比较器(comparer.structural)来确保执行更深层次的比较。相等检查很重要,因为只有当值(由跟踪函数产生)不同时,效果函数才会被调用。

MobX 何时会做出反应?

MobX 的反应性系统始于可观察对象的跟踪或观察。这是构建反应性图的重要方面,因此跟踪正确的可观察对象至关重要。通过遵循一套简单的规则,您可以保证跟踪过程的结果,并确保您的反应正确触发。

我们将使用术语跟踪函数来指代以下任何一个:

  • 传递给autorun()的函数。该函数中使用的可观察对象将被 MobX 跟踪。

  • reaction()或when()的选择器函数(第一个参数)。其中使用的可观察对象也将被跟踪。

  • observer-React 组件的render()方法。在执行render()方法时使用的可观察对象将被跟踪。

规则

在以下每条规则中,我们将看一个规则在实际中的例子:

  • 在跟踪函数的执行过程中始终解引用可观察对象。解引用是建立 MobX 跟踪器的关键。
const item = observable({
    name: 'Laptop',
    price: 999,
    quantity: 1,
});

autorun(() => {
 showInfo(item);
});

item.price = 1050;

在上面的片段中,由于没有可观察属性被解引用,autorun()不会再次被调用。为了让 MobX 对更改做出反应,需要在跟踪函数内读取可观察属性。一个可能的修复方法是在autorun()内部读取item.price,这样每当item.price发生变化时就会重新触发:

autorun(() => {
    showInfo(item.price);
});
  • 跟踪仅发生在跟踪函数的同步执行代码中:

  • 应该直接在跟踪函数中访问 observable,而不是在异步函数中访问。

  • 在以下代码中,MobX 永远不会对item.quantity的更改做出反应。尽管我们在autorun()中取消引用 observable,但这并不是同步进行的。因此,MobX 永远不会重新执行autorun():

autorun(() => {
 setTimeout(() => {
        if (item.quantity > 10) {
            item.price = 899;
        }
    }, 500);
});

item.quantity = 24;

要修复,我们可以将代码从setTimeout()中取出,并直接放入autorun()中。如果使用setTimeout()是为了添加一些延迟执行,我们可以使用autorun()的delay选项来实现。以下代码显示了修复:

autorun(
    () => {
        if (item.quantity > 10) {
            item.price = 899;
        }
    },
 { delay: 500 },
);
  • 只有已经存在的 observable 才会被跟踪:

  • 在以下示例中,我们正在取消引用一个 observable(一个计算属性),该属性在autorun()执行时并不存在于item上。因此,MobX 从不跟踪它。在代码的后面,我们改变了item.quantity,导致item.description发生变化,但autorun()仍然不会执行:

autorun(() => {
    console.log(`Item Description: ${item.description}`);
});

extendObservable(item, {
    get description() {
        return `Only ${item.quantity} left at $${item.price}`;
    },
});

item.quantity = 10;

一个简单的解决方法是确保在autorun()执行之前 observable 实际存在。通过改变语句的顺序,我们可以得到期望的行为,如下面的代码片段所示。在实践中,您应该预先声明所有需要的属性。这有助于 MobX 在需要时正确跟踪属性,有助于类型检查器(例如 TypeScript)确保正确的属性被使用,并且还清楚地表达了代码读者的意图:

extendObservable(item, {
 get description() {
 return `Only ${item.quantity} left at $${item.price}`;
 },
});

autorun(() => {
    console.log(`Item Description: ${item.description}`);
});

item.quantity = 10;

在修复之前的代码片段中,如果我们在autorun()中也读取了item.quantity,那么这个跟踪函数会在item.quantity发生变化时重新执行。这是因为 observable 属性在autorun()首次执行时存在。第二次autorun()执行(由于item.quantity的变化),item.description也将可用,MobX 也可以开始跟踪它。

  • 前一个规则的一个例外是 Observable Maps,其中还跟踪动态键:
const twitterUrls = observable.map({
    John: 'twitter.com/johnny',
});

autorun(() => {
    console.log(twitterUrls.get('Sara'));
});

twitterUrls.set('Sara', 'twitter.com/horsejs');

在前面的代码片段中,autorun()将重新执行,因为twitterUrls是一个observable.map,它跟踪新键的添加。因此,即使在autorun()执行时它不存在,键Sara仍然被跟踪。

在 MobX 5 中,它可以跟踪使用observable()API 创建的所有对象的尚不存在的属性。

总结

MobX 应用的思维模型是针对思考可观察状态的。这本身分为最小核心状态和派生状态。派生是我们如何处理核心状态在 UI 上的各种投影以及需要执行特定于领域的操作的地方。在添加更多核心状态之前,考虑它是否可以作为派生状态进行整合。只有在这种情况下,您才应该引入新的核心状态。

我们看到,异步操作与常规操作非常相似,没有太多的仪式感。唯一的注意事项是当您配置 MobX 为enforceActions时。在这种情况下,您必须在异步代码中的状态变化中使用runInAction()进行包装。当操作中有几个异步部分时,flow()是一个更好的选择。它采用一个生成器函数(用function*(){ }表示),其中插入了对各种基于 promise的调用的yield。

reaction()和autorun()提供了额外的选项来控制它们的行为。它们共享大多数选项,例如名称、延迟和onError。reaction()还有两个选项:控制如何对跟踪函数产生的数据进行比较(equals),以及在跟踪函数的第一次运行后是否立即触发效果函数(fireImmediately)。

在第六章中,处理真实用例,我们可以开始探索使用 MobX 解决各种常见情况的方法。如果到目前为止的章节看起来像是科学,那么下一章就是应用科学!

第六章:处理真实用例

当您开始使用 MobX 时,应用 MobX 的原则可能会看起来令人生畏。为了帮助您完成这个过程,我们将解决两个非平凡的使用 MobX 三要素可观察-操作-反应的示例。我们将涵盖可观察状态的建模,然后确定跟踪可观察对象的操作和反应。一旦您完成这些示例,您应该能够在使用 MobX 处理状态管理时进行心智转变。

本章我们将涵盖以下示例:

  • 表单验证

  • 页面路由

技术要求

您需要具备 JavaScript 编程语言。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter06

查看以下视频,以查看代码的运行情况:

bit.ly/2LDliA9

表单验证

填写表单和验证字段是 Web 的经典用例。因此,我们从这里开始,并看看 MobX 如何帮助我们简化它。在我们的示例中,我们将考虑一个用户注册表单,其中包含一些标准输入,如名字、姓氏、电子邮件和密码。

注册的各种状态如下图所示:

交互

从前面的屏幕截图中,我们可以看到一些标准的交互,例如:

  • 输入各种字段的输入

  • 对这些字段进行验证

  • 单击“注册”按钮执行网络操作

这里还有一些其他交互,不会立即引起注意:

  • 基于网络的电子邮件验证,以确保我们不会注册已存在的电子邮件地址

  • 显示注册操作的进度指示器

许多这些交互将使用 MobX 中的操作和反应进行建模。状态当然将使用可观察对象进行建模。让我们看看在这个示例中 Observables-Actions-Reactions三要素是如何生动起来的。

建模可观察状态

示例的视觉设计已经暗示了我们需要的核心状态。这包括firstName、lastName、email和password字段。我们可以将这些字段建模为UserEnrollmentData类的可观察属性。

此外,我们还需要跟踪将发生在电子邮件上的异步验证。我们使用布尔值validating属性来实现这一点。在验证过程中发现的任何错误都将使用errors进行跟踪。最后,enrollmentStatus跟踪了围绕注册的网络操作。它是一个字符串枚举,可以有四个值之一:none、pending、completed或failed。

class UserEnrollmentData {
    @observable email = '';
    @observable password = '';
    @observable firstName = '';
    @observable lastName = '';
    @observable validating = false;
    @observable.ref errors = null;
    @observable enrollmentStatus = 'none'; // none | pending | completed | failed
}

您会注意到errors标记为@observable.ref,因为它只需要跟踪引用更改。这是因为验证输出是一个不透明对象,除了引用更改之外没有任何可观察的东西。只有当errors有一个值时,我们才知道有验证错误。

进入操作

这里的操作非常简单。我们需要一个操作来根据用户更改设置字段值。另一个是在单击 Enroll 按钮时进行注册。这两个可以在以下代码中看到。

作为一般惯例,始终从调用configure({ enforceActions: 'strict' })开始。这确保您的可观察对象只在操作内部发生突变,为您提供了我们在第五章中讨论的所有好处,派生、操作和反应:

import { action, configure, flow } from 'mobx';

**configure({ enforceActions: 'strict' });**

class UserEnrollmentData {
    /* ... */

    @action
  setField(field, value) {
        this[field] = value;
    }

    getFields() {
        const { firstName, lastName, password, email } = this;
        return { firstName, lastName, password, email }
    }

    enroll = flow(function*() {
        this.enrollmentStatus = 'pending';
        try {
            // Validation
            const fields = this.getFields();
 yield this.validateFields(fields);
            if (this.errors) {
                throw new Error('Invalid fields');
            }

            // Enrollment
 yield enrollUser(fields);

            this.enrollmentStatus = 'completed';
        } catch (e) {
            this.enrollmentStatus = 'failed';
        }
    });

}

对于enroll操作使用flow()是故意的。我们在内部处理异步操作,因此在操作完成后发生的突变必须包装在runInAction()或action()中。手动执行这个操作可能很麻烦,也会给代码增加噪音。

使用flow(),您可以通过使用带有yield语句的生成器函数来获得清晰的代码,用于promises。在前面的代码中,我们有两个yield点,一个用于validateFields(),另一个用于enroll(),两者都返回promises。请注意,在这些语句之后我们没有包装代码,这样更容易遵循逻辑。

这里隐含的另一个操作是validateFields()。验证实际上是一个副作用,每当字段更改时都会触发,但也可以直接作为一个操作调用。在这里,我们再次使用flow()来处理异步验证后的突变:

我们使用validate.js (validatejs.org) NPM 包来处理字段验证。

**import Validate from 'validate.js';**

class UserEnrollmentData {

    /* ... */

    validateFields = flow(function*(fields) {
        this.validating = true;
        this.errors = null;

        try {
 yield Validate.async(fields, rules);

            this.errors = null;
        } catch (err) {
            this.errors = err;
        } finally {
            this.validating = false;
        }
    });

    /* ... */
}

注意flow()如何像常规函数一样接受参数(例如:fields)。由于电子邮件的验证涉及异步操作,我们将整个验证作为异步操作进行跟踪。我们使用validating属性来实现这一点。当操作完成时,我们在finally块中将其设置回false。

用反应完成三角形

当字段发生变化时,我们需要确保输入的值是有效的。因此,验证是输入各个字段的值的副作用。我们知道 MobX 提供了三种处理这种副作用的方法,它们是autorun()、reaction()和when()。由于验证是应该在每次字段更改时执行的效果,一次性效果的when()可以被排除。这让我们只剩下了reaction()和autorun()。通常,表单只会在字段实际更改时进行验证。这意味着效果只需要在更改后触发。

这将我们的选择缩小到reaction(<tracking-function>, <effect-function>),因为这是唯一一种确保effect函数在tracking函数返回不同值后触发的反应类型。另一方面,autorun()立即执行,这对于执行验证来说太早了。有了这个,我们现在可以在UserEnrollmentData类中引入验证副作用:

从技术上讲,这也可以通过autorun()实现,但需要一个额外的布尔标志来确保第一次不执行验证。任何一种解决方案在这种情况下都可以很好地工作。

class UserEnrollmentData {

    disposeValidation = null;

    constructor() {
        this.setupValidation();
    }

    setupValidation() {
        this.disposeValidation = reaction(
            () => {
                const { firstName, lastName, password, email } = this;
                return { firstName, lastName, password, email };
            },
            () => {
 this.validateFields(this.getFields());
            },
        );
    }

    /* ... */

 **cleanup**() {
        this.disposeValidation();
    }
}

在前述reaction()中的tracking函数选择要监视的字段。当它们中的任何一个发生变化时,tracking函数会产生一个新值,然后触发验证。我们已经看到了validateFields()方法,它也是使用flow()的动作。reaction()设置在UserEnrollmentData的构造函数中,因此监视立即开始。

当调用this.validateFields()时,它会返回一个promise,可以使用其cancel()方法提前取消。如果validateFields()被频繁调用,先前调用的方法可能仍在进行中。在这种情况下,我们可以cancel()先前返回的 promise 以避免不必要的工作。

我们将把这个有趣的用例留给读者来解决。

我们还跟踪reaction()返回的disposer函数,我们在cleanup()中调用它。这是为了清理和避免潜在的内存泄漏,当不再需要UserEnrollmentData时。在反应中始终有一个退出点并调用其disposer总是很好的。在我们的情况下,我们从根 React 组件中调用cleanup(),在其componentWillUnmount()挂钩中。我们将在下一节中看到这一点。

现在,验证不是我们示例的唯一副作用。更宏伟的副作用是 React 组件的 UI。

React 组件

我们知道的 UI 在 MobX 中是一个副作用,并且通过在 React 组件上使用observer()装饰器来识别。这些观察者可以在render()方法中读取可观察对象,从而设置跟踪。每当这些可观察对象发生变化时,MobX 将重新渲染组件。这种自动行为与最小的仪式非常强大,使我们能够创建对细粒度可观察状态做出反应的细粒度组件。

在我们的示例中,我们确实有一些细粒度的观察者组件,即输入字段、注册按钮和应用程序组件。它们在以下组件树中用橙色框标记:

每个字段输入都分离成一个观察者组件:InputField。电子邮件字段有自己的组件EmailInputField,因为它的视觉反馈还涉及在验证期间显示进度条并在检查输入的电子邮件是否已注册时禁用它。同样,EnrollButton也有一个旋转器来显示注册操作期间的进度。

我们正在使用Material-UI(material-ui.com)作为组件库。这提供了一组优秀的 React 组件,按照 Google 的 Material Design 指南进行了样式设置。

InputField只观察它正在渲染的字段,由field属性标识,该属性是从store属性(使用store[field])解除引用的。这作为InputField的value:

const InputField = observer(({ store, field, label, type }) => {
    const errors = store.errors && store.errors[field];
    const hasError = !!errors;

    return (
        <TextField
  fullWidth
 type={type}  value={store[field]}  label={label}   error={hasError}  onChange={event => store.setField(field, 
            event.target.value)}  margin={'normal'}   helperText={errors ? errors[0] : null}  />
    );
});

用户对此输入进行的编辑(onChange事件)将通过store.setField()操作通知回存储。InputField在 React 术语中是一个受控组件。

InputField组件的关键思想是传递可观察对象(store)而不是值(store[field])。这确保了可观察属性的解引用发生在组件的render()内部。这对于一个专门用于渲染和跟踪所需内容的细粒度观察者来说非常重要。在创建 MobX 观察者组件时,您可以将其视为设计模式。

UserEnrollmentForm 组件

我们在UserEnrollmentForm组件中使用了几个这些InputFields。请注意,UserEnrollmentForm组件不是观察者。它的目的是通过inject()装饰器获取存储并将其传递给一些子观察者组件。这里的inject()使用了基于函数的参数,比inject('store')的基于字符串的参数更安全。

import React from 'react';
import { inject  } from 'mobx-react';
import { Grid, TextField, Typography, } from '@material-ui/core';

@inject(stores => ({ store: stores.store }))
class UserEnrollmentForm extends React.Component {
    render() {
        const { store } = this.props;
        return (
            <form>
                <Grid container direction={'column'}>
                    <CenteredGridItem>
                        <Typography variant={'title'}>Enroll 
                        User</Typography>
                    </CenteredGridItem>

                    <CenteredGridItem>
                        <EmailInputField store={store} />
                    </CenteredGridItem>

                    <CenteredGridItem>
                        <**InputField**
  type={'password'}   field={'password'}   label={'Password'}   store={store}  />
                    </CenteredGridItem>

                    <CenteredGridItem>
                        <**InputField**
  type={'text'}   field={'firstName'}   label={'First Name'}   store={store}  />
                    </CenteredGridItem>

                    <CenteredGridItem>
                        <**InputField**
  type={'text'}   field={'lastName'}   label={'Last Name'}   store={store}  />
                    </CenteredGridItem>

                    <CenteredGridItem>
                        <EnrollButton store={store} />
                    </CenteredGridItem>
                </Grid>
            </form>
        );
    }
}

store,即UserEnrollmentData的一个实例,通过在组件树的根部设置的Provider组件传递下来。这是在根组件的constructor中创建的。

import React from 'react';
import { UserEnrollmentData } from './store';
import { Provider } from 'mobx-react';
import { App } from './components';

export class FormValidationExample extends React.Component {
    constructor(props) {
        super(props);

 this.store = new UserEnrollmentData();
    }

    render() {
        return (
 <Provider store={this.store}>
                <App />
            </Provider>
        );
    }

 componentWillUnmount() {
 this.store.cleanup();
 this.store = null;
 }
}

通过Provider,任何组件现在都可以inject() store并访问可观察状态。请注意使用componentWillUnmount()钩子来调用this.store.cleanup()。这在内部处理了验证反应,如前一部分所述(“使用反应完成三角形”)。

其他观察者组件

在我们的组件树中还有一些更细粒度的观察者。其中最简单的之一是App组件,它提供了一个简单的分支逻辑。如果我们仍在注册过程中,将显示UserEnrollmentForm。注册后,App将显示EnrollmentComplete组件。这里跟踪的可观察对象是store.enrollmentStatus:

@inject('store')
@observer export class App extends React.Component {
    render() {
        const { store } = this.props;
 return store.enrollmentStatus === 'completed' ? (
 <EnrollmentComplete />
 ) : (
 <UserEnrollmentForm />
 );
    }
}

EmailInputField相当不言自明,并重用了InputField组件。它还包括一个进度条来显示异步验证操作:

const EmailInputField = observer(({ store }) => {
    const { validating } = store;

    return (
        <Fragment>
            <InputField
  type={'text'}   store={store}   field={'email'}   label={'Email'}  />
            {validating ? <LinearProgress variant={'query'} /> : null}
        </Fragment>
    );
});

最后,最后一个观察者组件是EnrollButton,它观察enrollmentStatus并在store上触发enroll()动作。在注册过程中,它还显示圆形旋转器:

const EnrollButton = observer(({ store }) => {
 const isEnrolling = store.enrollmentStatus === 'pending';
 const failed = store.enrollmentStatus === 'failed';

    return (
        <Fragment>
            <Button
  variant={'raised'}   color={'primary'}   style={{ marginTop: 20 }}   disabled={isEnrolling}   onClick={() => store.enroll()}  >
                Enroll
                {isEnrolling ? (
                    <CircularProgress
  style={{
                            color: 'white',
                            marginLeft: 10,
                        }}   size={20}   variant={'indeterminate'}  />
                ) : null}
            </Button>
            {failed ? (
                <Typography color={'secondary'}  variant={'subheading'}>
                    Failed to enroll
                </Typography>
            ) : null}{' '}
        </Fragment>
    );
});

这些细粒度观察者的集合通过加速 React 的协调过程来提高 UI 的效率。由于更改局限于特定组件,React 只需协调该特定观察者组件的虚拟 DOM 更改。MobX 鼓励在组件树中使用这样的细粒度观察者并将它们分散其中。

如果您正在寻找一个专门用于使用 MobX 进行表单验证的库,请查看mobx-react-form(github.com/foxhound87/mobx-react-form)。

页面路由

单页应用程序(SPA)已经成为我们今天看到的许多 Web 应用程序中的常见现象。这些应用程序的特点是在单个页面内使用逻辑的客户端路由。您可以通过修改 URL 而无需完整加载页面来导航到应用程序的各个部分(路由)。这是由诸如react-router-dom之类的库处理的,它与浏览器历史记录一起工作,以实现URL驱动的路由更改。

在 MobX 世界中,路由更改或导航可以被视为副作用。可观察对象发生了一些状态变化,导致 SPA 中的导航发生。在这个例子中,我们将构建这个可观察状态,它跟踪浏览器中显示的当前页面。使用react-router-dom和history包的组合,我们将展示如何路由成为可观察状态变化的副作用。

购物车结账工作流

让我们看一个用例,我们可以看到路由更改(导航)作为 MobX 驱动的副作用。我们将使用典型的购物车结账工作流作为示例。如下截图所示,我们从主页路由开始,这是工作流的入口点。从那里,我们经历剩下的步骤:查看购物车,选择付款选项,查看确认,然后跟踪订单:

我们故意保持各个步骤在视觉上简单。这样我们可以更多地关注导航方面,而不是每个步骤内部发生的细节。然而,工作流的这些步骤中有一些共同的元素。

如下截图所示,每个步骤都有一个加载操作,用于获取该步骤的详细信息。加载完成后,您可以单击按钮转到下一步。在导航发生之前,会执行一个异步操作。完成后,我们将导航到工作流程的下一步。由于每个步骤都遵循这个模板,我们将在下一节中对其进行建模:

建模可观察状态

这个 SPA 的本质是逐步进行结账工作流程,其中每个步骤都是一个路由。由于路由由 URL 驱动,我们需要一种监视 URL 并在步骤之间移动时有能力更改它的方法。步骤之间的导航是可观察状态的某种变化的副作用。我们将使用包含核心可观察状态的CheckoutWorkflow类来对这个工作流程进行建模:

const routes = {
    shopping: '/',
    cart: '/cart',
    payment: '/payment',
    confirm: '/confirm',
    track: '/track',
};

export class CheckoutWorkflow {
    static steps = [
        { name: 'shopping', stepClass: ShoppingStep },
        { name: 'cart', stepClass: ShowCartStep },
        { name: 'payment', stepClass: PaymentStep },
        { name: 'confirm', stepClass: ConfirmStep },
        { name: 'track', stepClass: TrackStep },
    ];

 tracker = new HistoryTracker();
    nextStepPromise = null;

 @observable currentStep = null;
 @observable.ref step = null;

}

如前面的代码所示,我们用name和stepClass表示每个步骤。name也是我们用来识别该步骤对应路由的方式,存储在单例routes对象中。steps的有序列表存储为CheckoutWorkflow类的静态属性。我们也可以从单独的 JavaScript 文件(模块)中加载这些步骤,但为简单起见,我们将其保留在这里。

核心的可观察状态在这里非常简单:一个存储当前步骤的字符串名称的currentStep属性和一个step属性,作为observable.ref属性存储的stepClass的实例。当我们在步骤之间导航时,这两个属性会发生变化以反映当前步骤。我们将看到这些属性在处理路由更改时的使用方式。

一条路线对应一步,一步对应一条路线

你可能会想为什么我们需要两个单独的属性来跟踪当前步骤。是的,这似乎多余,但有原因。由于我们的工作流将是一组 url 路由,路由的变化也可以通过浏览器的返回按钮或直接输入 URL 来发生。将路由与步骤相关联的一种方法是使用其名称,这正是我们在currentStep属性中所做的。请注意,步骤的name与routes对象的键完全匹配。

当路由在外部发生变化时,我们依赖浏览器历史记录来通知我们 URL 的变化。tracker属性是HistoryTracker的一个实例(我们将创建一个自定义类),其中包含监听浏览器历史记录并跟踪浏览器中当前 URL 的逻辑。它公开了一个被CheckoutWorkflow跟踪的 observable 属性。我们稍后将在本章中查看它的实现:

CheckoutWorkflow中的每个步骤都是WorkflowStep类的子类型。WorkflowStep捕获了步骤及其异步操作的详细信息。工作流简单地编排步骤的流程,并在每个步骤的异步操作完成后在它们之间进行转换:

class ShowCartStep extends WorkflowStep { /* ... */}

// A mock step to simplify the representation of other steps
class MockWorkflowStep extends WorkflowStep { /* ... */ }

class PaymentStep extends MockWorkflowStep { /* ... */ }
class ConfirmStep extends MockWorkflowStep { /* ... */ }
class TrackStep extends MockWorkflowStep { /* ... */ }

对于大多数步骤,我们正在扩展MockWorkflowStep,它使用一些内置的默认值来创建一个模板WorkflowStep。这使得步骤非常简单,因此我们可以专注于步骤之间的路由。请注意下面的代码片段,我们只是模拟了load和main操作的网络延迟。delay()函数只是一个简单的帮助函数,返回一个在给定毫秒间隔后解析的Promise。

我们将在下一节中看到getLoadOperation()和getMainOperation()方法是如何使用的:

class MockWorkflowStep extends WorkflowStep {
    getLoadOperation() {
        return delay(1000);
    }

    getMainOperation() {
        return delay(1000);
    }
}

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

WorkflowStep

WorkflowStep充当了工作流中所有步骤的模板。它包含一些 observable 状态,用于跟踪它执行的两个异步操作:加载详情和执行主要工作。

class WorkflowStep {
    workflow = null; // the parent workflow
 @observable loadState = 'none'; // pending | completed | failed
  @observable operationState = 'none'; // pending | completed | 
     failed    async getLoadOperation() {}
    async getMainOperation() {}

    @action.bound
 async load() {
        doAsync(
            () => this.getLoadOperation(),
            state => (this.loadState = state),
        );
    }

    @action.bound
 async perform() {
        doAsync(
            () => this.getMainOperation(),
            state => (this.operationState = state),
        );
    }
}

load()和perform()是WorkflowStep执行的两个异步操作。它们的状态分别通过loadState和operationState observables 进行跟踪。这两个操作中的每一个都调用一个委托方法,子类重写该方法以提供实际的 promise。load()调用getLoadOperation(),perform()调用getMainOperation(),每个方法都会产生一个 promise。

doAsync()是一个帮助函数,它接受一个promise 函数并使用传入的回调(setState)通知状态。请注意这里使用runInAction()来确保所有变化发生在一个 action 内部。

load()和perform()使用doAsync()函数适当地更新loadState和operationState observables:

有一种不同的编写doAsync()函数的方法。提示:我们在早期的章节中已经看到过。我们将把这留给读者作为一个练习。

async function doAsync(getPromise, setState) {
 setState('pending');
    try {
        await getPromise();
        runInAction(() => {
 setState('completed');
        });
    } catch (e) {
        runInAction(() => {
 setState('failed');
        });
    }
}

现在我们可以看到可观察状态由CheckoutWorkflow和WorkflowStep实例承载。可能不清楚的一点是CheckoutWorkflow如何执行协调。为此,我们必须看一下动作和反应。

工作流的动作和反应

我们已经看到WorkflowStep有两个action方法,load()和perform(),处理步骤的异步操作:

class WorkflowStep {
    workflow = null;
    @observable loadState = 'none'; // pending | completed | failed
  @observable operationState = 'none'; // pending | completed | 
     failed    async getLoadOperation() {}
    async getMainOperation() {}

    @action.bound
 async load() {
        doAsync(
            () => this.getLoadOperation(),
            state => (this.loadState = state),
        );
    }

    @action.bound
 async perform() {
        doAsync(
            () => this.getMainOperation(),
            state => (this.operationState = state),
        );
    }
}

load()操作由CheckoutWorkflow调用,因为它加载工作流的每个步骤。perform()是用户调用的操作,当用户点击暴露在 React 组件上的按钮时发生。一旦perform()完成,operationState将变为completed。CheckoutWorkflow跟踪这一点,并自动加载序列中的下一个步骤。换句话说,工作流作为对当前步骤的operationState变化的反应(或副作用)而进展。让我们在以下一组代码片段中看到所有这些:

export class CheckoutWorkflow {
    /* ... */

    tracker = new HistoryTracker();
    nextStepPromise = null;

    @observable currentStep = null;
    @observable.ref step = null;

    constructor() {
        this.tracker.startListening(routes);

 this.currentStep = this.tracker.page;

 autorun(() => {
            const currentStep = this.currentStep;

            const stepIndex = CheckoutWorkflow.steps.findIndex(
                x => x.name === currentStep,
            );

            if (stepIndex !== -1) {
                this.loadStep(stepIndex);

                this.tracker.page = CheckoutWorkflow.steps[stepIndex].name;
            }
        });

 reaction(
            () => this.tracker.page,
            page => {
                this.currentStep = page;
            },
        );
    }

    @action
  async loadStep(stepIndex) {
        /* ... */
    }
}

CheckoutWorkflow的构造函数设置了核心副作用。我们需要知道的第一件事是浏览器使用this.tracker.page提供的当前页面。请记住,我们正在将工作流的currentStep与使用共享名称的基于 URL 的路由相关联。

第一个副作用使用autorun()执行,我们知道它立即运行,然后在跟踪的可观察对象发生变化时运行。在autorun()内部,我们首先确保加载currentStep是有效的步骤。由于我们在autorun()内部观察currentStep,我们必须确保我们保持this.tracker.page同步。成功加载当前步骤后,我们这样做。现在,每当currentStep发生变化时,tracker.page会自动同步,这意味着 URL 和路由会更新以反映当前步骤。稍后我们将看到,tracker,即HistoryTracker的实例,实际上是如何在内部处理这一点的。

下一个副作用是对tracker.page的变化的reaction()。这是对先前副作用的对应部分。每当tracker.page发生变化时,我们也必须改变currentStep。毕竟,这两个可观察对象必须协同工作。因为我们已经通过一个单独的副作用(autorun())来跟踪currentStep,当前的step加载了WorkflowStep的实例。

这里引人注目的一点是,当currentStep改变时,tracker.page会更新。同样,当tracker.page改变时,currentStep也会更新。因此,可能会出现一个无限循环:

然而,MobX 会发现一旦变化在一个方向上传播,另一方向就不会发生更新,因为两者是同步的。这意味着这两个相互依赖的值很快就会达到稳定状态,不会出现无限循环。

加载步骤

WorkflowStep是步骤变得活跃的地方,唯一能创建实例的是CheckoutWorkflow。毕竟,它是整个工作流的所有者。它在loadStep()动作方法中执行此操作:

export class CheckoutWorkflow {
    /* ... */

    @action
  async loadStep(stepIndex) {
        if (this.nextStepPromise) {
            this.nextStepPromise.cancel();
        }

        const StepClass = CheckoutWorkflow.steps[stepIndex].stepClass;
        this.step = new StepClass();
        this.step.workflow = this;
        this.step.load();
        this.nextStepPromise = when(
            () => this.step.operationState === 'completed',
        );

        await this.nextStepPromise;

        const nextStepIndex = stepIndex + 1;
        if (nextStepIndex >= CheckoutWorkflow.steps.length) {
            return;
        }

        this.currentStep = CheckoutWorkflow.steps[nextStepIndex].name;
    }
}

上述代码的有趣部分概述如下:

  • 我们通过从步骤列表中检索当前步骤索引的stepClass来获得当前步骤索引的stepClass。我们创建了这个stepClass的实例,并将其分配给可观察的step属性。

  • 然后触发WorkflowStep的load()。

  • 可能最有趣的部分是等待step的operationState改变。我们从前面知道,operationState跟踪步骤的主要异步操作的状态。一旦它变为completed,我们就知道是时候转到下一步了。

  • 注意使用带有 promise 的when()。这为我们提供了一个很好的方法来标记需要在when()解析后执行的代码。还要注意,我们在nextStepPromise属性中跟踪 promise。这是为了确保在当前步骤完成之前,我们也要cancel掉 promise。值得思考这种情况可能会出现的时候。提示:步骤的流程并不总是线性的。步骤也可以通过路由更改来更改,比如通过单击浏览器的返回按钮!

历史跟踪器

observable state puzzle的最后一部分是HistoryTracker,这是一个专门用于监视浏览器 URL 和历史记录的类。它依赖于history NPM 包(github.com/ReactTraining/history)来完成大部分工作。history包还为我们的 React 组件提供动力,我们将使用react-router-dom库。

HistoryTracker的核心责任是公开一个名为page的 observable,用于跟踪浏览器中的当前 URL(路由)。它还会反向操作,使 URL 与当前page保持同步:

import createHashHistory from 'history/createHashHistory';
import { observable, action, reaction } from 'mobx';

export class HistoryTracker {
    unsubscribe = null;
    history = createHashHistory();

    @observable page = null;

    constructor() {
        reaction(
            () => this.page,
            page => {
                const route = this.routes[page];
                if (route) {
                    this.history.push(route);
                }
            },
        );
    }

    /* ... */
}

在构造函数中设置了reaction(),路由更改(URL 更改)实际上是page observable 变化的副作用。这是通过将路由(URL)推送到浏览器历史记录中实现的。

HistoryTracker的另一个重要方面,正如其名称所示,是跟踪浏览器历史记录。这是通过startListening()方法完成的,可以由此类的消费者调用。CheckoutWorkflow在其构造函数中调用此方法来设置跟踪器。请注意,startListening()接收一个路由映射,其中key指向 URL 路径:

export class HistoryTracker {
    unsubscribe = null;
    history = createHashHistory();

    @observable page = null;

    startListening(routes) {
        this.routes = routes;
        this.unsubscribe = this.history.listen(location => {
            this.identifyRoute(location);
        });

        this.identifyRoute(this.history.location);
    }

    stopListening() {
        this.unsubscribe && this.unsubscribe();
    }

    @action
  setPage(key) {
        if (!this.routes[key]) {
            throw new Error(`Invalid Page: ${key}`);
        }

        this.page = key;
    }

    @action
  identifyRoute(location) {
        const { pathname } = location;
        const routes = this.routes;

        this.page = Object.keys(routes).find(key => {
            const path = routes[key];
            return path.startsWith(pathname);
        });
    }
}

当浏览器中的 URL 更改时,page observable 会相应地更新。这发生在identifyRoute()方法中,该方法从history.listen()的回调中调用。我们已经用 action 修饰它,因为它会改变page observable。在内部,MobX 会通知所有page的观察者,例如CheckoutWorkflow,它使用page observable 来更新其currentStep。这保持了整个路由同步,并确保更改是双向的。

以下图表显示了currentStep、page和url-route之间的双向同步。请注意,与history包的交互显示为灰色箭头,而 observable 之间的依赖关系显示为橙色箭头。这种颜色上的差异是有意的,并表明基于 url 的路由实际上是 observable 状态变化的副作用:

React 组件

在这个例子中,observable 状态的建模比 React UI 组件更有趣。在 React 方面,我们有设置Provider的顶层组件,其中store是CheckoutWorkflow的实例。Provider来自mobx-react包,并帮助将store注入到任何使用inject()装饰的 React 组件中:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'mobx-react';
import { CheckoutWorkflow } from './CheckoutWorkflow';

const workflow = new CheckoutWorkflow();

export function PageRoutingExample() {
    return (
        <Provider store={workflow}>
            <App />
        </Provider>
    );
}

App组件只是使用react-router-dom包设置所有路由。在<Route />组件中使用的路径与我们在routes对象中看到的 URL 匹配。请注意,HistoryTracker中的history用于Router。这允许在react-router和mobx之间共享浏览器历史记录:

import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Router, Switch } from 'react-router-dom';
import { CheckoutWorkflow } from './CheckoutWorkflow';
import { Paper } from '@material-ui/core/es/index';
import { ShowCart } from './show-cart';
import {
    ConfirmDescription,
    PaymentDescription,
    ShoppingDescription,
    TemplateStepComponent,
    TrackOrderDescription,
} from './shared';

const workflow = new CheckoutWorkflow();

class App extends React.Component {
    render() {
        return (
            <Paper elevation={2}  style={{ padding: 20 }}>
                <Router history={workflow.tracker.history}>
                    <Switch>
                        <**Route**
  exact
 path={'/'}   component={() => (
                                <TemplateStepComponent
  title={'MobX Shop'}   renderDescription=
                                   {ShoppingDescription}   operationTitle={'View Cart'}  />
                            )}  />
                        <Route exact path={'/cart'}  component=
                            {ShowCart} />
                        <**Route**
  exact
 path={'/payment'}   component={() => (
                                <TemplateStepComponent
  title={'Choose Payment'}   renderDescription=
                                    {PaymentDescription}   operationTitle={'Confirm'}  />
                            )}  />
                        <**Route**
  exact
 path={'/confirm'}   component={() => (
                                <TemplateStepComponent
  title={'Your order is confirmed'}   operationTitle={'Track Order'}   renderDescription=
                                     {ConfirmDescription}  />
                            )}  />
                        <**Route**
  exact
 path={'/track'}   component={() => (
                                <TemplateStepComponent
  title={'Track your order'}   operationTitle={'Continue 
                                      Shopping'}   renderDescription=
                                     {TrackOrderDescription}  />
                            )}  />
                    </Switch>
                </Router>
            </Paper>
        );
    }
}

如前所述,我们故意保持了工作流程的各个步骤非常简单。它们都遵循固定的模板,由WorkflowStep描述。它的 React 对应物是TemplateStepComponent,它呈现步骤并公开按钮,用于导航到下一步。

TemplateStepComponent

TemplateStepComponent为WorkflowStep提供了可视化表示。当步骤正在加载时,它会呈现反馈,当主要操作正在执行时也是如此。此外,它会在加载后显示步骤的详细信息。这些细节通过renderDetails属性显示,该属性接受一个 React 组件:

@inject('store')
export class TemplateStepComponent extends React.Component {
    static defaultProps = {
        title: 'Step Title',
        operationTitle: 'Operation',
        renderDetails: step => 'Some Description', // A render-prop to render details of a step
    };

    render() {
        const { title, operationTitle, renderDetails } = this.props;

        return (
            <Fragment>
                <Typography
  variant={'headline'}   style={{ textAlign: 'center' }}  >
                    {title}
                </Typography>

 <Observer>
 {() => {
 const { step } = this.props.store;

 return (
 <OperationStatus
  state={step.loadState}   render={() => (
 <div style={{ padding: '2rem 0' }}>
 {renderDetails(step)}
 </div>
 )}  />
 );
 }}
 </Observer>

                <Grid justify={'center'}  container>
 <Observer>
                        {() => {
                            const { step } = this.props.store;

                            return (
                                <Button
  variant={'raised'}   color={'primary'}   disabled={step.operationState === 
 'pending'}   onClick={step.perform}>
                                    {operationTitle}
                                    {step.operationState === 'pending'                           
                                         ? (
                                        <CircularProgress
  variant={'indeterminate'}   size={20}   style={{
                                                color: 'black',
                                                marginLeft: 10,
                                            }}  />
                                    ) : null}
                                </Button>
                            );
                        }}
 </Observer>
                </Grid>
            </Fragment>
        );
    }
}

Observer组件是我们以前没有见过的东西。这是由mobx-react包提供的一个特殊组件,简化了粒度观察者的创建。典型的 MobX 观察者组件将要求您创建一个单独的组件,用observer()和/或inject()装饰它,并确保适当的可观察对象作为 props 传递到该组件中。您可以通过简单地用<Observer />包装虚拟 DOM的一部分来绕过所有这些仪式。

它接受一个函数作为它唯一的子元素,在其中您可以从周围范围读取可观察对象。MobX 将自动跟踪函数作为子组件中使用的可观察对象。仔细观察Observer会揭示这些细节:

<Observer>
    {() => {
 const { step } = this.props.store;

        return (
            <OperationStatus
  state={step.loadState}   render={() => (
                    <div style={{ padding: '2rem 0' }}>
                        {renderDetails(step)}
                    </div>
                )}  />
        );
    }}
</Observer>

在上面的片段中,我们将一个函数作为<Observer />的子元素传递。在该函数中,我们使用step.loadState可观察对象。当step.loadState发生变化时,MobX 会自动呈现函数作为子组件。请注意,我们没有将任何 props 传递给Observer或子组件。它直接从外部组件的 props 中读取。这是使用Observer的优势。您可以轻松创建匿名观察者。

一个微妙的要点是TemplateStepComponent本身不是一个观察者。它只是用inject()获取store,然后在<Observer />区域内使用它。

ShowCart 组件

ShowCart是显示购物车中物品列表的组件。在这里,我们正在重用TemplateStepComponent和购物车的插件细节,使用renderDetails属性。这可以在以下代码中看到。为简单起见,我们不显示CartItem和TotalItem组件。它们是纯粹的呈现组件,用于呈现单个购物车项目:

import React from 'react';
import {
    List,
    ListItem,
    ListItemIcon,
    ListItemText,
    Typography,
} from '@material-ui/core';
import { Divider } from '@material-ui/core/es/index';
import { TemplateStepComponent } from './shared';

export class ShowCart extends React.Component {
    render() {
        return (
            <**TemplateStepComponent**
  title={'Your Cart'}   operationTitle={'Checkout'}  renderDetails={step => {
 const { items, itemTotal } = step;

 return (
 <List>
 {items.map(item => (
 <CartItem key={item.title}  item={item} />
 ))}

 <Divider />

 <TotalItem total={itemTotal} />
 </List>
 );
 }} />
        );
    }
}

function CartItem({ item }) {
    return (
        /* ... */
    );
}

function TotalItem({ total }) {
    return (
        /* ... */
    );
}

基于状态的路由器

现在您可以看到,所有WorkflowStep实例之间的路由纯粹是通过基于状态的方法实现的。所有导航逻辑都在 MobX 存储中,这种情况下是CheckoutWorkflow。通过连接可观察对象(tracker.page,currentStep和step)通过一系列反应,我们创建了更新浏览器历史的副作用,并创建了WorkflowStep的实例,这些实例由TemplateStepComponent使用。

由于我们在react-router-dom和 MobX 之间共享浏览器历史(通过HistoryTracker),我们可以使可观察对象与 URL 更改保持同步。

这种基于状态的路由方法有助于保持清晰的工作流心智模型。您的功能的所有逻辑都留在 MobX Store 中,提高了可读性。为这种基于状态的解决方案编写单元测试也很简单。事实上,在 MobX 应用程序中,大多数单元测试都围绕存储和反应中心。许多 React 组件成为可观察对象的纯粹观察者,并且可以被视为普通的演示组件。

使用 MobX,您可以专注于领域逻辑,并确保 UI 上有适当的可观察状态。通过将所有领域逻辑和状态封装在存储中,并将所有演示内容放在 React 组件中,可以清晰地分离关注点。这极大地改善了开发者体验(DX),并有助于随着时间的推移更好地扩展。这是 MobX 的真正承诺。

要了解更丰富功能的基于状态的路由解决方案,请查看mobx-state-router(github.com/nareshbhatia/mobx-state-router)。

摘要

在本章中,我们应用了我们在过去几章中学到的各种技术和概念。两个示例,表单验证和页面路由,分别提出了一套建模可观察状态的独特方法。我们还看到了如何创建细粒度的观察者组件,以实现 React 组件的高效渲染。

MobX 的实际应用始终以建模可观察状态为起点。毕竟,这就是驱动 UI 的数据。下一步是确定改变可观察状态的动作。最后,您需要调用副作用,并查看这些效果依赖于哪些可观察状态。这就是应用于现实场景的副作用模型,以 MobX 三元组的形式呈现:可观察状态-动作-反应。

根据我们迄今积累的所有知识,我们现在准备深入了解 MobX,从第七章开始,特殊情况的特殊 API。

第七章:特殊情况的特殊 API

MobX 的 API 表面非常简洁,为处理状态管理逻辑提供了正确的抽象。在大多数情况下,我们已经看到的 API 将足够。然而,总会有一些棘手的边缘情况需要略微偏离常规。正是为了这些特殊情况,MobX 为您提供了一些特殊的 API。我们将在本章中看到其中一些。

本章我们将涵盖以下主题:

  • 使用对象 API 进行直接操作

  • 使用inject()和observe()来连接到内部 MobX 事件系统。

  • 将有助于调试的特殊实用函数和工具

  • 快速提及一些杂项 API

技术要求

您需要具备 JavaScript 编程语言。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter07

查看以下视频,以查看代码的运行情况:

bit.ly/2A1Or6V

使用对象 API 进行直接操作

在决定可观察状态的数据结构时,您的自然选择应该是使用observable.object()、observable.array()、observable.map()、observable.box(),或者使用方便的observable()API。操作这些数据结构就像直接改变属性或根据需要添加和删除元素一样简单。

MobX 为您提供了另一种对数据结构进行手术式更改的方法。它公开了一个细粒度的对象 API,可以在运行时改变这些数据结构。事实上,它为您提供了一些原始数据结构甚至不可能的功能。例如,向可观察对象添加新属性,并保持其响应性。

细粒度读取和写入

对象 API 专注于对顶层数据结构(对象、数组和映射)的可观察属性进行细粒度控制。通过这样做,它们继续与 MobX 的响应式系统良好地配合,并确保您所做的细粒度更改被reactions捕获。以下 API 适用于可观察的对象/数组/映射:

  • get(thing, key): 检索键下的值。这个键甚至可以不存在。当在反应中使用时,当该键变为可用时,它将触发重新执行。

  • set(thing, key, value) 或 set(thing, { key: value }): 为键设置一个值。第二种形式更适合一次设置多个键-值对。在概念上,它与Object.assign()非常相似,但增加了响应性。

  • has(thing, key): 返回一个布尔值,指示键是否存在。

  • remove(thing, key): 删除给定的键及其值。

  • values(thing): 给出一个值数组。

  • keys(thing): 返回包含所有键的数组。请注意,这仅适用于可观察对象和映射。

  • entries(thing): 返回一个键值对数组,其中每对是两个元素的数组([key, value])。

以下代码片段练习了所有这些 API:

import {
    autorun,
    observable,
 set,
 get,
 has,
    toJS,
    runInAction,
 remove,
 values,
 entries,
 keys,
} from 'mobx';

class Todo {
    @observable description = '';
    @observable done = false;

    constructor(description) {
        this.description = description;
    }
}

const firstTodo = new Todo('Write Chapter');
const todos = observable.array([firstTodo]);
const todosMap = observable.map({
    'Write Chapter': firstTodo,
});

// Reactions to track changes autorun(() => {
 console.log(`metadata present: ${has(firstTodo, 'metadata')}`);
 console.log(get(firstTodo, 'metadata'), get(firstTodo, 'user'));
 console.log(keys(firstTodo));
});
autorun(() => {
    // Arrays
 const secondTodo = get(todos, 1);
 console.log('Second Todo:', toJS(secondTodo));
 console.log(values(todos), entries(todos));
});

// Granular changes runInAction(() => {
 set(firstTodo, 'metadata', 'new Metadata');
 set(firstTodo, { metadata: 'meta update', user: 'Pavan Podila' });
 set(todos, 1, new Todo('Get it reviewed'));
});

runInAction(() => {
 remove(firstTodo, 'metadata');
 remove(todos, 1);
});

通过使用这些 API,您可以针对可观察对象的特定属性并根据需要进行更新。使用对象 API 读取和写入不存在的键被认为是有效的。请注意,我们在autorun()中读取firstTodo的metadata属性,这在调用时并不存在。然而,由于使用了get()API,MobX 仍然跟踪这个键。当我们在操作中稍后set()了metadata时,autorun()会重新触发以在控制台上打印出它。

这可以在以下控制台输出中看到。请注意,当移除时,metadata检查从false变为true,然后再变回false:

metadata present: false undefined undefined (2) ["description", "done"] Second Todo: undefined  [Todo] [Array(2)]    metadata present: true meta update Pavan Podila (4) ["description", "done", "metadata", "user"] Second Todo: {description: "Get it reviewed", done: false}  (2) [Todo, Todo] (2) [Array(2), Array(2)]    metadata present: false undefined "Pavan Podila" (3) ["description", "done", "user"] Second Todo: undefined  [Todo] [Array(2)] 

从 MobX 到 JavaScript

所有的可观察类型都是由 MobX 创建的特殊类,它们不仅存储数据,还有一堆用来跟踪变化的杂事。我们将在后面的章节中探讨这些杂事,但就我们现在的讨论而言,这些 MobX 类型并不总是与其他第三方 API 兼容,特别是在使用 MobX 4 时。

当与外部库进行接口时,您可能需要发送原始的 JavaScript 值,而不是 MobX 类型的值。这就是您需要toJS()函数的地方。它将 MobX 可观察对象转换为原始的 JavaScript 值:

toJS(source, options?)

source: 任何可观察的盒子、对象、数组、映射或基元。

options: 一个可选参数,用于控制行为,例如:

  • exportMapsAsObject (boolean): 是否将可观察的映射序列化为对象(当为true时)或 JavaScript 映射(当为false时)。默认为true。

  • detectCycles (boolean): 默认设置为true。它在序列化过程中检测循环引用,并重用已经序列化的对象。在大多数情况下,这是一个很好的默认设置,但出于性能原因,当你确定没有循环引用时,可以将其设置为false。

toJS()的一个重要注意点是它不会序列化computed properties。这是有道理的,因为它纯粹是可以随时重新计算的派生信息。toJS()的目的是仅序列化核心 observable 状态。同样,observable 的任何不可枚举属性都不会被序列化,也不会递归到任何非 observable 的数据结构中。

在下面的例子中,你可以看到toJS() API 是如何应用于 observables 的:

const number = observable.box(10);
const cart = observable({
    items: [{ title: 'milk', quantity: 2 }, { title: 'eggs', quantity: 3 }],
});

console.log(toJS(number));

console.log('MobX type:', cart);
console.log('JS type:', toJS(cart));

控制台输出显示了在应用toJS() API 之前和之后的cart observable。

10 **MobX type: Proxy {Symbol(mobx administration): ObservableObjectAdministration$$1}** **JS type: {items: Array(2)}** 

观察事件流动

我们在前几章中看到的 API 允许你创建 observables 并通过reactions对变化做出反应。MobX 还提供了一种方法来连接到内部流动的事件,使得响应式系统能够工作。通过将监听器附加到这些事件,你可以微调一些昂贵资源的使用或控制允许应用于 observables 的更新。

连接到可观察性

通常,reactions是我们读取observables并应用一些副作用的地方。这告诉 MobX 开始跟踪 observable 并在变化时重新触发 reaction。然而,如果我们从 observable 的角度来看,它如何知道它何时被 reaction 使用?它如何在被 reaction 读取时进行一次性设置,并在不再被使用时进行清理?

我们需要的是能够知道何时 observable 变为observed和何时变为unobserved:它在 MobX 响应式系统中变为活动和非活动的两个时间点。为此,我们有以下恰如其名的 APIs:

  • disposer = onBecomeObserved(observable, property?: string, listener: () => void)

  • disposer = onBecomeUnobserved(observable, property?: string, listener: () => void)

observable:可以是一个包装的 observable,一个 observable 对象/数组/映射。

property: 可观察对象的可选属性。指定属性与直接引用属性有根本的不同。例如,onBecomeObserved(cart, 'totalPrice', () => {})与onBecomeObserved(cart.totalPrice, () => {})是不同的。在第一种情况下,MobX 将能够跟踪可观察属性,但在第二种情况下,它不会,因为它只接收值而不是属性。事实上,MobX 将抛出一个Error,指示在cart.totalPrice的情况下没有东西可跟踪:

Error: [mobx] Cannot obtain atom from 0 

前面的错误现在可能没有太多意义,特别是原子一词。我们将在第九章 Mobx Internals中更详细地了解原子。

disposer: 这些处理程序的返回值。这是一个函数,可用于处理这些处理程序并清理事件连接。

以下代码片段展示了这些 API 的使用:

import {
    onBecomeObserved,
    onBecomeUnobserved,
    observable,
    autorun,
} from 'mobx';

const obj = observable.box(10);
const cart = observable({
    items: [],
    totalPrice: 0,
});

onBecomeObserved(obj, () => {
 console.log('Started observing obj');
});

onBecomeUnobserved(obj, () => {
 console.log('Stopped observing obj');
});

onBecomeObserved(cart, 'totalPrice', () => {
 console.log('Started observing cart.totalPrice');
});
onBecomeUnobserved(cart, 'totalPrice', () => {
 console.log('Stopped observing cart.totalPrice');
});

const disposer = autorun(() => {
    console.log(obj.get(), `Cart total: ${cart.totalPrice}`);
});
setTimeout(disposer);

obj.set(20);
cart.totalPrice = 100;

在前面的代码片段中,当autorun()第一次执行时,onBecomeObserved()处理程序将被调用。调用disposer函数后,将调用onBecomeUnobserved()处理程序。这可以在以下控制台输出中看到:

Started observing obj Started observing cart.totalPrice 10 "Cart total: 0" 20 "Cart total: 0" 20 "Cart total: 100" Stopped observing cart.totalPrice Stopped observing obj 

onBecomeObserved()和onBecomeUnobserved()是延迟设置(和清除)可观察对象的绝佳钩子,可以在首次使用(和最后一次使用)时进行。这在某些情况下非常有用,例如可能需要执行昂贵的操作来设置可观察对象的初始值。此类操作可以通过推迟执行,直到实际上某处使用它时才执行。

延迟加载温度

让我们举一个例子,我们将延迟加载城市的温度,但只有在访问时才加载。这可以通过使用onBecomeObserved()和onBecomeUnobserved()的钩子对可观察属性进行建模来实现。以下代码片段展示了这一点:

// A mock service to simulate a network call to a weather API const temperatureService = {
    fetch(location) {
        console.log('Invoked temperature-fetch');

        return new Promise(resolve =>
            setTimeout(resolve(Math.round(Math.random() * 35)), 200),
        );
    },
};

class City {
 @observable temperature;
    @observable location;

    interval;
    disposers;

    constructor(location) {
        this.location = location;
 const disposer1 = onBecomeObserved(
 this,
 'temperature',
 this.onActivated,
 );
 const disposer2 = onBecomeUnobserved(
 this,
 'temperature',
 this.onDeactivated,
 );

        this.disposers = [disposer1, disposer2];
    }

    onActivated = () => {
        this.interval = setInterval(() => this.fetchTemperature(), 5000);
        console.log('Temperature activated');
    };

    onDeactivated = () => {
        console.log('Temperature deactivated');
        this.temperature = undefined;
        clearInterval(this.interval);
    };

    fetchTemperature = flow(function*() {
        this.temperature = yield temperatureService.fetch(this.location);
    });

    cleanup() {
        this.disposers.forEach(disposer => disposer());
        this.disposers = undefined;
    }
}

const city = new City('Bengaluru');
const disposer = autorun(() =>
    console.log(`Temperature in ${city.location} is ${city.temperature}ºC`),
);

setTimeout(disposer, 15000);  

前面的控制台输出显示了temperature可观察对象的激活和停用。它在autorun()中被激活,15 秒后被停用。我们在onBecomeObserved()处理程序中启动定时器来不断更新温度,并在onBecomeUnobserved()处理程序中清除它。定时器是我们管理的资源,只有在访问temperature之后才会创建,而不是之前:

Temperature activated Temperature in Bengaluru is undefinedºC   Invoked temperature-fetch Temperature in Bengaluru is 22ºC Invoked temperature-fetch Temperature in Bengaluru is 32ºC Invoked temperature-fetch Temperature in Bengaluru is 4ºC   Temperature deactivated

变化的守门人

您对 observable 所做的更改不会立即应用于 MobX。相反,它们经过一层拦截器,这些拦截器有能力保留变化、修改变化,甚至完全丢弃变化。这一切都可以通过intercept()API 实现。签名与onBecomeObserved和onBecomeUnobserved非常相似,回调函数(interceptor)给出了 change 对象:

disposer = intercept(observable, property?, interceptor: (change) => change | null )

observable:一个封装的 observable 或 observable 对象/数组/映射。

property:要拦截的 observable 的可选字符串名称。就像我们之前在onBecomeObserved和onBecomeUnobserved中看到的那样,对于intercept(cart, 'totalPrice', (change) => {})和intercept(cart.totalPrice, () => {})有所不同。对于后者(cart.totalPrice),您拦截的是一个值而不是 observable 属性。MobX 将抛出错误,指出您未传递正确的类型。

interceptor:一个回调函数,接收 change 对象并期望返回最终的变化;原样应用、修改或丢弃(null)。在拦截器中抛出错误也是有效的,以通知异常更新。

disposer:返回一个函数,当调用时将取消此拦截器。这与我们在onBecomeObserved()、onBecomeUnobserved()以及autorun()、reaction()和when()中看到的非常相似。

拦截变化

接收到的 change 参数具有一些已知字段,提供了详细信息。其中最重要的是type字段,它告诉您变化的类型,以及object,它给出了发生变化的对象。根据type,一些其他字段为变化添加了更多的上下文:

  • type:可以是 add、delete 或 update 之一

  • object:一个封装的 observable 或 observable 对象/数组/映射实例

  • newValue:当类型为 add 或 update 时,此字段包含新值

  • oldValue:当类型为 delete 或 update 时,此字段携带先前的值

在拦截器回调中,您有机会最终确定您实际想要应用的变化类型。您可以执行以下操作之一:

  • 返回 null 并丢弃变化

  • 使用不同的值进行更新

  • 抛出指示异常值的错误

  • 原样返回并应用变化

让我们举一个拦截主题更改并确保只应用有效更新的示例。在下面的片段中,您可以看到我们如何拦截主题可观察对象的color属性。颜色可以是light或dark,也可以是l或d的简写值。对于任何其他值,我们会抛出错误。我们还防止取消颜色的设置,通过返回null并丢弃更改:

import { intercept, observable } from 'mobx';

const theme = observable({
    color: 'light',
    shades: [],
});

const disposer = intercept(theme, 'color', change => {
    console.log('Intercepting:', change);

    // Cannot unset value, so discard this change
  if (!change.newValue) {
        return **null**;
    }

    // Handle shorthand values
  const newTheme = change.newValue.toLowerCase();
    if (newTheme === 'l' || newTheme === 'd') {
        change.newValue = newTheme === 'l' ? 'light' : 'dark'; // set 
         the correct value
  return change;
    }

    // check for a valid theme
  const allowedThemes = ['light', 'dark'];
    const isAllowed = allowedThemes.includes(newTheme);
    if (!isAllowed) {
        **throw** new Error(`${change.newValue} is not a valid theme`);
    }

    return change; // Correct value so return as-is });

观察()变化

作为intercept()对应的实用程序是observe()。正如其名称所示,observe()允许您对可观察对象进行细粒度观察:

observe(observable, property?, observer: (change) => {})

签名与intercept()完全相同,但行为完全不同。observe()在可观察对象被应用更改后被调用。

一个有趣的特点是observe()对事务是免疫的。这意味着观察者回调会在突变后立即被调用,而不是等到事务完成。正如您所知,actions是发生突变的地方。MobX 通过触发它们来优化通知,但只有在顶层action完成后才会触发。使用observe(),您可以在突变发生时获得未经过滤的视图。

建议在感觉需要observe()时使用autorun()。仅在您认为需要立即通知突变时使用它。

以下示例显示了在突变可观察对象时您可以观察到的各种细节。正如您所看到的,change参数与intercept()完全相同:

import { observe, observable } from 'mobx';

const theme = observable({
    color: 'light',
    shades: [],
});

const disposer = observe(theme, 'color', change => {
    console.log(
        `Observing ${change.type}`,
        change.oldValue,
        '-->',
        change.newValue,
        'on',
        change.object,
    );
});

theme.color = 'dark';

开发工具

随着应用程序功能的增加,了解 MobX 反应系统的使用方式和时间变得必不可少。MobX 配备了一组调试工具,帮助您监视和跟踪其中发生的各种活动。这些工具为您提供了系统内所有可观察变化、操作和反应的实时视图。

使用 spy()跟踪反应性

之前,我们看到了observe()函数,它允许您对单个可观察对象发生的变化进行"观察"。但是,如果您想观察跨所有可观察对象发生的变化,而不必单独设置observe()处理程序,该怎么办?这就是spy()发挥作用的地方。它让您了解系统中各种可观察对象随时间变化的情况:

disposer = spy(listener: (event) => { })

它接受一个监听函数,该函数接收携带所有细节的事件对象。事件具有与observe()处理程序非常相似的属性。有一个type字段告诉您事件的类型。类型可以是以下之一:

  • update:对于对象、数组、映射

  • add:对于对象、数组、映射

  • delete:对于映射

  • create:对于包装的可观察对象

  • action:当动作触发时

  • reaction:在执行autorun()、reaction()或when()时

  • compute:对于计算属性

  • error:在操作或反应内捕获任何异常的情况下

这是一小段设置spy()并将输出打印到控制台的代码片段。我们还将在五秒后取消此间谍:

import { spy } from 'mobx';

const disposer = spy(event => console.log(event));

setTimeout(disposer, 5000);
// Console output
{type: "action", name: "<unnamed action>", object: undefined, arguments: Array(0), **spyReportStart**: true} {type: "update", object: BookSearchStore, oldValue: 0, name: "BookSearchStore@1", newValue: 2179, …} {**spyReportEnd**: true} {object: Proxy, type: "splice", index: 0, removed: Array(0), added: Array(20), …} {spyReportEnd: true} {type: "update", object: BookSearchStore, oldValue: Proxy, name: "BookSearchStore@1", newValue: Proxy, …} {spyReportEnd: true} {type: "update", object: BookSearchStore, oldValue: "pending", name: "BookSearchStore@1", newValue: "completed", …} 

一些间谍事件可能伴随着spyReportStart或spyReportEnd属性。这些标记了一组相关的事件。

在开发过程中直接使用spy()可能不是最佳选择。最好依赖于可视化调试器(在下一节中讨论),它利用spy()来为您提供更可读的日志。请注意,当您将NODE_ENV环境变量设置为"production"时,对spy()的调用在生产构建中将是无操作。

跟踪反应

虽然spy()可以让您观察 MobX 中发生的所有更改,但trace()是一个专门针对计算属性、反应和组件渲染的实用程序。您可以通过简单地在其中放置一个trace()语句来找出为什么会调用计算属性、反应或组件渲染:

trace(thing?, property?, enterDebugger?)

它有三个可选参数:

  • thing:一个可观察对象

  • property:一个可观察属性

  • enterDebugger:一个布尔标志,指示您是否希望自动步入调试器

通常会使用trace(true)来调用跟踪,这将在调用时暂停在调试器内。对于书搜索示例(来自第三章,使用 MobX 的 React 应用),我们可以直接在SearchTextField组件的render()内放置一个跟踪语句:

import { trace } from 'mobx';

@inject('store')
@observer export class SearchTextField extends React.Component {
    render() {
        trace(true);

        /* ... */
    }

}

当调试器暂停时,您将获得为什么执行了此计算属性、反应或渲染的完整根本原因分析。在 Chrome 开发工具中,您可以看到这些细节如下:

Chrome 开发工具上的详细信息

使用 mobx-react-devtools 进行可视化调试

spy()和trace()非常适合深入了解 MobX 响应式系统的代码级别。然而,在开始分析性能改进时,可视化调试非常方便。MobX 有一个名为mobx-react-devtools的姊妹 NPM 包,它提供了一个简单的<DevTools />组件,可以帮助您可视化组件树如何对可观察对象做出反应。通过在应用程序顶部包含此组件,您将在运行时看到一个工具栏:

import DevTools from 'mobx-react-devtools';
import React from 'react';

export class MobXBookApp extends React.Component {
    render() {
        return (
            <Fragment>
 <DevTools />
                <RootAppComponent />
            </Fragment>
        );
    }
}

下面的屏幕截图显示了 MobX DevTools 工具栏出现在屏幕的右上角!

通过启用按钮,您可以看到哪些组件在可观察值发生变化时进行渲染,查看连接到 DOM 元素的可观察值的依赖树,并在操作/反应执行时打印控制台日志。组件在渲染时会闪烁一个彩色矩形。矩形的颜色表示渲染所需的时间,绿色表示最快,红色表示最慢。您可以观察闪烁的矩形,以确保只有您打算更改的部分重新渲染。这是识别不必要渲染的组件并可能创建更精细的观察者的好方法。

mobx-react-devtools包依赖于spy()来打印执行操作和反应的控制台日志。

其他一些 API

MobX 提供了一些不太常用的杂项 API。为了完整起见,这里还是值得一提的。

查询响应式系统

在处理 MobX 中的各种抽象(可观察值、操作、反应)时,有时需要知道某个对象、函数或值是否属于某种类型。MobX 有一组isXXX API,可以帮助您确定值的类型:

  • isObservableObject(thing), isObservableArray(thing), isObservableMap(thing): 告诉你传入的值是否是可观察的对象、数组或映射

  • isObservable(thing)和isObservableProp(thing, property?):类似于前面的点,但更一般化地检查可观察值

  • isBoxedObservable(thing): 值是否是一个包装的可观察值

  • isAction(func): 如果函数被操作包装,则返回true

  • isComputed(thing)和isComputedProp(thing, property?):检查值是否是计算属性

深入了解响应式系统

MobX 在内部构建了一个反应性的结构,保持所有的可观察对象和反应都连接在一起。我们将在第九章 Mobx Internals中探索这些内部结构,那里我们将看到某些术语的提及,比如atoms。现在,让我们快速看一下这些 API,它们为您提供了可观察对象和反应的内部表示。

  • getAtom(thing, property?):在每个可观察对象的核心是一个Atom,它跟踪依赖于可观察值的观察者。它的目的是在任何人读取或写入可观察值时报告。通过此 API,您可以获取支持可观察对象的Atom的实例。

  • getDependencyTree(thing, property?):这为您提供了给定对象依赖的依赖树。它可用于获取计算属性或反应的依赖关系。

  • getObserverTree(thing, property?):这是getDependencyTree()的对应物,它为您提供了依赖于给定对象的观察者。

摘要

尽管 MobX 有一个精简的外层 API,但也有一组 API 用于更精细的观察和变化。我们看到了如何使用 Object API 来对可观察树进行非常精确的更改。通过observe()和intercept(),您可以跟踪可观察对象中发生的更改,并拦截以修改更改。

spy()和trace()在调试期间是您的朋友,并与mobx-react-devtools配合使用,您可以获得一个用于识别和改进渲染性能的可视化调试器。这些工具和实用程序为您提供了丰富的开发人员体验(DX),在使用 MobX 时非常有用。

在第八章 探索 mobx-utils 和 mobx-state-tree中,我们将提高使用 MobX 与特殊包mobx-utils和mobx-state-tree的水平。

第八章:探索 mobx-utils 和 mobx-state-tree

当你开始深入了解 MobX 的世界时,你会意识到某些类型的用例经常重复出现。第一次解决它们时,会有一种明确的成就感。然而,第五次之后,你会想要标准化解决方案。mobx-utils是一个 NPM 包,为你提供了几个标准实用程序,用于处理 MobX 中的常见用例。

为了进一步推动标准化水平,我们可以将更多结构化的意见引入我们的 MobX 解决方案中。这些意见是在多年的 MobX 使用中形成的,并包含了快速开发的各种想法。这一切都可以通过mobx-state-tree NPM 包实现。

在本章中,我们将更详细地介绍以下包:

  • mobx-utils提供了一系列实用功能

  • mobx-state-tree(MST)是一个有意见的 MobX

技术要求

你需要在系统上安装 Node.js。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter08

查看以下视频,了解代码的实际操作:

bit.ly/2LiFSJO

mobx-utils 的实用功能

mobx-utils提供了各种实用功能,可以简化 MobX 中的编程任务。你可以使用npm或yarn安装mobx-utils:

$ npm install mobx-utils

在本节的其余部分,我们将重点介绍一些经常使用的实用程序。其中包括以下内容:

  • fromPromise()

  • lazyObservable()

  • fromResource()

  • now()

  • createViewModel()

使用 fromPromise()可视化异步操作

在 JavaScript 中,promise 是处理异步操作的好方法。在表示 React UI 上的操作状态时,我们必须确保处理 promise 的三种状态中的每一种。这包括 promise 处于pending(操作进行中)状态时,fulfilled(操作成功完成)状态时,或者rejected(失败)状态时。fromPromise()是处理 promise 的一种便利方式,并提供了一个很好的 API 来直观地表示这三种状态:

newPromise = fromPromise(promiseLike)

promiseLike:Promise的实例或(resolve, reject) => { }

fromPromise()包装给定的 promise,并返回一个新的、带有额外可观察属性的、MobX 充电的 promise:

  • state:三个字符串值之一:pending、fulfilled或rejected:这些也作为mobx-utils包的常量可用:mobxUtils.PENDING、mobxUtils.FULFILLED和mobxUtils.REJECTED。

  • value:已解析的value或rejected错误。使用state来区分值。

  • case({pending, fulfilled, rejected}):这用于为三种状态提供 React 组件。

让我们通过一个例子来看看所有这些。我们将创建一个简单的Worker类,执行一些操作,这些操作可能会随机失败。这是跟踪操作的Worker类,通过调用fromPromise()来调用操作。请注意,我们将一个promise作为参数传递给fromPromise():

import { fromPromise, PENDING, FULFILLED, REJECTED } from 'mobx-utils';
class Worker {
    operation = null;
 start() {
 this.operation = fromPromise(this.performOperation());
    }
    performOperation() {
        return new Promise((resolve, reject) => {
            const timeoutId = setTimeout(() => {
                clearTimeout(timeoutId);
                Math.random() > 0.25 ? resolve('200 OK') 
                    : reject(new Error('500 FAIL'));
            }, 1000);
        });
    }
}

为了可视化这个操作,我们可以利用case() API 来显示每个状态对应的 React 组件。这可以在以下代码中看到。随着操作从pending到fulfilled或rejected的进展,这些状态将以正确的 React 组件呈现。对于fulfilled和rejected状态,已解析的value或rejected error作为第一个参数传入:

import { fromPromise, PENDING, FULFILLED, REJECTED } from 'mobx-utils';
import { observer } from 'mobx-react';

import React, { Fragment } from 'react';
import { CircularProgress, Typography } from '@material-ui/core/es/index';

@observer export class FromPromiseExample extends React.Component {
    worker;

    constructor(props) {
        super(props);

 this.worker = new Worker();
 this.worker.start();
    }

    render() {
        const { operation } = this.worker;
 return operation.case({
            [PENDING]: () => (
                <Fragment>
                    <CircularProgress size={50}  color={'primary'} />
                    <Typography variant={'title'}>
                        Operation in Progress
                    </Typography>
                </Fragment>
            ),
            [FULFILLED]: value => (
                <Typography variant={'title'}  color={'primary'}>
                    Operation completed with result: {value}
                </Typography>
            ),
            [REJECTED]: error => (
                <Typography variant={'title'}  color={'error'}>
                    Operation failed with error: {error.message}
                </Typography>
            ),
        });
    }
}

我们也可以手动切换可观察的state属性,而不是使用case()函数。实际上,case()在内部执行这个操作。

使用lazyObservable()进行延迟更新

对于执行代价高昂的操作,将其推迟到需要时是有意义的。使用lazyObservable(),您可以跟踪这些操作的结果,并在需要时更新。它接受一个执行计算并在准备就绪时推送值的函数:

result = lazyObservable(sink => { }, initialValue)

在这里,sink是要调用的回调函数,将值推送到lazyObservable上。延迟可观察对象也可以以一些initialValue开始。

可以使用result.current()来检索lazyObservable()的当前值。一旦延迟可观察对象已更新,result.current()将有一些值。要再次更新延迟可观察对象,可以使用result.refresh()。这将重新调用计算,并最终通过sink回调推送新值。请注意,sink回调可以根据需要调用多次。

在以下代码片段中,您可以看到使用lazyObservable()来更新操作的值:

import { lazyObservable } from 'mobx-utils';

class ExpensiveWorker {
    operation = null;

    constructor() {
 this.operation = lazyObservable(async sink => {
 sink(null); // push an empty value before the update
            const result = await this.performOperation();
 sink(result);
        });
    }

    performOperation() {
        return new Promise(resolve => {
            const timeoutId = setTimeout(() => {
                clearTimeout(timeoutId);
                resolve('200 OK');
            }, 1000);
        });
    }
}

MobX 跟踪对current()方法的调用,因此请确保仅在需要时调用它。在render()中使用此方法会导致 MobX 重新渲染组件。毕竟,组件的render()在 MobX 中转换为 reaction,每当其跟踪的 observable 发生变化时重新评估。

要在 React 组件(observer)中使用 lazy-observable,我们依赖于current()方法来获取其值。MobX 将跟踪此值,并在其更改时重新渲染组件。请注意,在按钮的onClick处理程序中,我们通过调用其refresh()方法来更新 lazy-observable:

import { observer } from 'mobx-react';
import React, { Fragment } from 'react';
import {
    Button,
    CircularProgress,
    Typography,
} from '@material-ui/core/es/index'; **@observer** export class LazyObservableExample extends React.Component {
    worker;
    constructor(props) {
        super(props);

 this.worker = new ExpensiveWorker();
    }
   render() {
 const { operation } = this.worker;
 const result = operation.current();
        if (!result) {
            return (
                <Fragment>
                    <CircularProgress size={50}  color={'primary'} />
                    <Typography variant={'title'}>
                        Operation in Progress
                    </Typography>
                </Fragment>
            );
        }
         return (
            <Fragment>
                <Typography variant={'title'}  color={'primary'}>
                    Operation completed with result: {result}
                </Typography>
                <Button
  variant={'raised'}   color={'primary'}  onClick={() => operation.refresh()} >
                    Redo Operation
                </Button>
            </Fragment>
        );
    }
}

使用 fromResource()的通用 lazyObservable()

还有一种更一般化的lazyObservable()形式,称为fromResource()。类似于lazyResource(),它接受一个带有sink回调的函数。这充当订阅函数,仅在实际请求资源时调用。此外,它接受第二个参数,取消订阅函数,可用于在不再需要资源时进行清理:

resource = fromResource(subscriber: sink => {}, unsubscriber: () => {},    
           initialValue)

fromResource() 返回一个 observable,当第一次调用它的current()方法时,它将开始获取值。它返回一个 observable,还具有dispose()方法来停止更新值。

在下面的代码片段中,您可以看到一个DataService类依赖于fromResource()来管理其 WebSocket 连接。数据的值可以使用data.current()来检索。这里,data充当 lazy-observable。在订阅函数中,我们设置了 WebSocket 并订阅了特定频道。我们在fromResource()的取消订阅函数中取消订阅此频道:

import { **fromResource** } from 'mobx-utils';

class DataService {
    data = null;
    socket = null;

    constructor() {
 this.data = fromResource(
            async sink => {
                this.socket = new WebSocketConnection();
                await this.socket.subscribe('data');

                const result = await this.socket.get();

                sink(result);
            },
            () => {
                this.socket.unsubscribe('data');
                this.socket = null;
            },
        );
    }
}

const service = new DataService(); console.log(service.data.current());

// After some time, when no longer needed service.data.dispose();

我们可以使用dispose()方法显式处理资源。但是,MobX 足够聪明,知道没有更多观察者观察此资源时,会自动调用取消订阅函数。

mobx-utils 提供的一种特殊类型的 lazy-observable 是now(interval: number)。它将时间视为 observable,并以给定的间隔更新。您可以通过简单调用now()来检索其值,默认情况下每秒更新一次。作为 observable,它还会导致任何 reaction 每秒执行一次。在内部,now()使用fromResource()实用程序来管理计时器。

管理编辑的视图模型

在基于数据输入的应用程序中,通常会有表单来接受各种字段。在这些表单中,原始模型直到用户提交表单之前都不会发生变化。这允许用户取消编辑过程并返回到先前的值。这种情况需要创建原始模型的克隆,并在提交时推送编辑。尽管这种技术并不是非常复杂,但它确实增加了一些样板文件。

mobx-utils提供了一个方便的实用程序,名为createViewModel(),专门为这种情况量身定制:

viewModel = createViewModel(model)

model是包含可观察属性的原始模型。createViewModel()包装了这个模型并代理了所有的读取和写入。这个实用程序具有一些有趣的特性,如下:

  • 只要viewModel的属性没有更改,它将返回原始模型中的值。更改后,它将返回更新后的值,并将viewModel视为已修改。

  • 要最终确定原始模型上的更新值,必须调用viewModel的submit()方法。要撤消任何更改,可以调用reset()方法。要恢复单个属性,请使用resetProperty(propertyName: string)。

  • 要检查viewModel是否被修改,请使用isDirty属性。要检查单个属性是否被修改,请使用isPropertyDirty(propertyName: string)。

  • 要获取原始模型,请使用方便的model()方法。

使用createViewModel()的优势在于,您可以将整个编辑过程视为单个事务。只有在调用submit()时才是最终的。这允许您过早取消并保留原始模型在其先前状态。

在以下示例中,我们正在创建一个包装FormData实例并记录viewModel和model属性的viewModel。您将注意到viewModel的代理效果以及值在submit()时如何传播回模型:

class FormData {
    @observable name = '<Unnamed>';
    @observable email = '';
    @observable favoriteColor = '';
}

const viewModel = createViewModel(new FormData());

autorun(() => {
    console.log(
        `ViewModel: ${viewModel.name}, Model: ${
            viewModel.model.name
  }, Dirty: ${viewModel.isDirty}`,
    );
});

viewModel.name = 'Pavan';
viewModel.email = 'pavan@pixelingene.com';
viewModel.favoriteColor = 'orange';

console.log('About to reset');
viewModel.reset();

viewModel.name = 'MobX';

console.log('About to submit');
viewModel.submit();

autorun()的日志如下。您可以看到submit()和reset()对viewModel.name属性的影响:

ViewModel: <Unnamed>, Model: <Unnamed>, Dirty: false ViewModel: Pavan, Model: <Unnamed>, Dirty: true About to reset... ViewModel: <Unnamed>, Model: <Unnamed>, Dirty: false ViewModel: MobX, Model: <Unnamed>, Dirty: true About to submit... ViewModel: MobX, Model: MobX, Dirty: false

还有很多可以发现的地方

这里描述的一些实用程序绝不是详尽无遗的。mobx-utils提供了更多实用程序,我们强烈建议您查看 GitHub 项目(github.com/mobxjs/mobx-utils)以发现其余的实用功能。

有一些函数可以在 RxJS 流和 MobX Observables 之间进行转换,processor-functions可以在可观察数组附加时执行操作,MobX 的变体when(),它在超时后自动释放,等等。

一个有主见的 MobX 与 mobx-state-tree

MobX 在组织状态和应用各种操作和反应方面非常灵活。然而,它确实留下了一些问题需要你来回答:

  • 应该使用类还是只是带有extendObservable()的普通对象?

  • 数据应该如何规范化?

  • 在序列化状态时如何处理循环引用?

  • 还有很多

mobx-state-tree是一个提供了组织和结构化可观察状态的指导性指导的包。采用 MST 的思维方式会让你从一开始就获得几个好处。在本节中,我们将探讨这个包及其好处。

模型 - 属性、视图和操作

mobx-state-tree正如其名称所示,将状态组织在模型树中。这是一种以模型为先的方法,其中每个模型定义了需要捕获的状态。定义模型添加了在运行时对模型分配进行类型检查的能力,并保护您免受无意的更改。将运行时检查与像 TypeScript 这样的语言的使用结合起来,还可以获得编译时(或者说是设计时)类型安全性。通过严格类型化的模型,mobx-state-tree为您提供了安全的保证,并确保了您的类型模型的完整性和约束。这本身就是一个巨大的好处,特别是在处理像 JavaScript 这样的动态语言时。

让我们用一个简单的Todo模型来实现 MST:

import { types } from 'mobx-state-tree';

const Todo = types.model('Todo', {
    title: types.string,
    done: false,
});

模型描述了它所持有的数据的形状。在Todo模型的情况下,它只需要一个title string和一个boolean done属性。请注意,我们将我们的模型分配给了一个大写的名称(Todo)。这是因为 MST 实际上定义了一个类型,而不是一个实例。

MST 中的所有内置类型都是types命名空间的一部分。types.model()方法接受两个参数:一个可选的字符串name(用于调试和错误报告)和一个定义类型各种属性的object。所有这些属性都将被严格类型化。让我们尝试创建这个模型的一个实例:

const todo = Todo.create({
    title: 'Read a book',
    done: false,
});

请注意,我们已经将与模型中定义的相同的数据结构传递给了 Todo.create()。传递任何其他类型的数据将导致 MST 抛出类型错误。创建模型的实例也使其所有属性变成了可观察的。这意味着我们现在可以使用 MobX API 的全部功能。

让我们创建一个简单的反应,记录对 todo 实例的更改:

import { autorun } from 'mobx';

autorun(() => {
    console.log(`${todo.title}: ${todo.done}`);
});

// Toggle the done flag todo.done = !todo.done; 

如果您运行此代码,您会注意到一个异常被抛出,如下所示:

Error: [mobx-state-tree] Cannot modify 'Todo@<root>', the object is protected and can only be modified by using an action.

这是因为我们在动作之外修改了 todo.done 属性。您会回忆起前几章中的一个良好实践是将所有可观察的变化封装在一个动作内。事实上,甚至有一个 MobX API:configure({ enforceActions: 'strict' }),以确保这种情况发生。MST 对其状态树中的数据非常保护,并要求对所有变化使用动作。

这可能听起来非常严格,但它确实带来了额外的好处。例如,使用动作允许 MST 提供对中间件的一流支持。中间件可以拦截发生在状态树上的任何更改,并使实现诸如日志记录、时间旅行、撤销/重做、数据库同步等功能变得微不足道。

在模型上定义动作

我们之前创建的模型类型 Todo 可以通过链接的 API 进行扩展。actions() 就是这样一个 API,可以用来扩展模型类型的所有动作定义。让我们为我们的 Todo 类型做这件事:

const Todo = types
  .model('Todo', {
        title: types.string,
        done: false,
    })
 .actions(self => ({
 toggle() {
 self.done = !self.done;
 },
 }));

const todo = Todo.create({
    title: 'Read a book',
    done: false,
});

autorun(() => {
    console.log(`${todo.title}: ${todo.done}`);
});

todo.toggle();

actions() 方法接受一个函数作为参数,该函数接收模型实例作为其参数。在这里,我们称之为 self。这个函数应该返回一个定义所有动作的键值映射。在前面的片段中,我们利用了 ES2015 的对象字面量语法,使动作对象看起来更易读。接受动作的这种风格有一些显著的好处:

  • 使用函数允许您创建一个闭包,用于跟踪只被动作使用的私有状态。例如,设置在其中一个动作内部的 WebSocket 连接,不应该暴露给外部世界。

  • 通过将模型的实例传递给 actions(),您可以保证 this 指针始终是正确的。您再也不必担心在 actions() 中定义的函数的上下文了。toggle() 动作利用 self 来改变模型实例。

定义的 actions 可以直接在模型实例上调用,这就是我们在todo.toggle()中所做的。MST 不再对直接突变提出异议,当todo.done改变时,autorun()也会触发。

使用视图创建派生信息

与 actions 类似,我们还可以使用views()扩展模型类型。在 MST 中,模型中的派生信息是使用views()来定义的。就像actions()方法一样,它可以链接到模型类型上:

const Todo = types
  .model(/* ... */)
    .actions(/* ... */)
 .views(self => ({
 get asMarkdown() {
 return self.done
  ? `* [x] ~~${self.title}~~`
  : `* [ ] ${self.title}`;
 },

 contains(text) {
 return self.title.indexOf(text) !== -1;
 },
 })); const todo = Todo.create({
    title: 'Read a book',
    done: false,
});

autorun(() => {
    console.log(`Title contains "book"?: ${todo.contains('book')}`);
});

console.log(todo.asMarkdown);
// * [ ] Read a book

console.log(todo.contains('book')); // true

在Todo类型上引入了两个视图:

  • asMarkdown()是一个getter,它转换为一个 MobX 计算属性。像每个计算属性一样,它的输出被缓存。

  • contains()是一个常规函数,其输出不被缓存。然而,它具有在响应式上下文中重新执行的能力,比如reaction()或autorun()。

mobx-state-tree引入了一个非常严格的模型概念,其中明确定义了state、actions和derivations。如果你对在 MobX 中构建代码感到不确定,MST 可以帮助你应用 MobX 的理念并提供清晰的指导。

微调原始类型

到目前为止我们所看到的单一模型类型只是一个开始,几乎不能称为树。我们可以扩展领域模型使其更加真实。让我们添加一个User类型,他将创建todo项目:

import { types } from 'mobx-state-tree';

const User = types.model('User', {
    name: types.string,
    age: 42,
    twitter: types.maybe(types.refinement(types.string, v => 
 /^\w+$/.test(v))),
});

在前面的定义中有一些有趣的细节,如下所示:

  • age属性被定义为常量42,这对应于age的默认值。当没有为用户提供值时,它将被设置为这个默认值。此外,MST 足够聪明,可以推断类型为number。这对于所有原始类型都适用,其中默认值的类型将被推断为属性的类型。此外,通过给出默认值,我们暗示age属性是可选的。声明属性的更详细形式是:types.optional(types.number, 42)。

  • twitter属性有一个更复杂的定义,但可以很容易地分解。types.maybe()表明twitter句柄是可选的,因此它可能是undefined。当提供值时,它必须是字符串类型。但不是任何字符串;只有与提供的正则表达式匹配的字符串。这为您提供了运行时类型安全性,并拒绝无效的 Twitter 句柄,如Calvin & Hobbes或空字符串。

MST 提供的类型系统非常强大,可以处理各种复杂的类型规范。它还很好地组合,并为您提供了一种将许多较小类型组合成较大类型定义的功能方法。这些类型规范为您提供了运行时安全性,并确保了您的领域模型的完整性。

组合树

现在我们有了Todo和User类型,我们可以定义顶层的App类型,它组合了先前定义的类型。App类型代表应用程序的状态。

const App = types.model('App', {
 todos: types.array(Todo),
 users: types.map(User),
});

const app = App.create({
    todos: [
        { title: 'Write the chapter', done: false },
        { title: 'Review the chapter', done: false },
    ],
    users: {
        michel: {
            name: 'Michel Westrate',
            twitter: 'mwestrate',
        },
        pavan: {
            name: 'Pavan Podila',
            twitter: 'pavanpodila',
        },
    },
});

app.todos[0].toggle();

我们通过使用高阶类型(接受类型作为输入并创建新类型的类型)定义了App类型。在前面的片段中,types.map()和types.array()创建了这些高阶类型。

创建App类型的实例只是提供正确的 JSON 负载的问题。只要结构与类型规范匹配,MST 在运行时构建模型实例时就不会有问题。

记住:数据的形状始终会被 MST 验证。它永远不会允许不符合模型类型规范的数据更新。

请注意在前面的片段中,我们能够无缝调用app.todos[0].toggle()方法。这是因为 MST 能够成功构建app实例并用适当的类型包装 JSON 节点。

mobx-state-tree提升了对建模应用程序状态的重要性。为应用程序中的各种实体定义适当的类型对于其结构和数据完整性至关重要。一个很好的开始方式是将从服务器接收到的 JSON 编码为 MST 模型。下一步是通过添加更严格的类型、附加操作和视图来加强模型。

引用和标识符

到目前为止,本章一直在完全讨论在树中捕获应用程序的状态。树具有许多有趣的属性,易于理解和探索。但通常,当一个人开始将新技术应用于实际问题领域时,往往会发现树在概念上不足以描述问题领域。例如,友谊关系是双向的,不适合单向树。处理不是组合性质的关系,而是关联性质的关系,通常需要引入新的抽象层和技术,如数据规范化。

我们的应用程序中可以通过为Todo添加一个assignee属性来快速介绍这种关系。现在,很明显Todo并不拥有它的assignee,反之亦然;todos不是由单个用户拥有的,因为它们可以稍后被重新分配。因此,当组合不足以描述关系时,我们经常会退回到使用外键来描述关系。

换句话说,Todo项的 JSON 可以像下面的代码一样,其中Todo的assignee字段对应于User对象的userid字段:

使用name来存储assignee关系是一个坏主意,因为一个人的name并不是唯一的,而且它可能随时间改变。

{
    todos: [
        {
            title: 'Learn MST',
            done: false,
            assignee: '37',
        },
    ],
    users: {
        '37': {
            userid: '37',
            name: 'Michel Weststrate',
            age: 33,
            twitter: 'mweststrate',
        },
    },
}

我们最初的想法可能是将assignee和userid属性类型化为types.string字段。然后,每当我们需要时,我们可以在users映射中查找指定的用户,因为用户存储在其自己的userid下。由于用户查找可能是一个常见的操作,我们甚至可以引入一个视图和操作来读取或写入该用户。这将使我们的用户模型如下所示的代码所示:

import { types, getRoot } from 'mobx-state-tree';

const User = types.model('User', {
 userid: types.string, // uniquely identifies this User  name: types.string,
    age: 42,
    twitter: types.maybe(types.refinement(types.string, v => /^\w+$/.test(v))),
});

const Todo = types
  .model('Todo', {
 assignee: types.string, // represents a User  title: types.string,
        done: false,
    })
    .views(self => ({
 getAssignee() {
            if (!this.assignee) return undefined;
            return getRoot(self).users.get(this.assignee);
        },
    }))
    .actions(self => ({
 setAssignee(user) {
            if (typeof user === 'string') this.assignee = user;
            else if (User.is(user)) this.assignee = user.userid;
            else throw new Error('Not a valid user object or user id');
        },
    }));

const App = {
    /* as is */ };

const app = App.create(/* ... */);

console.log(app.todos[0].getAssignee().name); // Michel Weststrate 

在getAssignee()视图中,我们方便地利用了每个 MST 节点都知道自己在树中的位置这一事实。通过利用getRoot()实用程序,我们可以导航到users映射并获取正确的User对象。通过使用getAssignee()视图,我们获得了一个真正的User对象,以便我们可以直接访问和打印其name属性。

有几个有用的实用程序可以用来反映或处理树中的位置,例如getPath()、getParent()、getParentOfType()等。作为替代方案,我们可以将getAssignee()视图表示为return resolvePath(self, "../../users/" + self.assignee)。

我们可以将 MST 树视为状态的文件系统!getAssignee()只是将其转换为符号链接。

此外,我们引入了一个更新assignee属性的操作。为了确保setAssignee()操作可以方便地通过提供userid或实际用户对象来调用,我们应用了一些类型区分。在 MST 中,每种类型不仅公开了create()方法,还公开了is方法,以检查给定值是否属于相应的类型。

通过 types.identifier()和 types.reference()进行引用

我们可以在 MST 中清晰地表达这些查找/更新实用程序,这很好,但是如果您的问题领域很大,这将变得相当重复。幸运的是,这种模式内置在 MST 中。我们可以利用的第一种类型是types.identifier(),它表示某个字段唯一标识某个模型类型的实例。因此,在我们的示例中,我们可以将userid的类型定义为types.identifier(),而不是types.string。

其次,还有types.reference()。这种类型表示某个字段被序列化为原始值,但实际上表示对树中另一种类型的引用。MST 将自动为我们匹配identifier字段和reference字段,因此我们可以简化我们之前的状态树模型如下:

import { types } from "mobx-state-tree"

const User = types.model("User", {
 userid: types.identifier(), // uniquely identifies this User
  name: types.string,
  age: 42,
  twitter: types.maybe(types.refinement(types.string, (v => /^\w+$/.test(v))))
})

const Todo = types.model("Todo", {
 assignee: types.maybe(types.reference(User)), // a Todo can be assigned to a User
  title: types.string,
  done: false
})

const App = /* as is */

const app = App.create(/* */)
console.log(app.todos[0].assignee.name) // Michel Weststrate

由于引用类型,读取Todo的assignee属性实际上将解析存储的标识符并返回正确的User对象。因此,我们可以立即在前面的示例中打印其名称。请注意,我们的状态仍然是一个树。还要注意的是,我们不必指定User实例的引用应该在何处或如何解析。MST 将自动维护一个内部的基于类型+标识符的查找表来解析引用。通过使用引用和标识符,MST 具有足够的类型信息来自动处理我们的数据(去)规范化。

types.reference非常强大,并且可以自定义,例如根据相对路径(就像真正的符号链接!)而不是标识符解析对象。在许多情况下,您将与上述一样结合types.maybe,以表达Todo不一定有assignee。同样,引用的数组和映射可以以类似的方式建模。

声明性模型的开箱即用的好处

MST 帮助您以声明性方式组织和建模复杂的问题领域。由于在您的领域中定义类型的一致方法,我们得到了清晰简单的心智模型的好处。这种一致性还为我们带来了许多开箱即用的功能,因为 MST 深入了解状态树。我们之前看到的一个例子是使用标识符和引用进行自动数据规范化。MST 内置了许多更多功能。其中,有一些功能在实际中最为实用。我们将在本节的其余部分简要讨论它们。

不可变的快照

MST 始终在内存中保留状态树的不可变版本,可以使用getSnapshot()API 检索。基本上,const snapshot = getSnapshot(tree)是const tree = Type.create(snapshot)的反向操作。getSnapshot()使得快速序列化整个树的状态非常方便。由于 MST 由 MobX 支持,我们也可以很好地跟踪这一点。

快照在模型实例上转换为计算属性。

以下代码片段在每次更改时自动将树的状态存储在local-storage中,但每秒最多一次:

import { reaction } from 'mobx';
import { getSnapshot } from 'mobx-state-tree';

const app = App.create(/* as before */);

reaction(
    () => getSnapshot(app),
    snapshot => {
        window.localStorage.setItem('app', JSON.stringify(snapshot));
    },
    { delay: 1000 },
);

应该指出,MST 树中的每个节点本身都是 MST 树。这意味着在根上调用的任何操作也可以在其任何子树上调用。例如,如果我们只想存储整个状态的一部分,我们可以只获取子树的快照。

与getSnapshot()搭配使用的相关 API 是applySnapshot()。这可以用来以高效的方式使用快照更新树。通过结合getSnapshot()和applySnapshot(),你可以只用几行代码就构建一个时间旅行者!这留给读者作为练习。

JSON 补丁

尽管快照有效地捕获了整个应用程序的状态,但它们不适合与服务器或其他客户端频繁通信。这是因为快照的大小与要序列化的状态的大小成线性增长。相反,对于实时更改,最好向服务器发送增量更新。JSON-patch(RFC-6902)是关于如何序列化这些增量更新的官方标准,MST 默认支持此标准。

onPatch()API 可用于监听作为更改副作用生成的patches。另一方面,applyPatch()执行相反的过程:给定一个补丁,它可以更新现有树。onPatch()监听器会生成由操作所做的状态更改产生的patches。它还公开了所谓的inverse-patches:一个可以撤消patches所做更改的集合。

import { onPatch } from 'mobx-state-tree';

const app = App.create(/* see above */);

onPatch(app, (patches, inversePatches) => {
 console.dir(patches, inversePatches);
});

app.todos[0].toggle();

切换todo的前面代码在控制台上打印如下内容:

// patches:   [{
 op: "replace", path: "/todos/0/done", value: true }]   // inverse-patches:   [{
 op: "replace", path: "/todos/0/done", value: false }]

中间件

我们在前面的部分简要提到了中间件,但让我们在这里扩展一下。中间件充当对状态树上调用的操作的拦截器。因为 MST 要求使用操作,我们可以确保每个 操作 都会通过中间件。中间件的存在使得实现几个横切面特性变得微不足道,例如以下内容:

  • 日志记录

  • 认证

  • 时间旅行

  • 撤销/重做

事实上,mst-middlewares NPM 包包含了一些先前提到的中间件,以及一些其他中间件。有关这些中间件的更多详细信息,请参阅:github.com/mobxjs/mobx-state-tree/blob/master/packages/mst-middlewares/README.md。

进一步阅读

我们几乎只是触及了 MobX-State-Tree 的表面,但希望它已经在组织和构建 MobX 中的可观察状态方面留下了印象。这是一个明确定义的、社区驱动的方法,它融入了本书中讨论的许多最佳实践。要深入探索 MST,您可以参考官方入门指南:github.com/mobxjs/mobx-state-tree/blob/master/docs/getting-started.md#getting-started。

总结

在本章中,我们涵盖了使用 mobx-utils 和 mobx-state-tree 等包采用 MobX 的实际方面。这些包将社区对于在各种场景中使用 MobX 的智慧编码化。

mobx-utils 为您提供了一组用于处理异步任务、处理昂贵的更新、为事务编辑创建视图模型等的实用工具。

mobx-state-tree 是一个全面的包,旨在简化使用 MobX 进行应用程序开发。它采用规范化的方法来构建和组织 MobX 中的可观察状态。通过这种声明性的方法,MST 能够更深入地理解状态树,并提供各种功能,例如运行时类型检查、快照、JSON 补丁、中间件等。总的来说,它有助于开发对 MobX 应用程序的清晰的心智模型,并将类型域模型置于前沿。

在下一章中,我们将通过一瞥了解 MobX 的内部工作,来完成 MobX 的旅程。如果 MobX 的某些部分看起来像黑魔法,下一章将驱散所有这些神话。

第九章:Mobx 内部

到目前为止我们所看到的 MobX 是从消费者的角度出发的,重点是如何使用它,最佳实践以及处理真实用例的 API。本章将向下一层,并揭示 MobX 响应式系统背后的机制。我们将看到支撑和构成Observables-Actions-Reactions三元组的核心抽象。

本章将涵盖的主题包括以下内容:

  • MobX 的分层架构

  • Atoms 和 ObservableValues

  • Derivations 和 reactions

  • 什么是透明函数式响应式编程?

技术要求

您需要在系统上安装 Node.js。最后,要使用本书的 Git 存储库,用户需要安装 Git。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter09

查看以下视频以查看代码的运行情况:

bit.ly/2LvAouE

分层架构

像任何良好的系统一样,MobX 由各个层构建而成,每个层提供了更高层的服务和行为。如果你把这个视角应用到 MobX 上,你可以从下往上看到这些层:

  • Atoms:Atoms 是 MobX observables 的基础。顾名思义,它们是可观察依赖树的原子部分。它跟踪它的观察者,但实际上不存储任何值。

  • ObservableValue,ComputedValue 和 Derivations:ObservableValue扩展了Atom并提供了实际的存储。它也是包装 Observables 的核心实现。与此同时,我们有 derivations 和 reactions,它们是原子的观察者。它们对原子的变化做出响应并安排反应。ComputedValue建立在 derivations 之上,也充当一个 observable。

  • Observable{Object, Array, Map}和 APIs:这些数据结构建立在ObservableValue之上,并使用它来表示它们的属性和值。这也是 MobX 的 API 层,是与库从消费者角度交互的主要手段。

层的分离也在源代码中可见,不同的抽象层有不同的文件夹。这与我们在这里描述的情况并不是一一对应的,但在概念上,这些层在代码中也有很多相似之处。MobX 中的所有代码都是使用 TypeScript 编写的,并得到了一流的支持。

原子

MobX 的响应式系统由存在于可观察对象之间的依赖关系图支持。一个可观察对象的值可能依赖于一组可观察对象,而这些可观察对象又可能依赖于其他可观察对象。例如,一个购物车可以有一个名为description的计算属性,它依赖于它所持有的items数组和应用的任何coupons。在内部,coupons可能依赖于CouponManager类的validCoupons 计算属性。在代码中,这可能看起来像这样:

class Coupon {
    @observable isValid = false;

    /*...*/ }

class CouponManager {
    @observable.ref coupons = [];

    @computed
  get validCoupons() {
        return this.coupons.filter(coupon => coupon.isValid);
    }

    /*...*/ }

class ShoppingCart {
    @observable.shallow items = [];

    couponManager = new CouponManager();

    @computed
  get coupons() {
        return this.couponManager.validCoupons;
    }

    @computed
  get description() {
        return `Cart has ${this.items.length} item(s) with ${
            this.coupons.**length**
  } coupon(s) applied.`;
    }

    /*...*/ }

可视化这组依赖关系可能会给我们一个简单的图表,如下所示:

在运行时,MobX 将创建一个支持依赖树。这棵树中的每个节点都将由Atom的一个实例表示,这是 MobX 的核心构建块。因此,我们可以期望在前面图表中的树中的节点有五个原子。

原子有两个目的:

  • 当它被读取时通知。这是通过调用reportObserved()来完成的。

  • 当它被改变时通知。这是通过调用reportChanged()来完成的。

作为 MobX 响应性结构的一个节点,原子扮演着通知每个节点上发生的读取和写入的重要角色。

在内部,原子会跟踪其观察者并通知它们发生的变化。当调用reportChanged()时会发生这种情况。这里一个明显的遗漏是原子的实际值并没有存储在Atom本身。为此,我们有一个名为ObservableValue的子类,它是建立在Atom之上的。我们将在下一节中看到它。

因此,原子的核心约定包括我们之前提到的两种方法。它还包含一些像observers数组、是否正在被观察等一些管理属性。我们可以在讨论中安全地忽略它们:

class Atom {
    observers = [];

 reportObserved() {}
 reportChanged() {}

    /* ... */ }

在运行时读取原子

MobX 还让你能够在运行时看到后台的原子。回到我们之前的计算description属性的例子,让我们探索它的依赖树:

import { autorun, **$mobx**, **getDependencyTree** } from 'mobx';

const cart = new ShoppingCart();
const disposer = autorun(() => {
    console.log(cart.description);
});

const descriptionAtom = cart[$mobx].values.get('description'); console.log(getDependencyTree(descriptionAtom));

在前面的片段中有一些细节值得注意:

  • MobX 为您提供了一个特殊的符号$mobx,其中包含对可观察对象的内部维护结构的引用。cart实例使用cart[$mobx].values维护其所有可观察属性的映射。通过从此映射中读取,可以获得description属性的后备原子:cart[$mobx].values.get('description')。

  • 我们可以使用 MobX 公开的getDependencyTree()函数获取此属性的依赖树。它以Atom作为输入,并返回描述依赖树的对象。

这是description属性的getDependencyTree()的输出。为了清晰起见,已经删除了一些额外的细节。您看到ShoppingCart@16.items被提到两次的原因是因为它指向items(引用)和items.length属性:

{
    name: 'ShoppingCart@16.description',
    dependencies: [
        { name: 'ShoppingCart@16.items' },
        { name: 'ShoppingCart@16.items' },
        {
            name: 'ShoppingCart@16.coupons',
            dependencies: [
                {
                    name: 'CouponManager@19.validCoupons',
                    dependencies: [{ name: 'CouponManager@19.coupons' }],
                },
            ],
        },
    ],
};

还有一个方便的 API,getAtom(thing: any, property: string),用于从可观察对象和观察者中读取原子。例如,在我们之前的示例中,我们可以使用getAtom(cart, 'description')来获取description原子,而不是使用特殊符号$mobx并读取其内部结构。getAtom()是从mobx包中导出的。作为练习,找出前一个代码片段中autorun()的依赖树。您可以使用disposer[$mobx]或getAtom(disposer)来获取反应实例。类似地,还有getObserverTree()实用程序,它可以给出依赖于给定可观察对象的观察者。看看您是否可以从支持description属性的原子找到与autorun()的连接。

创建一个原子

作为 MobX 用户,您很少直接使用Atom。相反,您会依赖 MobX 公开的其他便利 API 或数据结构,如ObservableObject、ObservableArray或ObservableMap。然而,现实世界总是会出现一些情况,您可能需要深入了解一些更深层次的内容。

MobX 确实为您提供了一个方便的工厂函数来创建原子,恰当地命名为createAtom():

createAtom(name, onBecomeObservedHandler, onBecomeUnobservedHandler)

  • name(string):原子的名称,由 MobX 中的调试和跟踪工具使用

  • onBecomeObservedHandler(()=> {}):当原子首次被观察时通知的回调函数

  • onBecomeUnobservedHandler(()=> {}):当原子不再被观察时通知的回调函数

onBecomeObserved和onBecomeUnobserved是原子在响应系统中变为活动和非活动的两个时间点。这些通常用于资源管理,分别用于设置和拆除。

原子钟示例

让我们看一个使用Atom的例子,也说明了原子如何参与响应系统。我们将创建一个简单的时钟,当原子被观察时开始滴答,并在不再被观察时停止。实质上,我们这里的资源是由Atom管理的计时器(时钟):

import { createAtom, autorun } from 'mobx';

class Clock {

    constructor() {
 this.atom = createAtom(
 'Clock',
 () => {
 this.startTicking();
 },
 () => {
 this.stopTicking();
 },
 );

        this.intervalId = null;
    }

    startTicking() {
        console.log('Clock started');
        this.tick();
        this.intervalId = setInterval(() => this.tick(), 1000);
    }

    stopTicking() {
        clearInterval(this.intervalId);
        this.intervalId = null;

        console.log('Clock stopped');
    }

    tick() {
 this.atom.reportChanged();
    }

    get() {
 this.atom.reportObserved();
        return new Date();
    }
}

const clock = new Clock();

const disposer = autorun(() => {
 console.log(clock.get());
});

setTimeout(disposer, 3000);

在前面的片段中有许多有趣的细节。让我们在这里列出它们:

  • 在调用createAtom()时,我们提供了当原子被观察和不再被观察时的处理程序。当原子实际上变得被观察时,这可能看起来有点神秘。这里的秘密在于使用autorun(),它设置了一个副作用来读取原子钟的当前值。由于autorun()立即运行,调用了clock.get(),进而调用了this.atom.reportObserved()。这就是原子在响应系统中变为活动的方式。

  • 一旦原子被观察,我们就开始时钟计时器,每秒滴答一次。这发生在onBecomeObserved回调中,我们在其中调用this.startTicking()。

  • 每秒,我们调用this.atom.reportChanged(),将改变的值传播给所有观察者。在我们的例子中,我们只有一个autorun(),它重新执行并打印控制台日志。

  • 我们不必存储当前时间,因为我们在每次调用get()时返回一个新值。

  • 另一个神秘的细节是当原子变得未被观察时。这发生在我们在三秒后处理autorun()后,导致在原子上调用onBecomeUnobserved回调。在回调内部,我们停止计时器并清理资源。

由于Atoms只是依赖树的节点,我们需要一个可以存储可观察值的构造。这就是ObservableValue类的用处。将其视为带有值的Atom。MobX 在内部区分两种可观察值,ObservableValue和ComputedValue。让我们依次看看它们。

ObservableValue

ObservableValue是Atom的子类,它增加了存储可观察值的能力。它还增加了一些功能,比如提供拦截值更改和观察值的钩子。这也是ObservableValue的定义的一部分。以下是ObservableValue的简化定义:

class ObservableValue extends Atom {
    value;

    get() {
        /* ... */
 this.reportObserved();
    }

    set(value) {

        /* Pass through interceptor, which may modify the value (*newValue*) ... */

        this.value = newValue;
 this.reportChanged();
    }

    intercept(handler) {}
    observe(listener, fireImmediately) {}
}

请注意get()方法中对reportObserved()的调用以及set()方法中对reportChanged()的调用。这些是原子值被读取和写入的地方。通过调用这些方法,ObservableValue参与了响应系统。还要注意,intercept()和observe()实际上并不是响应系统的一部分。它们更像是钩入到可观察值发生的更改的事件发射器。这些事件不受事务的影响,这意味着它们不会排队等到批处理结束,而是立即触发。

ObservableValue也是 MobX 中所有高级构造的基础。这包括 Boxed Observables、Observable Objects、Observable Arrays 和 Observable Maps。这些数据结构中存储的值都是ObservableValue的实例。

包装在ObservableValue周围的最薄的包装器是箱式可观察值,您可以使用observable.box()创建它。这个 API 实际上会给您一个ObservableValue的实例。您可以使用它来调用ObservableValue的任何方法,就像在以下代码片段中看到的那样:

import {observable} from 'mobx';

const count = observable.box(0);

count.intercept(change => {
    console.log('Intercepted:', change);

    return change; // No change
 // Prints // Intercepted: {object: ObservableValue$$1, type: "update", newValue: 1} // Intercepted: {object: ObservableValue$$1, type: "update", newValue: 2} });

count.observe(change => {
    console.log('Observed:', change);
    // Prints
 // Observed: {object: ObservableValue$$1, type: "update", newValue: 1} // Observed: {object: ObservableValue$$1, type: "update", newValue: 2, oldValue: 1} });

// Increment count.set(count.get() + 1);

count.set(count.get() + 1);

ComputedValue

在可观察树中,您可以拥有的另一种可观察值是ComputedValue。这与ObservableValue在许多方面都不同。ObservableValue为基础原子提供存储并具有自己的值。MobX 提供的所有数据结构,如 Observable Object/Array/Map,都依赖于ObservableValue来存储叶级别的值。ComputedValue在某种意义上是特殊的,它没有自己的内在值。其值是从其他可观察值(包括其他计算值)计算得出的。

这在ComputedValue的定义中变得明显,它不是Atom的子类。相反,它具有与ObservableValue类似的接口,除了拦截的能力。以下是一个突出显示有趣部分的简化定义:

class ComputedValue {
    get() {
        /* ... */
 reportObserved(this);
        /* ... */
    }

    set(value) { /* rarely applicable */ }

    observe(listener, fireImmediately) {}
}

在前面的片段中需要注意的一件重要的事情是,由于ComputedValue不依赖于Atom,它对reportObserved()使用了不同的方法。这是一个更低级别的实现,它建立了可观察对象和观察者之间的链接。这也被Atom在内部使用,因此行为完全相同。此外,没有调用reportChanged(),因为ComputedValue的 setter 没有定义得很好。

正如你所看到的,ComputedValue主要是一个只读的可观察对象。虽然 MobX 提供了一种设置计算值的方法,但在大多数情况下,这并没有太多意义。计算值的 setter 必须对 getter 进行相反的计算。在大多数情况下,这几乎是不可能的。考虑一下本章前面的关于购物车description的例子。这是一个从其他可观察对象(如items和coupons)产生字符串的计算值。这个计算属性的setter会是什么样子?它必须解析字符串,并以某种方式得到items和coupons的值。这显然是不可能的。因此,一般来说,最好将ComputedValue视为只读的可观察对象。

由于计算值依赖于其他可观察对象,实际的值计算更像是一个副作用。它是依赖对象中任何一个变化的副作用。MobX 将这种计算称为派生。稍后我们将看到,派生与反应是同义词,强调了计算的副作用方面。

ComputedValue是依赖树中唯一一种既是可观察的又是观察者的节点。它的值是可观察的,并且由于它依赖于其他可观察值,它也是观察者。

ObservableValue = 仅可观察

Reaction = 仅观察者

ComputedValue = 可观察和观察者

高效的计算

ComputedValue的派生函数可能是一个昂贵的操作。因此,最好缓存这个值,并尽可能懒惰地计算。这是 MobX 的规范,并且它采用了一堆优化来使这个计算变成懒惰评估:

  • 首先,除非明确请求或者有一个依赖于这个ComputedValue的反应,否则值永远不会被计算。如预期的那样,当没有观察者时,它根本不会被计算。

  • 一旦计算出来,它的值将被缓存以供将来读取。它会一直保持这种状态,直到依赖的可观察对象发出变化信号(通过其reportChanged())并导致推导重新评估。

  • ComputedValue可以依赖于其他计算值,从而创建依赖树。除非直接子级发生了变化,否则它不会重新计算。如果依赖树深处发生了变化,它将等待直接依赖项发生变化。这种行为提高了效率,不会进行不必要的重新计算。

正如您所看到的,ComputedValue中嵌入了多个级别的优化。强烈建议利用计算属性的强大功能来表示领域逻辑及其 UI 的各种细微差别。

推导

到目前为止,我们已经看到了 MobX 的构建模块,它用Atoms、ObservableValue和ComputedValue表示可观察状态。这些都是构建应用程序的反应状态图的良好选择。但是,反应性的真正力量是通过使用推导或反应来释放的。观察对象和反应一起形成了 MobX 的阴阳。它们彼此依赖,以推动反应系统。

推导或反应是跟踪发生的地方。它跟踪在推导或反应的上下文中使用的所有可观察对象。MobX 将监听它们的reportObserved()并将它们添加到被跟踪的可观察对象列表(ObservableValue或ComputedValue)。每当可观察对象调用reportChanged()(当它被改变时会发生),MobX 将安排运行所有连接的观察者。

我们将交替使用推导和反应。两者都旨在传达使用可观察对象产生新值(推导)或副作用(反应)的副作用执行。这两种类型之间的跟踪行为是共同的,因此我们将它们视为同义词使用。

推导的周期

MobX 使用globalState来保持对当前执行的推导或反应的引用。每当反应运行时,所有触发其reportObserved()的可观察对象都将被标记为该反应的一部分。事实上,这种关系是双向的。一个可观察对象跟踪其所有观察者(反应),而一个反应跟踪它当前正在观察的所有可观察对象。当前执行的反应将被添加为每个可观察对象的观察者。如果观察者已经被添加,它将被忽略。

当您设置观察者时,它们都会返回一个清理函数。我们已经在autorun(),reaction()或when()的返回值中看到了这一点,它们都是清理函数。调用此清理函数将从连接的可观察对象中删除观察者:

在执行反应时,只有现有的可观察对象才会被考虑进行跟踪。然而,在同一反应的不同运行中,可能会引用一些新的可观察对象。当由于某些分支逻辑而原本被跳过的代码段执行时,这是可能的。由于在跟踪反应时可能会发现新的可观察对象,MobX 会对可观察对象进行检查。新的可观察对象将被添加到可观察对象列表中,而不再使用的可观察对象将被移除。可观察对象的移除不会立即发生;它们将在当前反应完成后排队等待移除。

在可观察对象和反应之间的相互作用中,操作似乎是非常缺失的。嗯,并非完全如此。它们确实有一定的作用要发挥。正如本书中多次提到的,操作是改变可观察对象的推荐方式。操作创建一个事务边界,并确保所有更改通知仅在完成后触发。这些操作也可以嵌套,导致嵌套事务。只有当最顶层的操作(或事务)完成时,通知才会被触发。这也意味着在事务(嵌套或非嵌套)进行时,反应都不会运行。MobX 将此事务边界视为批处理,并在内部跟踪嵌套。在批处理期间,所有反应将被排队并在最顶层批处理结束时执行。

当排队的反应执行时,循环再次开始。它将跟踪可观察对象,将它们与执行的派生链接起来,添加任何新发现的可观察对象,并在批处理期间排队任何发现的反应。如果没有更多的批处理,MobX 将认为自己是稳定的,并回到等待任何可观察变化的状态。

关于反应的一个有趣的事情是它们可以重新触发自己。在一个反应中,你可以读取一个可观察对象,并触发一个改变同一个可观察对象的动作。这可能发生在同一段代码中,也可能间接地通过从反应中调用的某个函数。唯一的要求是它不应该导致无限循环。MobX 期望反应尽快变得稳定。

如果由于某种原因,迭代超过 100 次并且没有稳定性,MobX 将以异常退出。

反应在 100 次迭代后没有收敛到稳定状态。可能是反应函数中存在循环:Reaction[Reaction@14]

如果没有 100 次迭代的上限,它会在运行时导致堆栈溢出,使得更难追踪其原因。MobX 通过100 次迭代的限制来保护你免受这种困境的影响。请注意,它并不禁止你使用循环依赖,而是帮助识别导致不稳定(无限循环)的代码。

即使在100 次反应之后仍然不稳定的简单片段如下所示。这个反应观察counter可观察对象,并通过调用spinLoop()动作来修改它。这导致反应一遍又一遍地运行,直到在100 次迭代后放弃:

class Infinite {
    @observable counter = 0;

    constructor() {
        reaction(
 () => this.counter,
            counterValue => {
                console.log(`Counter is ${counterValue}`);
 this.spinLoop();
            },
        );
    }

    @action
  spinLoop() {
        this.counter = this.counter + 1;
    }
}

new Infinite().spinLoop();

/* Console log:
*Reaction doesn't converge to a stable state after 100 iterations. Probably there is a cycle in the reactive function: Reaction[Reaction@14]* */

正如你所知,执行派生或反应对于建立可观察对象和观察者之间的联系至关重要。没有反应,反应性系统中就没有生命。它只会是一组可观察对象。你仍然可以触发动作和改变它们,但它仍然会非常静态和非反应性。反应(派生)完成了可观察对象-动作-反应的三元组,并为这个反应性系统注入了生命。

最终,反应是从你的状态中提取值并启动整个反应过程的关键!

异常处理

处理错误被认为是 MobX 反应的一个重要部分。事实上,它为autorun()、reaction()和when()提供了一个提供错误处理程序(onError)的选项,在computed()的情况下,每当读取计算值时都会将错误抛回给你。在这些情况下,MobX 会像预期的那样继续工作。

在内部,MobX 在执行 reactions 和 derivations 时加入了额外的try-catch块。它会捕获这些块内部抛出的错误,并通过onError处理程序或在读取计算值时将它们传播回给你。这种行为确保你可以继续运行你的 reactions,并在onError处理程序内采取任何恢复措施。

如果对于一个 reaction 没有指定onError处理程序,MobX 也有一个全局的onReactionError()处理程序,它将被调用来处理 reaction 中抛出的任何异常。你可以注册一个监听器来处理这些全局 reaction 错误,比如错误监控、报告等:

onReactionError(handler-function: (error, reaction) => { })

handler-function:一个接受错误和 reaction 实例作为参数的函数。

在调用全局onReactionError处理程序之前,MobX 首先检查失败的 reaction 是否有一个onError处理程序。只有当不存在时,才会调用全局处理程序。

现在,如果出于某种原因,你不希望 MobX 捕获异常并在全局onReactionError处理程序上报告它,你有一个出路。通过配置 MobX 为configure({ disableErrorBoundaries: true }),你将会在失败点得到一个常规异常。现在你需要通过try-catch块在 reaction 内部直接处理它。

在正常情况下不应该使用configure({ disableErrorBoundaries: true }),因为不处理异常可能会破坏 MobX 的内部状态。然而,打开这个配置可以帮助你调试,因为它会使异常未被捕获。现在你可以在引起异常的确切语句上暂停调试器。

API 层

这是 MobX 面向消费者的最外层层,建立在前面提到的基础之上。在这一层中,突出的 API 包括本书中遍布的observable()、observable.box()、computed()、extendObservable()、action()、reaction()、autorun()、when()等。当然,我们还有装饰器,比如observable.ref、observable.deep、observable.shallow、action.bound、computed.struct等。

核心数据结构,如ObservableObject、ObservableArray和ObservableMap依赖于ObservableValue来存储它们的所有值。

对于ObservableObject...:

  • 键值对的值由ObservableValue支持。

  • 每个计算属性都由ComputedValue支持。

  • ObservableObject的keys()方法也由Atom支持。这是必要的,因为您可能会在其中一个反应中对keys()进行迭代。当添加或删除键时,您希望您的反应再次执行。keys()的这个原子会对添加和删除触发reportChanged(),并确保连接的反应被重新执行。

对于ObservableArray...:

  • 每个索引值都由ObservableValue支持。

  • length属性明确由Atom支持。请注意,ObservableArray具有与 JavaScript 数组相同的接口。在MobX 4中,它是一个类似数组的数据结构,在MobX 5中成为了真正的 JS 数组(由 ES6 的Proxy支持)。对length的读取和写入将导致在原子上调用reportObserved()和reportChanged()。实际上,当使用map、reduce、filter等方法时,将使用支持Atom来触发reportObserved()。对于任何类似splice、push、pop、shift等的变异方法,将触发reportChanged()。这确保了连接的反应按预期触发。

对于ObservableMap...:

  • 键-值对的值由ObservableValue支持。

  • 就像ObservableObject一样,它也为keys()方法维护了一个Atom的实例。任何添加或删除键的操作都会通过原子上的reportChanged()通知。调用keys()方法本身将在原子上触发reportObserved()。

MobX 中的集合,包括对象、数组和映射,本质上是可观察盒子(ObservableValue)的集合。它们可以组织为列表或映射,或者组合在一起创建复杂的结构。

所有这些数据结构还公开了intercept()和observe()方法,允许对值进行细粒度拦截和观察。通过构建在Atom、ObservableValue和derivations的基础上,MobX 为您提供了一个强大的 API 工具箱,用于在应用程序中构建复杂的状态管理解决方案。

透明的函数式响应式编程

MobX 被认为是透明的函数式响应式编程(TFRP)系统。是的,在那一行有太多的形容词!让我们逐字逐句地分解它。

它是透明的...

将可观察对象连接到观察者,使观察者能够对可观察对象的变化做出反应。这是我们对 MobX 的基本期望,我们建立这些连接的方式非常直观。除了使用装饰器和在观察者内部取消引用可观察对象之外,没有明确的连接。由于连接的开销很低,MobX 变得非常声明式,您可以表达您的意图,而不必担心机制。在可观察对象和观察者之间建立的自动连接使反应系统能够自主运行。这使 MobX 成为一个透明的系统,因为连接可观察对象和观察者的工作基本上被取消了。在反应中使用可观察对象就足以连接这两者。

它是反应性的...

这种反应性也非常细粒度。可观察对象的依赖树可以尽可能简单,也可以同样深入。有趣的是,您永远不必担心连接的复杂性或效率。MobX 深知您的依赖关系,并通过仅在需要时做出反应来确保效率。没有轮询或过多的事件被触发,因为依赖关系不断变化。因此,MobX 也是一个非常反应灵敏的系统。

它是功能性的...

正如我们所知,功能编程是利用函数的力量来执行数据流转换。通过使用各种功能操作符,如 map、reduce、filter、compose 等,我们可以对输入数据进行转换并产生输出值。在 MobX 的情况下,关键在于输入数据是可观察的,是一个随时间变化的值。MobX 结合了反应系统的特性,并确保在输入数据(可观察对象)发生变化时自动应用功能-转换。正如前面讨论的那样,它以一种透明的方式通过建立可观察对象和反应之间的隐式连接来实现这一点。

这些特质的结合使 MobX 成为一个 TFRP 系统。

从作者的角度来看,TFRP 的首字母缩略词的起源来自以下文章:github.com/meteor/docs/blob/version-NEXT/long-form/tracker-manual.md。

价值导向编程

MobX 也涉及价值导向编程(VOP),在这里你关注值的变化、它的依赖关系以及在响应系统中的传播。通过 VOP,你关注的是连接的值是什么?而不是值是如何连接的?它的对应物是事件导向编程(EOP),在这里你关注的是一系列事件来通知变化。事件只报告已发生的事情,没有依赖关系的概念。与价值导向编程相比,它在概念上处于较低级别。

VOP 依赖事件在内部执行其工作。当一个值发生变化时,会触发事件来通知变化。这些事件的处理程序将把值传播给所有监听器(观察者)的可观察值。这通常会导致调用反应/派生。因此,反应和派生,即值变化的副作用,处于值传播事件的尾端。

以 VOP 的方式思考会提高抽象级别,使你更接近正在处理的领域。与其担心值传播的机制,你只需专注于通过可观察值、计算属性和观察者(反应/派生)建立连接。正如我们所知,这就是 MobX 的三位一体:可观察值-动作-反应。这种思维方式在本质上非常声明式:值的变化是什么而不是如何。当你更深入地沉浸在这种思维模式中时,许多状态管理中的场景会变得更加可行。你会对这种范式提供的简单、强大和高效感到惊讶。

如果你确实需要深入了解事件层,MobX 有intercept()和observe()的 API。它们允许你钩入当可观察值添加、更新或删除时触发的事件。还有来自mobx-utils npm 包的fromStream()和toStream()的 API,它们提供了与 RxJS 兼容的事件流。这些事件不参与 MobX 事务(批处理),永远不会排队,总是立即触发。

在消费者代码中很少使用事件 API;它们主要被工具和实用函数(如spy()、trace()等)使用,以便深入了解 MobX 的事件层。

总结

通过这个深入了解 MobX 的窥视,您可以欣赏到 TFRP 系统的强大之处,它暴露了一个令人惊讶地简单的 API。从 Atoms 开始的功能层,由 ObservableValue 包装,具有 API 和更高级的数据结构,为您的领域建模提供了全面的解决方案。

在内部,MobX 管理着可观察对象和观察者(反应/推导)之间的所有连接。它会自动完成,几乎不会干扰您通常的编程风格。作为开发者,您编写的代码会感觉很自然,而 MobX 则消除了管理响应式连接的复杂性。

MobX 是一个经过各种领域的实战考验的开源项目,接受来自世界各地开发者的贡献,并在多年来不断成熟。通过这次对 MobX 的内部了解,我们希望能够降低对这个强大的状态管理库的贡献障碍。

posted @ 2024-05-16 14:49  绝不原创的飞龙  阅读(307)  评论(0)    收藏  举报