Flux-架构指南-全-

Flux 架构指南(全)

原文:zh.annas-archive.org/md5/178533a8bdc2a40c4892bffde0868094

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我喜欢 Backbone.js。这是一个功能强大的小库,用很少的东西做了很多。它也是中立的——有无数种方法可以完成同一件事。最后这一点给许多 Backbone.js 程序员带来了头痛。按照我们自己的方式实现事物的自由是很好的,直到我们开始犯那些不可避免的错误。

当我最初开始使用 Flux 时,我根本看不到这样的架构如何帮助一个普通的 Backbone.js 程序员。最终,我发现了两点。首先,Flux 在关键的地方是中立的——实现的具体细节。其次,Flux 在精神上非常类似于 Backbone,它以最小移动部件做一件事做得很好的理念。

当我开始尝试使用 Flux 时,我意识到 Flux 提供了实现可扩展性的缺失架构视角。在 Backbone.js 和其他相关技术中,当出现问题的时候,它们就会分崩离析。实际上,这些错误可能如此难以解决,以至于它们从未真正被修复——整个系统都留下了权宜之计的痕迹。

我决定写这本书,希望来自各个领域的 JavaScript 程序员都能体验到我在与 Facebook 这项美妙技术合作时所获得的同样程度的启迪。

本书涵盖的内容

第一章, 什么是 Flux?,概述了 Flux 是什么以及为什么它被创建。

第二章, Flux 原则,讨论了 Flux 的核心概念以及构建 Flux 架构的必要知识。

第三章, 构建骨架架构,介绍了在实现应用功能之前构建骨架架构所涉及的步骤。

第四章, 创建动作,展示了如何使用动作创建函数将新数据输入到系统中,同时描述了刚刚发生的事情。

第五章, 异步动作,通过异步动作创建函数的示例,说明了它们如何在 Flux 架构中适用。

第六章, 更改 Flux 存储状态,提供了许多详细解释和示例,说明了 Flux 存储是如何工作的。

第七章, 查看信息,提供了许多详细解释和示例,说明了 Flux 视图是如何工作的。

第八章, 信息生命周期,讨论了在 Flux 架构中信息是如何进入系统以及它最终是如何退出系统的。

第九章, 不变存储,展示了不变性是如何成为软件架构(如 Flux)中一个关键架构特性的,在这些架构中数据流向一个方向。

第十章,实现分发器,介绍了分发器组件的实现,而不是使用 Facebook 参考实现。

第十一章,替代视图组件,展示了如何在 Flux 架构中使用除 React 之外的视图技术。

第十二章,利用 Flux 库,概述了两个流行的 Flux 库——Alt.js 和 Redux。

第十三章,测试和性能,从 Flux 架构的上下文中讨论测试组件,并讨论了架构的性能测试。

第十四章,流和软件开发生命周期,讨论了流对整个软件栈的影响以及如何打包流功能。

您需要这本书的什么

  • 任何网络浏览器

  • NodeJS >= 4.0

  • 代码编辑器

这本书面向的对象

您是否在尝试使用 React,但发现难以理解 Flux?也许,您对大规模的 MV*意大利面代码感到厌倦?您发现自己想知道什么是 Flux?!

Flux 架构将引导您了解您需要了解的 Flux 模式和设计,并构建依赖 Flux 架构的强大 Web 应用程序。

您不需要知道 Flux 是什么或它是如何工作的就可以阅读这本书。不需要了解 Flux 的合作伙伴技术 ReactJS,但建议您具备良好的 JavaScript 实际操作知识。

习惯用法

在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“当HOME_LOAD动作被分发时,我们改变存储的状态。”

代码块设置如下:

// This object is used by several action
// creator functions as part of the action
// payload.
export constPAYLOAD_SORT = {
  direction: 'asc'
};

注意

警告或重要注意事项以如下方式显示。

小贴士

小技巧和窍门如下所示。

读者反馈

读者反馈总是受欢迎。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有一些东西可以帮助您从您的购买中获得最大收益。

下载示例代码

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

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

您也可以通过点击 Packt Publishing 网站书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。

一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现了错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。

海盗行为

互联网上版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送链接至 <copyright@packtpub.com> 的方式与我们联系,以提供疑似盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以联系我们的邮箱 <questions@packtpub.com>,我们将尽力解决问题。

第一章. 什么是 Flux?

Flux 应该是一种构建复杂用户界面的新方法,这种方法可以很好地扩展。至少,这是关于 Flux 的一般信息,如果你只是浏览互联网文献。但是,我们如何定义这种“构建用户界面的新方法”呢?是什么让它优于其他更成熟的前端架构?

本章的目的是透过销售要点,明确说明 Flux 是什么,以及它不是什么,通过观察 Flux 提供的模式来实现。由于 Flux 在传统意义上不是一个软件包,我们将探讨我们试图用 Flux 解决的问题的概念性问题。

最后,我们将通过介绍任何 Flux 架构中找到的核心组件来结束本章,并立即安装 Flux 的npm包,编写一个 hello world 的 Flux 应用程序。让我们开始吧。

Flux 是一套模式

我们可能首先应该澄清一个严峻的现实——Flux 不是一个软件包。它是一套我们应遵循的架构模式。虽然这可能会让一些人感到失望,但不要绝望——不实现另一个框架有很好的理由。在本书的整个过程中,我们将看到 Flux 作为一套模式而不是既定实现存在的价值。现在,我们将探讨 Flux 实施的一些高级架构模式。

数据输入点

在使用传统方法构建前端架构时,我们并没有太多考虑数据如何进入系统。我们可能会考虑数据输入点的概念,但不会详细考虑。例如,在MVC模型-视图-控制器)架构中,控制器应该控制数据的流动。在大多数情况下,它确实做到了这一点。另一方面,控制器实际上只是控制数据到来之后发生的事情。控制器最初是如何获得数据的呢?考虑以下插图:

数据输入点

初看这幅图,似乎没有什么问题。数据流,由箭头表示,很容易跟随。但是数据从哪里来呢?例如,视图可以创建新数据并将其传递给控制器,作为对用户事件的响应。控制器可以创建新数据并将其传递给另一个控制器,这取决于我们控制器层次结构的组成。那么,关于这个特定的控制器——它能否自己创建数据然后使用它呢?

在这样的图表中,这些问题并没有多少价值。但是,如果我们试图扩展一个架构以包含数百个这样的组件,数据进入系统的点就变得非常重要。由于 Flux 用于构建可扩展的架构,它将数据输入点视为一个重要的架构模式。

状态管理

状态是我们在前端开发中需要应对的现实之一。不幸的是,我们不能因为两个原因而将整个应用程序完全由没有副作用的纯函数组成。首先,我们的代码需要以某种方式与 DOM 接口交互。这就是用户看到 UI 变化的方式。其次,我们并不将所有应用数据都存储在 DOM 中(至少我们不应该这样做)。随着时间的推移和用户与应用程序的交互,这些数据将发生变化。

在 Web 应用程序中管理状态没有一刀切的方法,但有一些方法可以限制可能发生的状态改变的数量,并强制规定它们如何发生。例如,纯函数不会改变任何东西的状态,它们只能创建新的数据。以下是一个这样的例子:

管理状态

如您所见,纯函数没有副作用,因为调用它们不会导致任何数据状态改变。那么,如果状态改变是不可避免的,为什么这是一个期望的特性呢?其理念是强制规定状态改变发生的位置。例如,我们可能只允许某些类型的组件改变应用数据的状态。这样,我们可以排除几个可能成为状态改变原因的来源。

Flux 非常注重控制状态改变发生的位置。在章节的后面部分,我们将看到 Flux 存储如何管理状态改变。Flux 管理状态的重要之处在于它是在架构层处理的。与规定哪些组件类型允许修改应用数据的一套规则的方法相比——事情会变得混乱。在 Flux 中,猜测状态改变发生的位置的空间更小。

保持更新同步

与数据输入点相辅相成的是更新同步性的概念。也就是说,除了管理状态改变起源的位置外,我们还需要管理这些改变相对于其他事物的顺序。如果数据输入点是我们的数据的什么,那么在系统中同步应用所有数据的状态改变就是何时

让我们思考一下这为什么很重要。在一个数据异步更新的系统中,我们必须考虑到竞争条件。竞争条件可能是有问题的,因为一块数据可能依赖于另一块,如果它们以错误的顺序更新,我们就会看到从组件到组件的级联问题。看看这个图表,它说明了这个问题:

保持更新同步

当某事是异步的,我们就无法控制它何时改变状态。因此,我们所能做的就是等待异步更新发生,然后遍历我们的数据,确保所有数据依赖都得到满足。如果没有自动处理这些依赖的工具,我们最终会编写大量的状态检查代码。

Flux 通过确保跨我们的数据存储发生的更新是同步的来解决此问题。这意味着前面图中展示的场景是不可能的。以下是 Flux 如何处理 JavaScript 应用程序中典型的数据同步问题的更好可视化:

保持更新同步

信息架构

很容易忘记我们在信息技术领域工作,我们应该围绕信息构建技术。然而,近年来,我们似乎走向了另一个方向,即在考虑信息之前被迫考虑实现。很多时候,我们应用程序使用的源数据中暴露的数据并不包含用户所需的内容。这取决于我们的 JavaScript 将原始数据转换为用户可消费的内容。这就是我们的信息架构。

这是否意味着 Flux 是用来设计信息架构而不是软件架构的?这根本不是事实。实际上,Flux 组件被实现为真正的软件组件,它们执行实际的计算。诀窍在于 Flux 模式使我们能够将信息架构视为一等的设计考虑因素。我们不必筛选各种组件及其实现问题,而可以确保我们向用户提供正确的信息。

当我们的信息架构成形后,我们的应用程序的更大架构也随之而来,作为我们试图向用户传达的信息的自然扩展。从数据中产生信息是困难的部分。我们必须将许多数据源提炼成不仅包含信息,而且对用户有价值的信息。做错这一点对任何项目都是一个巨大的风险。当我们做对的时候,我们就可以继续处理特定的应用程序组件,比如按钮小部件的状态等等。

Flux 架构将数据转换限制在其存储中。存储是一个信息工厂——原始数据进入,新的信息出来。存储控制数据如何进入系统,状态变化的同步性,以及它们定义状态变化的方式。当我们随着本书的进展更深入地探讨存储时,我们将看到它们是如何成为我们信息架构的支柱的。

Flux 不是另一个框架

现在我们已经探索了一些 Flux 的高级模式,是时候重新审视这个问题了:Flux 究竟是什么?嗯,它只是一套我们可以应用于前端 JavaScript 应用程序的架构模式。Flux 之所以能够很好地扩展,是因为它将信息放在首位。信息是软件中最难扩展的方面;Flux 直面信息架构问题。

那么,为什么 Flux 模式不以框架的形式实现?这样,Flux 将为每个人提供一个标准实现;并且像任何其他大型开源项目一样,随着时间的推移,随着项目的成熟,代码会得到改进。

主要问题是 Flux 在架构层面运作。它用于解决防止特定应用程序扩展以满足用户需求的信息问题。如果 Facebook 决定将 Flux 作为另一个 JavaScript 框架发布,它可能会遇到其他框架中普遍存在的相同类型的实现问题。例如,如果框架中的某些组件没有以最适合我们正在工作的项目的方式进行实现,那么在不破坏框架的情况下实现更好的替代方案并不容易。

Flux 的优点在于,Facebook 决定将实现选项留给了我们。他们确实提供了一些 Flux 组件实现,但这些是参考实现。它们是功能性的,但目的是它们是我们理解诸如派发器等事物预期如何工作的起点。我们可以自由地实现我们看到的相同的 Flux 架构模式。

Flux 不是一个框架。这意味着我们必须自己实现一切吗?不,我们不必这样做。实际上,开发者正在实现 Flux 库,并将它们作为开源项目发布。一些 Flux 库更紧密地遵循 Flux 模式,而其他则不是。这些实现是有偏见的,如果它们适合我们正在构建的内容,使用它们是没有问题的。Flux 模式旨在用 JavaScript 开发解决通用概念性问题,所以在深入 Flux 实现讨论之前,你会了解它们是什么。

Flux 解决概念性问题

如果 Flux 仅仅是一系列架构模式而不是一个软件框架,那么它解决了哪些问题?在本节中,我们将从架构的角度探讨 Flux 解决的一些概念性问题。这包括单向数据流、可追溯性、一致性、组件分层和松散耦合的组件。这些概念性问题中的每一个都对我们软件的某些方面构成了一定的风险,特别是其可扩展性。Flux 帮助我们提前解决这些问题,在我们构建软件的过程中。

数据流方向

我们正在创建一个信息架构来支持最终将位于这个架构之上的功能丰富的应用程序。数据流入系统,最终会达到一个端点,终止数据流。在入口点和终止点之间发生的事情决定了 Flux 架构中的数据流。这在此处得到了说明:

数据流方向

数据流是一个有用的抽象,因为它很容易将数据视为它进入系统并在一个点到另一个点之间移动。最终,流动会停止。但在它停止之前,沿途会发生几个副作用。我们关注的是前面图中的中间块,因为我们不知道数据流是如何到达终点的。

假设我们的架构对数据流没有任何限制。任何组件都可以将数据传递给任何其他组件,无论该组件位于何处。让我们尝试可视化这个设置:

数据流方向

如您所见,我们的系统对我们的数据有明确的入口和出口点。这是好事,因为它意味着我们可以自信地说数据流通过我们的系统。这幅图的问题在于数据流在系统组件之间的流动方式。没有方向,或者说,它是多向的。这不是好事。

Flux 是一种单向数据流架构。这意味着前面的组件布局是不可能的。问题是——这有什么关系?有时,能够以任何方向传递数据可能看起来很方便,也就是说,从任何组件到任何其他组件。这本身并不是问题——仅仅传递数据本身并不会破坏我们的架构。然而,当数据以多个方向在我们的系统中移动时,组件之间失去同步的机会就更多了。这仅仅意味着如果数据不总是朝同一方向移动,就总有可能出现排序错误。

Flux 强制数据流的方向,从而消除了组件以破坏系统顺序的方式自行更新的可能性。无论什么数据刚刚进入系统,它总是会按照与其他任何数据相同的顺序通过系统,如下所示:

数据流方向

可预测的根本原因

当数据以一个方向进入我们的系统并通过我们的组件流动时,我们可以更容易地将任何影响追溯到其根本原因。相比之下,当一个组件向任何其他组件发送数据,而这些组件位于任何架构层时,确定数据如何到达目的地就困难得多。这有什么关系?调试器足够复杂,我们可以在运行时轻松地穿越任何复杂度。这个概念的问题在于它假设我们只需要追踪代码中的行为以进行调试。

Flux 架构具有固有的可预测数据流。这对于许多设计活动来说很重要,而不仅仅是调试。在 Flux 应用程序上工作的程序员将开始直观地感觉到将要发生的事情。预期是关键,因为它让我们在遇到它们之前避免设计死胡同。当原因和结果容易分辨时,我们可以花更多的时间专注于构建应用程序功能——客户关心的事情。

一致的提醒

在 Flux 架构中,我们从组件到组件传递数据的方向应该是一致的。在一致性的方面,我们还需要考虑用于在系统中移动数据的机制。

例如,发布/订阅(pub/sub)是用于组件间通信的一种流行机制。这种方法的优点在于,我们的组件可以相互通信,同时我们还能保持一定程度的解耦。实际上,这在前端开发中相当常见,因为组件通信主要是由用户事件驱动的。这些事件可以被视为一次性触发。任何其他想要以某种方式对这些事件做出响应的组件,都需要自行订阅特定的事件。

虽然 pub/sub 确实有一些不错的特性,但它也带来了架构挑战,特别是扩展复杂性。例如,假设我们刚刚为新的功能添加了几个新组件。那么,这些组件相对于现有组件接收更新消息的顺序是什么?它们是在所有现有组件之后被通知的吗?它们应该排在第一位吗?这提出了数据依赖性扩展问题。

Pub/Sub(发布/订阅)的另一个挑战是发布的事件通常非常细粒度,以至于我们可能想要订阅这些通知,然后在之后取消订阅。这导致了一致性挑战,因为当系统中存在大量组件时,尝试编码生命周期变化是困难的,并且会错过事件的机会。

Flux 的想法是通过维护一个静态的组件间消息基础设施来规避问题,该基础设施向每个组件发布通知。换句话说,程序员不能选择他们的组件将订阅哪些事件。相反,他们必须弄清楚哪些被发送给他们的事件是相关的,忽略其余的。以下是 Flux 向组件分发事件的可视化:

一致的提醒

Flux 调度器将事件发送到每个组件;这是无法避免的。我们不是试图调整难以扩展的消息基础设施,而是在组件内部实现逻辑以确定消息是否感兴趣。同样,我们可以在组件内部声明对其他组件的依赖,这有助于影响消息的顺序。我们将在后面的章节中更详细地介绍这一点。

简单的架构层次

层次结构可以是一种组织组件架构的绝佳方式。一方面,它是一种显而易见的方式来对构成我们应用程序的各种组件进行分类。另一方面,层次结构充当了限制通信路径的手段。这一点对于 Flux 架构尤其相关,因为数据流向一个方向是很重要的。相对于单个组件,对层次结构应用约束要容易得多。以下是一个 Flux 层次的示例:

简单的架构层次

注意

此图并不是为了捕捉 Flux 架构的整个数据流,而是展示数据如何在主要三个层次之间流动。它也没有提供关于层次结构中内容的任何细节。请放心,下一节将介绍 Flux 组件的类型及其在层次结构之间的通信,这是本书的重点。

如您所见,数据流从一个层次流向下一个层次,方向一致。Flux 只有几个层次,并且随着我们的应用程序在组件数量方面进行扩展,层次数量保持不变。这为向已经很大的应用程序添加新功能涉及的复杂性设置了上限。除了限制层次数量和数据流向之外,Flux 架构对哪些层次可以相互通信也非常严格。

例如,动作层可以与视图层通信,而我们仍然在向一个方向移动。我们仍然会有 Flux 期望的层次结构。然而,跳过这样的层次是禁止的。通过确保层次结构只与直接下方的层次结构通信,我们可以排除因顺序不当而引入的 bug。

松散耦合的渲染

Flux 设计者做出的一个引人注目的决定是,Flux 架构并不关心 UI 元素是如何渲染的。也就是说,视图层与架构的其他部分松散耦合。这有很好的理由。

Flux 首先是一个信息架构,其次才是软件架构。我们从这个开始,逐步过渡到后者。视图技术的挑战在于它可能会对整个架构产生负面影响。例如,一个视图有与 DOM 交互的特定方式。如果我们已经决定了这项技术,我们最终会让它影响我们的信息架构结构。这并不一定是坏事,但它可能导致我们在最终向用户展示的信息上做出妥协。

我们真正应该考虑的是信息本身以及这些信息随时间的变化。涉及哪些动作导致了这些变化?一条数据如何依赖于另一条数据?Flux 自然地摆脱了当时浏览器技术的限制,以便我们首先关注信息。随着它演变成一个软件产品,很容易将视图插入到我们的信息架构中。

流量组件

在本节中,我们将开始探索 Flux 的概念。这些概念是构建 Flux 架构的基本成分。虽然没有详细说明这些组件应该如何实现,但它们仍然为我们实现奠定了基础。这是对我们将在这本书中实现的所有组件的高级介绍。

动作

动作是系统的动词。实际上,如果我们直接从句子中推导出动作的名称,这很有帮助。这些句子通常是功能声明——我们希望应用程序执行的操作。以下是一些例子:

  • 获取会话

  • 导航到设置页面

  • 过滤用户列表

  • 切换详细信息的可见性

这些是应用程序的简单功能,当我们将其作为 Flux 架构的一部分实现时,动作是起点。这些可读的动作声明通常需要在系统其他地方实现其他新组件,但第一步始终是动作。

那么,Flux 动作究竟是什么呢?在最简单的情况下,动作不过是一个字符串——一个帮助识别动作目的的名称。更典型的是,动作由名称负载组成。现在不必担心负载的具体细节——就动作而言,它们只是被传递到系统中的不透明数据块。换句话说,动作就像邮件包裹。我们的 Flux 系统的入口点不关心包裹的内部结构,只关心它们能否到达目的地。以下是一个动作进入 Flux 系统的示意图:

动作

这张图可能会给人一种动作是 Flux 外部的感觉,但实际上它们是系统的一个组成部分。这种观点有价值的原因在于它迫使我们把动作视为将新数据输入系统的唯一手段。

注意

黄金 Flux 规则:如果不是行动,就无法发生。

调度器

在 Flux 架构中,调度器负责将行动分发到存储组件(我们将在下一节讨论存储)。调度器实际上有点像经纪人——如果行动想要将新数据传递给存储,它们必须与经纪人交谈,以便找出最佳传递方式。想想像 RabbitMQ 这样的系统中的消息经纪人。它是所有消息在真正传递之前发送到的中心枢纽。以下是描述 Flux 调度器接收行动并将它们分发给存储的图示:

调度器

本章的早期部分——“简单的架构层”——没有为调度器设置一个明确的层。这是故意的。在 Flux 应用中,只有一个调度器。它更像是伪层而不是明确层。我们知道调度器在那里,但它不是这个抽象级别的必需品。我们在架构层面关心的是确保当某个特定的行动被调度时,我们知道它将到达系统中的每个存储。

话虽如此,调度器在 Flux 的工作方式中起着至关重要的作用。它是注册存储回调函数的地方,也是处理数据依赖的地方。存储告诉调度器它依赖的其他存储,而调度器负责确保这些依赖得到适当处理。

注意

黄金 Flux 规则:调度器是数据依赖的最终仲裁者。

存储

在 Flux 应用中,存储是保持状态的地方。通常,这意味着从 API 发送到前端的应用数据。然而,Flux 存储更进一步,明确地模拟整个应用的状态。如果这听起来很困惑或者像是一个普遍的坏主意,不用担心——随着我们进入后续章节,我们会澄清这一点。现在,只需知道存储是重要状态可以找到的地方。其他 Flux 组件没有状态——它们在代码级别有隐式状态,但我们对此不感兴趣,从架构的角度来看。

行动是系统中新数据进入的传递机制。术语“新数据”并不意味着我们只是将其附加到存储中的某个集合。所有进入系统的数据都是新的,因为在意义上它还没有被作为行动发出——实际上它可能导致存储状态改变。让我们看看一个导致存储状态改变的行动的可视化:

存储

存储如何改变状态的关键方面是没有任何外部逻辑决定状态变化应该发生。只有存储,并且只有存储,才会做出这个决定并执行状态转换。这一切都紧密封装在存储中。这意味着当我们需要推理特定信息时,我们不需要再往其他地方看,只需查看存储即可。他们是自己的老板——他们是自雇的。

注意

黄金 Flux 规则:状态存储在存储中,只有存储本身可以改变这种状态。

视图

在本节中,我们将要查看的最后一个 Flux 组件是视图,从技术上讲,它甚至不是 Flux 的一部分。同时,视图显然是我们应用程序的一个关键部分。视图几乎被普遍理解为负责向用户显示数据的架构部分——它是数据流通过我们的信息架构的最后一站。例如,在 MVC 架构中,视图接收模型数据并显示它。从这个意义上说,基于 Flux 的应用程序中的视图与 MVC 视图并没有太大的不同。它们之间的显著差异在于处理事件的方式。让我们看一下以下图表:

视图

在这里,我们可以看到 Flux 视图与典型 MVC 架构中找到的视图组件的对比责任。这两种视图类型接收到的数据类型相似——用于渲染组件的应用数据以及事件(通常是用户输入)。这两种类型视图之间的不同之处在于它们流出的内容。

典型的视图在事件处理函数如何与其他组件通信方面并没有任何限制。例如,在用户点击按钮的响应中,视图可以直接在控制器上调用行为,改变模型的状态,或者它可能查询另一个视图的状态。另一方面,Flux 视图只能分派新的动作。这保持了我们的系统单入口的完整性和一致性,与其他想要改变我们存储数据状态的机制相一致。换句话说,API 响应更新状态的方式与用户点击按钮的方式完全相同。

由于在 Flux 架构中,视图应该限制数据流出的方式(除了 DOM 更新),因此人们可能会认为视图应该是一个实际的 Flux 组件。这在某种程度上是有道理的,因为使动作成为视图的唯一可能选项。然而,也没有理由我们现在不能强制执行这一点,好处是 Flux 可以完全专注于创建信息架构。

然而,请记住,Flux 仍然处于起步阶段。随着越来越多的人开始采用 Flux,无疑会有外部影响。也许 Flux 将来会对视图有所评论。在此之前,视图存在于 Flux 之外,但受到 Flux 单向特性的限制。

注意

黄金 Flux 规则:数据从视图中流出的唯一方式是通过分发一个动作。

安装 Flux 包

我们将通过编写一些代码来结束第一章,因为每个人都需要一个基础的好莱坞应用。我们还将完成一些样板代码的设置任务,因为我们将在整个书中使用类似的设置。

注意

我们将跳过 Node + NPM 的安装说明,因为互联网上已经有很多详细的覆盖。我们将假设从现在开始 Node 已经安装并准备就绪。

我们首先需要安装的 NPM 包是 Webpack。这是一个适用于现代 JavaScript 应用(包括基于 Flux 的应用)的高级模块打包器。我们希望全局安装此包,以便webpack命令可以在我们的系统上安装:

npm install webpack -g

在 Webpack 配置就绪的情况下,我们可以构建本书附带的所有代码示例。然而,我们的项目需要安装几个本地的 NPM 包,这些包可以按照以下步骤安装:

npm install flux babel-core babel-loader babel-preset-es2015 --save-dev

--save-dev选项将这些开发依赖项添加到我们的文件中,如果存在的话。这只是开始——手动安装这些包来运行本书中的代码示例并不是必需的。你下载的示例已经包含了package.json文件,因此要安装本地依赖项,只需在package.json文件所在的目录中运行以下命令:

npm install

现在,可以使用webpack命令来构建示例。这是第一章中唯一的示例,因此很容易在终端窗口中导航并运行webpack命令,该命令会构建main-bundle.js文件。如果你打算玩转代码,这显然是被鼓励的,可以尝试运行webpack --watch。这个命令的后者形式将监视用于构建的文件的变化,并在它们发生变化时运行构建。

这确实是一个简单的 hello world,让我们能够顺利地开始阅读本书的其余部分。我们已经通过安装 Webpack 及其支持模块来处理了所有样板设置任务。现在让我们看看代码。我们将从使用的标记开始看起。

<!doctype html>
<html>
  <head>
    <title>Hello Flux</title>
    <script src="img/main-bundle.js" defer></script>
  </head>
  <body></body>
</html>

这并没有什么复杂的地方,对吧?甚至body标签内也没有内容。重要的是main-bundle.js脚本——这是 Webpack 为我们构建的代码。现在让我们看看这段代码:

// Imports the "flux" module.
import * as flux from 'flux';

// Creates a new dispatcher instance. "Dispatcher" is
// the only useful construct found in the "flux" module.
const dispatcher = new flux.Dispatcher();

// Registers a callback function, invoked every time
// an action is dispatched.
dispatcher.register((e) => {
  var p;

  // Determines how to respond to the action. In this case,
  // we're simply creating new content using the "payload"
  // property. The "type" property determines how we create
  // the content.
  switch (e.type) {
    case 'hello':
      p = document.createElement('p');
      p.textContent = e.payload;
      document.body.appendChild(p);
      break;
    case 'world':
      p = document.createElement('p');
      p.textContent = `${e.payload}!`;
      p.style.fontWeight = 'bold';
      document.body.appendChild(p);
      break;
    default:
      break;
  }
});

// Dispatches a "hello" action.
dispatcher.dispatch({
  type: 'hello',
  payload: 'Hello'
});

// Dispatches a "world" action.
dispatcher.dispatch({
  type: 'world',
  payload: 'World'
});

如您所见,这个 hello world Flux 应用并没有什么复杂的地方。实际上,这段代码创建的唯一 Flux 特定组件是一个分发器。然后它分发几个动作,注册到存储中的处理函数处理这些动作。

不要担心这个示例中没有存储或视图。我们的想法是,我们已经安装并准备好了基本的 Flux NPM 包。

概述

本章向您介绍了 Flux。具体来说,我们探讨了 Flux 是什么以及它不是什么。Flux 是一套架构模式,当应用于我们的 JavaScript 应用程序时,有助于正确处理架构中的数据流方面。Flux 并不是用于解决特定实现挑战的另一个框架,无论是浏览器怪癖还是性能提升——已有众多工具可用于这些目的。也许 Flux 最重要的定义特征是它解决的问题的概念性——比如单向数据流。这是没有默认 Flux 实现的主要原因之一。

我们通过回顾本书中使用的构建组件的设置来结束本章。为了测试所有包是否就绪,我们创建了一个非常基础的 hello world Flux 应用程序。

现在我们已经了解了 Flux 是什么,是时候看看为什么 Flux 是这样的了。在下一章中,我们将更详细地探讨驱动 Flux 应用程序设计的原则。

第二章. Flux 原则

在上一章中,你以 10,000 英尺的高度了解了 Flux 的一些核心原则。例如,单向数据流是 Flux 存在的基础。本章的目标是超越对 Flux 原则的简单看法。

我们将从一点 MVC 回顾开始,以确定当我们试图扩展前端架构时,它在哪里失败了。在此之后,我们将更深入地探讨单向数据流以及它是如何解决我们在 MVC 架构中确定的一些扩展问题的。

接下来,我们将解决 Flux 架构面临的一些高级组合问题,例如使一切变得明确,并优先考虑层而不是深层层次结构。最后,我们将比较 Flux 架构中发现的各类状态,并介绍更新轮的概念。

MV* 的挑战

MV* 是前端 JavaScript 应用程序的流行架构模式。我们称之为 MV*,因为存在许多被接受的模式变体,每个变体都以模型和视图为核心概念。在我们的讨论中,它们都可以被视为同一种 JavaScript 架构风格。

MV* 并不是因为它是一套糟糕的模式而在开发社区中获得了影响力。不,MV之所以受欢迎,是因为它有效。尽管 Flux 可以被视为一种 MV的替代品,但无需去拆解一个正在运行的应用程序。

没有任何一种架构是完美的,Flux 也不例外。本节的目标不是贬低 MV及其做得好的所有事情,而是要看看 MV的一些弱点,以及 Flux 是如何介入并改善情况的。

关注点分离

MV* 真正擅长的一件事是建立清晰的关注点分离。也就是说,一个组件有一个责任,而另一个组件负责其他事情,如此类推,贯穿整个架构。与“关注点分离”原则相辅相成的是“单一责任”原则,它强制执行清晰的关注点分离。

我们为什么要关心这个问题呢?简单的答案是,当我们把责任分解成不同的组件时,系统的不同部分自然会相互解耦。这意味着我们可以改变一件事,而不必必然影响另一件事。这是任何软件系统都希望拥有的特性,无论其架构如何。但是,我们真的通过 MV*得到了这些,这真的是我们应该追求的目标吗?

例如,将一个特性划分为五个不同的职责可能并没有明显的优势。也许特性行为的解耦实际上并没有达到任何效果,因为我们每次想要更改任何东西时,都必须触及这五个组件。因此,关注点分离原则并没有帮助我们构建一个健壮的架构,反而变成了阻碍生产力的间接手段。以下是一个将特性分解为几个具有聚焦职责的部分的例子:

关注点分离

任何需要拆分特性以理解其工作方式的开发者,最终都会花费更多的时间在源代码文件之间跳转。特性感觉是碎片化的,这种代码结构并没有明显的优势。以下是 Flux 架构中构成特性的各个运动部件的观察:

关注点分离

Flux 特性分解让我们有一种可预测的感觉。我们排除了视图本身可能被分解的潜在方式,但这是因为视图位于 Flux 之外。就我们的 Flux 架构而言,我们关心的是当状态发生变化时,始终将正确的信息传递给我们的视图。

你会注意到,给定 Flux 特性的逻辑和状态紧密耦合在一起。这与 MV不同,在 MV中,我们希望应用逻辑是一个独立的实体,可以在任何数据上操作。而在 Flux 中,情况正好相反,我们会发现负责改变状态的逻辑与该状态紧密相邻。这是一个有意的设计特性,其含义是我们不需要过分追求关注点之间的分离,而且这种活动有时可能会适得其反。

正如我们将在接下来的章节中看到的那样,这种数据和逻辑的紧密耦合是 Flux 存储的特征。前面的图示表明,对于复杂特性,添加更多逻辑和状态要容易得多,因为它们总是位于特性的表面,而不是隐藏在组件的嵌套树中。

级联更新

当我们有一个“只需工作”的软件组件时,这感觉很好。这可能意味着许多事情,但通常它的意义是围绕自动为我们处理事情。例如,我们不需要手动调用这个方法,然后是那个方法,等等,所有的事情都由组件为我们处理。让我们看看以下插图:

级联更新

当我们将输入传递给一个更大的组件时,我们可以期望它会自动为我们做正确的事情。这些类型组件的吸引力在于,这意味着我们需要维护的代码更少。毕竟,组件知道如何通过协调任何子组件之间的通信来自动更新自己。

这就是级联效应开始的地方。我们告诉一个组件执行某些行为。这反过来又导致另一个组件做出反应。我们给它一些输入,这又导致另一个组件做出反应,以此类推。很快,就很难理解我们代码中的情况了。这是因为为我们“处理”的事情被隐藏了起来。这是有意为之的设计,但产生了意想不到的后果。

之前的图表并不太糟糕。当然,如果添加到较大组件中的子组件数量较多,可能会稍微难以理解,但总的来说,这是一个可处理的问题。让我们看看这个图表的一个变体:

级联更新

刚才发生了什么?又增加了三个框和四条线,导致级联更新复杂性的爆炸式增长。问题不再可处理,因为我们根本无法处理这种类型的复杂性,并且大多数依赖这种类型自动更新的 MV*应用程序有超过六个组件。我们所能期望的最好的结果就是,一旦它按照我们想要的方式工作,它就会继续工作。

这是我们对自动更新组件做出的天真假设——这是我们想要封装的东西。问题是这通常并不成立,至少如果我们打算维护软件的话。Flux 通过只有存储可以改变其自身状态,并且这种改变总是对动作的反应来规避级联更新的问题。

模型更新责任

在 MV架构中,状态存储在模型中。为了初始化模型状态,我们可以从后端 API 获取数据。这已经很清晰了:我们创建一个新的模型,然后告诉该模型去获取一些数据。然而,MV并没有说明谁负责更新这些模型。有人可能会认为控制器组件应该完全控制模型,但在实践中这真的会发生吗?

例如,在响应用户交互而调用的视图事件处理器中会发生什么?如果我们只允许控制器更新我们模型的状态,那么视图事件处理器函数应该直接与相关的控制器通信。以下图表是控制器以不同方式更改模型状态的可视化:

模型更新责任

乍一看,这种控制器设置似乎非常合理。它作为存储状态的模型的包装器。假设任何想要修改这些模型中的任何模型的东西都需要通过控制器进行。毕竟,这是它的责任——控制事物。来自 API 的数据、由用户触发并由视图处理的事件,以及其他模型——所有这些都需要与控制器通信,如果它们想要改变模型的状态。

随着我们的控制器增长,确保模型状态更改由控制器处理将产生越来越多的更改模型状态的方法。如果我们退后一步,看看这些方法如何累积,我们会开始注意到很多不必要的间接。通过代理这些状态更改,我们能够获得什么?

另一个原因是,控制器在尝试在 MV* 中建立一致的状态更改时是一个死胡同,因为模型可以对自己进行更改。例如,设置模型中的一个属性可能会作为副作用更改其他模型属性。更糟糕的是,我们的模型可能有监听器,它们会响应系统其他地方(级联更新问题)的状态更改。

Flux 存储通过仅允许通过操作进行状态更改来解决级联更新问题。这个相同的机制解决了这里讨论的 MV* 挑战;我们不必担心视图或其他存储直接更改我们存储的状态。

单向数据

任何 Flux 架构的基石是单向数据流。其想法是数据从点 A 流向点 B,或者从点 A 流向 B 再流向 C,或者从点 A 流向 C。在单向数据流中,重要的是方向,其次是顺序。因此,当我们说我们的架构使用单向数据流时,我们可以这样说:数据永远不会从点 B 流向点 A。这是 Flux 架构的一个重要特性。

如前节所述,MV* 架构的数据流没有明显的方向。在本节中,我们将讨论一些使单向数据流值得实施的特征。我们将从查看数据流的起点和终点开始,然后考虑如何避免数据单向流动时的副作用。

从开始到结束

如果数据流仅在一个方向上,必须有起点和终点。换句话说,我们不能只有一个无限的数据流,它任意地影响数据流通过的各种组件。当数据流具有明确定义的起点和终点时,我们不可能有循环流。相反,在 Flux 中,我们有一个大的数据流循环,如图所示:

从开始到结束

这显然是对任何 Flux 架构的过度简化,但它确实有助于说明任何给定数据流的起点和终点。我们所观察到的被称为更新轮次。一轮是原子的,意味着它运行到完成——无法停止更新轮次以完成(除非抛出异常)。

JavaScript 是一种运行到完成的语言,这意味着一旦代码块开始运行,它就会完成。这是好事,因为它意味着一旦我们开始更新 UI,回调函数就无法中断我们的更新。例外情况是当我们的代码中断更新过程时。例如,我们打算修改存储状态的存储逻辑分发了动作。这对我们的 Flux 架构来说是个坏消息,因为它会违反单向数据流。为了防止这种情况,调度器实际上可以检测到在更新轮次内部发生分发。我们将在后面的章节中了解更多关于这一点。

更新轮次负责更新整个应用程序的状态,而不仅仅是订阅了特定类型动作的部分。这意味着随着我们的应用程序增长,我们的更新轮次也在增长。由于更新轮次会触及每个存储,可能会开始感觉数据似乎正通过我们所有的存储横向流动。以下是这个想法的说明:

从头到尾

从单向数据流的角度来看,实际上并不重要有多少个存储。重要的是要记住,更新不会被其他正在分发的动作所中断。

无副作用

正如我们在 MV* 架构中看到的那样,自动状态变化的优点也是其终结之处。当我们通过隐藏规则编程时,本质上是在通过拼接一系列副作用来编程。这并不容易扩展,主要是因为在某个特定时间点,我们不可能在脑海中保留所有这些隐藏的连接。Flux 喜欢尽可能避免副作用。

让我们暂时考虑一下存储。这些是我们应用程序状态的主宰。当某个东西的状态发生变化时,它有可能导致另一段代码作为响应而运行。这确实在 Flux 中发生了。当一个存储改变状态时,如果它们已经订阅了存储,视图可能会被通知变化。这是 Flux 中发生副作用的唯一地方,这是不可避免的,因为我们在状态变化时确实需要更新 DOM。但 Flux 与之不同之处在于,当涉及数据依赖时,它如何避免副作用。处理用户界面中数据依赖的典型方法是通过通知依赖模型发生了某些事情。想想级联更新,如下所示:

无副作用

当在 Flux 中两个存储之间存在依赖关系时,我们只需要在依赖存储中声明这个依赖关系。这样做是告诉调度器确保我们依赖的存储始终是最先更新的。然后,依赖存储可以直接使用它所依赖的存储数据。这样,所有的更新仍然可以在同一个更新轮次内进行。

显式优于隐式

在架构模式中,趋势是通过随着时间的推移变得越来越复杂的抽象来简化事物。最终,系统中的更多数据会自动更改,开发者的便利性被隐藏的复杂性所取代。

这是一个真正的可扩展性问题,Flux 通过优先考虑显式动作和数据转换而不是隐式抽象来处理它。在本节中,我们将探讨显式性的好处以及需要做出的权衡。

通过隐藏的副作用进行更新

我们在本章中已经看到,处理隐藏在抽象背后的状态变化有多么困难。它们帮助我们避免编写代码,但同时也使得在稍后回顾代码时理解整个工作流程变得困难。在 Flux 中,状态被保存在存储中,存储负责改变自己的状态。这很好,因为当我们想要了解某个存储如何改变状态时,所有的状态转换代码都集中在一个地方。让我们看看一个示例存储:

// A Flux store with state.
class Store {
  constructor() {

    // The initial state of the store.
    this.state = { clickable: false };

    // All of the state transformations happen
    // here. The "action.type" property is how it
    // determines what changes will take place.
    dispatcher.register((e) => {

      // Depending on the type of action, we
      // use "Object.assign()" to assign different
      // values to "this.state".
      switch (e.type) {
        case 'show':
          Object.assign(this.state, e.payload,
            { clickable: true });
          break;
        case 'hide':
          Object.assign(this.state, e.payload,
            { clickable: false });
          break;
        default:
          break;
      }
    });
  }
}

// Creates a new store instance.
var store = new Store();

// Dispatches a "show" action.
dispatcher.dispatch({
  type: 'show',
  payload: { display: 'block' }
});

console.log('Showing', store.state);
// → Showing {clickable: true, display: "block"}

// Dispatches a "hide" action.
dispatcher.dispatch({
  type: 'hide',
  payload: { display: 'none' }
});

console.log('Hiding', store.state);
// → Hiding {clickable: false, display: "none"}

在这里,我们有一个包含简单state对象的存储。在构造函数中,存储向dispatcher注册了一个回调函数。所有状态转换都明确地在一个函数中发生。这就是数据变成用户界面信息的地方。我们不必在多个组件中寻找数据的小片段,因为它们的状态发生变化时,这种情况不会在 Flux 中发生。

因此,现在的问题变成了,视图如何利用这种单一的状态数据?在其他类型的客户端架构中,视图会在任何状态发生变化时收到通知。在前面的例子中,当clickable属性发生变化时,视图会收到通知,同样,当display属性发生变化时,视图也会收到通知。视图有逻辑来独立渲染这两个变化。然而,在 Flux 中,视图不会收到这样精细的更新。相反,它们会在存储状态变化时收到通知,并且状态数据就是提供给它们的内容。

这里的含义是,我们应该倾向于使用擅长重新渲染整个组件的视图技术。这正是 React 适合 Flux 架构的原因。尽管如此,我们仍然可以自由地使用我们喜欢的任何视图技术,正如我们将在本书后面的内容中看到的。

数据状态在一个地方发生变化

如前所述,存储转换代码被封装在存储中。这是故意的。那些改变存储状态的转换代码应该靠近存储。这种紧密的邻近性大大减少了随着系统变得更加复杂时确定状态变化发生位置的复杂性。这使得状态变化变得明确,而不是抽象和隐含的。

存储器管理所有状态转换代码的一个潜在权衡是可能会有很多这样的代码。我们之前看到的代码使用单个switch语句来处理所有的状态转换逻辑。这显然会在以后处理大量情况时引起一些头痛。我们将在本书稍后更详细地考虑这一点,当考虑大型、复杂的存储器时。只需知道我们可以重构我们的存储器,以优雅地处理大量情况,同时保持业务逻辑和状态的耦合紧密。

这直接把我们带回到了关注点分离原则。在 Flux 存储器中,数据和操作它的逻辑完全没有分离。但这实际上是不是一件坏事呢?一个行动被派发,存储器被通知此事,并改变其状态(或者什么都不做,忽略该行动)。改变状态的逻辑位于同一组件中,因为将其移动到其他地方没有任何好处。

行动太多?

行动使 Flux 架构中发生的所有事情都变得明确。通过“所有事情”,我的意思是所有事情——如果发生了,那一定是某个行动被派发的结果。这是好事,因为很容易找出行动是从哪里派发的。即使系统在增长,行动派发在我们的代码中也很容易找到,因为它们只能来自少数几个地方。例如,我们不会在存储器中找到正在派发的行动。

我们创建的任何功能都有可能创建数十个甚至更多的行动。我们倾向于认为更多意味着不好,从架构的角度来看。如果某物越多,就越难进行扩展和编程。在这方面有一些真实性,但如果我们要有很多某物,这在任何大型系统中都是不可避免的,那么它是行动就很好。行动相对较轻,因为它们描述了在我们应用程序中发生的事情。换句话说,行动不是我们需要担心拥有很多的重型项目。

拥有许多行动是否意味着我们需要将它们全部塞入一个巨大的单体行动模块中?幸运的是,我们不必这样做。仅仅因为行动是进入任何 Flux 系统的入口点,并不意味着我们不能按我们的喜好对它们进行模块化。这对我们开发的 Flux 组件都适用,并且我们将保持警惕,寻找我们可以在阅读本书的过程中保持代码模块化的方法。

层次结构之上的层

用户界面在本质上具有层次性,部分原因是因为 HTML 本身是层次化的,部分原因是因为我们构建用户所呈现信息的方式。例如,这就是为什么在一些应用程序中我们会有嵌套的导航层级——我们不可能一次性将所有内容都放在屏幕上。自然地,我们的代码开始反映这种层次结构,通过自身成为一个层次结构。这在某种程度上是好的,因为它反映了用户所看到的内容。但在另一方面,深层次的层次结构很难理解。

在本节中,我们将探讨前端架构中的层次结构以及 Flux 如何避免复杂的层次结构。我们首先将介绍拥有多个顶级组件的概念,每个组件都有自己的层次结构。然后,我们将探讨在层次结构内部发生的副作用以及数据如何在 Flux 层中流动。

多个组件层次结构

一个特定的应用程序可能只有几个主要功能。这些通常作为我们代码中的顶级组件或模块实现。这些不是单体组件;它们被分解成越来越小的组件。也许其中一些组件共享较小的多功能组件。例如,一个顶级组件层次结构可能由模型、视图和控制器组成,如图所示:

多个组件层次结构

这在我们的应用程序结构中是有意义的。当我们查看组件层次结构的图片时,很容易看出我们的应用程序是由什么构成的。这些层次结构,以顶级组件为根,就像独立于彼此存在的小宇宙。再次,我们回到了关注点分离的概念。我们可以开发一个功能,而不会影响另一个。

这种方法的缺点是用户界面功能通常依赖于其他功能。换句话说,一个组件层次结构的状态可能会依赖于另一个组件的状态。当没有机制来控制状态何时可以改变时,我们如何保持这两个组件树之间的同步?最终发生的情况是,一个层次结构中的组件将向另一个层次结构中的组件引入任意的依赖。这服务于单一目的,因此我们必须不断引入新的层次间依赖,以确保一切同步。

层次深度和副作用

层次结构的一个挑战是深度。也就是说,一个特定的层次结构会延伸多深?我们应用程序的功能不断变化和扩展范围。这可能导致我们的组件树变得更高。但它们也变得更宽。例如,假设我们的功能使用的是一个三级深的组件层次结构。

然后,我们添加一个新的层级。嗯,我们可能需要向这个新层级和更高层级添加几个新组件。因此,为了在层次结构上构建,我们必须在多个方向上进行扩展——水平和垂直。这个想法在这里得到了说明:

层次深度和副作用

在多个方向上扩展组件很困难,尤其是在没有数据流方向的组件层次结构中。也就是说,最终改变某个状态输入可以进入层次结构的任何级别。毫无疑问,这会有某种副作用,如果我们依赖于其他层次结构中的组件,所有的希望都破灭了。

数据流和层

Flux 具有独特的架构层,这些层比层次结构更适合扩展架构。原因很简单——我们只需要在架构的每一层中水平扩展组件。我们不需要向一个层添加新组件,也不需要添加新层。让我们看看以下图中 Flux 架构扩展的样子:

数据流和层

无论应用程序有多大,都不需要添加新的架构层。我们只需向这些层添加新组件。我们之所以能够这样做而不在给定层内创建组件连接的混乱,是因为这三个层都在更新轮次中发挥作用。更新轮次从动作开始,以渲染的最后一个视图结束。数据从层到层通过我们的应用程序单向流动。

应用程序数据和 UI 状态

当我们有职责分离,将表示固定在一个地方,将应用程序数据放在另一个地方时,我们需要管理状态的两个不同的地方。但在 Flux 中,唯一有状态的地方是在存储中。在本节中,我们将比较应用程序数据和 UI 数据。然后,我们将讨论最终导致用户界面变化的转换。最后,我们将讨论 Flux 存储以功能为中心的特性。

两样相同的东西

很常见,从 API 获取的应用程序数据会被输入到某种视图层。这也被称为表示层,负责将应用程序数据转换为对用户有价值的东西——换句话说,从数据到信息。在这些层中,我们最终得到状态来表示 UI 元素。例如,复选框是否被勾选?以下是我们倾向于在我们组件内分组这两种状态的说明:

两样相同的东西

这与 Flux 架构不太相符,因为存储是状态所在的地方,包括 UI。那么,存储能否同时包含应用和 UI 状态呢?好吧,对此并没有强烈的反对意见。如果所有具有状态的事物都包含在存储中,那么区分应用数据和属于 UI 元素的状态应该相当简单。以下是在 Flux 存储中找到的状态类型的说明:

同一事物的两个例子

尝试将 UI 状态与其他状态分开的基本误解在于,组件通常依赖于 UI 状态。即使是不同功能中的 UI 组件也可能以不可预测的方式相互依赖。Flux 认识到这一点,并不试图将 UI 状态视为应该与应用数据分开的特殊事物。

最终存储中结束的 UI 状态可以由许多因素推导而来。通常,我们应用数据中的两个或更多项可以确定一个 UI 状态项。一个 UI 状态可以由另一个 UI 状态推导而来,或者由更复杂的事物推导而来,例如 UI 状态和其他应用数据。在其他情况下,应用数据足够简单,可以直接由视图消费。关键是视图拥有足够的信息,可以自行渲染而无需跟踪自己的状态。

紧密耦合的转换

在 Flux 存储中,应用数据和 UI 状态紧密耦合在一起。这个数据上操作的转换紧密耦合到存储中是有意义的。这使得我们可以根据其他应用数据或其他存储的状态轻松地更改 UI 的状态。

如果我们的业务逻辑代码不在存储中,那么我们就需要开始向包含存储所需逻辑的组件引入依赖。当然,这意味着通用的业务逻辑,它转换状态,并且可以在多个存储中共享,但这在高层次上很少发生。存储最好保持其转换存储状态的业务逻辑紧密耦合。如果我们需要减少重复代码,我们可以引入更小、更精细的实用函数来帮助数据转换。

注意

我们也可以使我们的存储变得通用。这些存储是抽象的,并且不直接与视图接口。我们将在本书的后面部分更详细地介绍这个高级主题。

以功能为中心

如果改变存储状态的数据转换与存储本身紧密耦合,这意味着存储是为特定功能定制的吗?换句话说,我们关心存储在其他功能中被重用吗?当然,在某些情况下,我们有通用数据,在多个存储中重复几次并没有太多意义。但一般来说,存储是针对特定功能的。在 Flux 术语中,功能与域同义——每个人都会以不同的方式划分他们 UI 的功能。

这与其他架构不同,这些架构基于 API 的数据模型构建其数据模型。然后,他们使用这些模型来创建更具体的视图模型。任何给定的 MV*框架在其模型抽象中都会有大量的功能,比如数据绑定和自动 API 获取。他们只关心在状态改变时存储状态和发布通知。

当存储鼓励我们创建和存储特定于 UI 的新状态时,我们可以更容易地为用户设计。这是 Flux 中的存储与其他架构中的模型之间的基本区别——UI 数据模型优先。存储内的转换存在是为了确保正确的状态被发布到视图中——其他一切都是次要的。

摘要

本章向您介绍了 Flux 的驱动原则。这些原则应在您处理任何 Flux 架构时牢记在心。我们以对前端开发中普遍存在的 MV*风格架构的简要回顾开始本章。这种架构风格的一些挑战包括模型更新的级联和缺乏数据流方向。然后,我们探讨了 Flux 的奖赏概念——单向数据流。

接下来,我们讨论了 Flux 如何更倾向于显式动作而非隐式抽象。这使得阅读 Flux 代码时更容易理解,因为我们不必挖掘状态变化的原因。我们还探讨了 Flux 如何利用架构层来可视化数据在系统中的单向流动。

最后,我们比较了应用数据与通常被认为是特定于 UI 元素的州。Flux 存储通常关注与它所支持的功能相关的状态,并且不区分应用数据和 UI 状态。现在我们已经掌握了驱动 Flux 架构的原则,是时候我们来编写一个了。在下一章中,我们将实现我们的 Flux 架构骨架,这样我们可以专注于信息设计。

第三章:建立骨架架构

以 Flux 的方式思考的最佳方法是使用 Flux 编写代码。这就是为什么我们希望尽早开始构建骨架架构。我们称构建我们应用程序的这个阶段为骨架架构,因为它还不是完整的架构。它缺少许多关键的应用程序组件,这是故意的。骨架的目的是将移动部件保持在最低限度,使我们能够专注于我们的存储将为我们的视图生成的信息。

我们将以一种极简的结构开始,虽然规模不大,但不需要做很多工作就能将我们的骨架架构转换为我们的代码库。然后,我们将继续探讨骨架架构的一些信息设计目标。接下来,我们将深入实施存储的一些方面。

在我们开始构建的过程中,我们将开始了解这些存储如何映射到领域——用户将与之交互的功能。在此之后,我们将创建一些非常简单的视图,这有助于我们确保我们的数据流确实达到了最终目的地。最后,我们将通过检查每个 Flux 架构层的清单来结束本章,以确保我们在进行其他开发活动之前已经验证了我们的骨架。

一般组织

在构建骨架 Flux 架构的第一步,我们将花几分钟时间进行组织。在本节中,我们将建立一个基本的目录结构,弄清楚我们将如何管理我们的依赖关系,并选择我们的构建工具。这一切都不是一成不变的——我们的想法是快速开始,同时建立一些规范,以便将我们的骨架架构转换为应用程序代码尽可能无缝。

目录结构

用于开始构建我们的骨架的目录结构不需要很复杂。这是一个骨架架构,而不是完整的架构,因此初始目录结构应该与之相匹配。话虽如此,我们也不想使用一个难以演变成产品实际使用的目录结构。让我们看看我们将在项目目录根目录中找到的项目:

目录结构

很简单,对吧?让我们逐一了解这些项目代表什么:

  • main.js: 这是应用程序的主要入口点。这个 JavaScript 模块将启动系统的初始动作。

  • dispatcher.js: 这是我们的调度器模块。这是 Flux 调度器实例创建的地方。

  • actions: 这个目录包含所有我们的动作创建函数和动作常量。

  • stores: 这个目录包含我们的存储模块。

  • views: 这个目录包含我们的视图模块。

这可能看起来不多,这是有意为之。目录结构反映了 Flux 的架构层。显然,一旦我们过了骨架架构阶段,实际应用将会有更多内容,但不会太多。不过,在这个阶段,我们应避免添加任何额外的组件,因为骨架架构完全是关于信息设计的。

依赖管理

作为起点,我们将需要 Facebook Flux 分发器作为我们骨架架构的依赖项——即使我们最终的产品中不会使用这个分发器。我们需要开始设计我们的存储,因为这是骨架架构中最关键且最耗时的方面;在这个阶段担心像分发器这样的问题根本不值得。

我们需要从某个地方开始,Facebook 分发器的实现已经足够好。问题是,我们是否需要其他包?在第一章中,什么是 Flux?我们介绍了 Facebook Flux NPM 包的设置,并使用 Webpack 构建我们的代码。这可以作为我们最终的生产构建系统吗?

没有包管理器或模块打包器让我们从一开始就处于不利地位。这就是为什么我们需要将依赖管理视为骨架架构的第一步,即使目前我们没有很多依赖。如果我们是第一次构建一个背后有 Flux 架构的应用程序,我们处理依赖的方式将作为未来 Flux 项目的蓝图。

在开发骨架架构期间添加更多的模块依赖是否是一个坏主意?根本不是。事实上,使用适合这项工作的工具会更好。在我们实现骨架的过程中,我们将开始看到在存储中一些地方使用库会有所帮助。例如,如果我们对数据集合进行大量的排序和过滤,并构建高阶函数,使用类似 lodash 这样的工具是完美的。

另一方面,在这个阶段引入像 ReactJS 或 jQuery 这样的东西并不合理,因为我们还在考虑信息而不是如何在 DOM 中呈现它。所以,这本书中我们将采用这种方法——NPM 作为我们的包管理器,Webpack 作为我们的打包器。这是我们需要的最基本的基础设施,没有太多开销来分散我们的注意力。

信息设计

我们知道我们正在尝试构建的骨架架构特别关注将正确的信息传递给我们的用户。这意味着我们并没有太多关注用户的交互性或以用户友好的方式格式化信息。如果我们为自己设定一些粗略的目标——我们如何知道我们的信息设计实际上有所进展?

在本节中,我们将讨论 API 数据模型对我们用户界面设计可能产生的负面影响。然后,我们将探讨将数据映射到用户所看到的内容,以及这些映射应该如何在我们的商店中得到鼓励。最后,我们将思考我们正在工作的环境。

用户不理解模型

作为用户界面程序员,我们的任务是确保在正确的时间将正确的信息提供给用户。我们如何做到这一点?传统智慧围绕着从 API 获取一些数据,然后将其渲染为 HTML。除了语义标记和一些样式外,自数据从 API 到达以来,数据并没有发生太大的变化。我们说的是“这里有我们的数据,让我们让它对用户看起来更美观。”以下是这个想法的说明:

用户不理解模型

这里没有进行数据转换,这是可以接受的,只要用户得到他们需要的东西。这幅图描绘的问题在于 API 的数据模型已经绑架了 UI 功能开发。我们必须注意从后端发送给我们的每一件事。这是因为我们实际上能为用户提供的东西有限。我们可以做的一件事是让我们的模型增强从 API 返回的数据。这意味着如果我们正在开发一个需要的信息与 API 意图不完全一致的功能,我们可以像这里展示的那样,在前端模型中构建它。

用户不理解模型

这让我们在某种程度上更接近我们的目标,因为我们可以创建一个我们试图实现的特性的模型,并将其展示给用户。所以尽管 API 可能无法提供我们想在屏幕上显示的确切内容,但我们可以使用我们的转换函数来生成所需信息的模型。

在我们的设计过程的骨架架构阶段,我们应该尽可能独立于 API 来考虑商店。不是完全独立;我们不希望偏离太远,危及产品。但生产 Flux 骨架架构的想法是确保我们首先产生正确的信息。如果 API 无法支持我们试图做的事情,那么我们可以在花费大量时间实现完整功能之前采取必要的步骤。

商店映射用户所看到的内容

状态并不是我们 Flux 架构中商店唯一封装的东西。还有将旧状态映射到新状态的数据转换。我们应该花更多的时间思考用户需要看到什么,而不是花更多的时间思考 API 数据,这意味着商店转换函数是至关重要的。

我们需要在 Flux 存储中拥抱数据转换,因为它们是用户眼前事物变化的最终决定因素。没有这些转换,用户只能查看静态信息。当然,我们可以试图设计一个只使用传入系统的“原样”数据的架构,而不进行转换。这永远不会按照我们的意图实现,简单的理由是我们将发现与其他 UI 组件的依赖关系。

我们在存储方面应该设定什么样的早期目标?我们如何转换它们的状态?嗯,骨架架构完全是关于实验的,如果我们一开始就编写转换功能,我们可能会更早地发现依赖关系。依赖关系并不一定是坏事,除非我们在项目后期发现了很多依赖关系,那时我们早已完成了骨架架构阶段。当然,新功能会添加新的依赖关系。如果我们能尽早使用状态转换来识别潜在的依赖关系,那么我们可以避免未来的麻烦。

我们有什么可以工作的?

在我们卷起袖子开始实现这个骨架 Flux 架构之前,我们需要考虑的最后一件事情是已经存在的东西。例如,这个应用程序已经有一个建立的 API,我们正在重构前端?我们需要保留现有 UI 的用户体验吗?项目是完全的绿色地带,没有任何 API 或用户体验输入?

以下图表说明了这些外部因素如何影响我们对待骨架架构实现的方式:

我们有什么可以工作的?

让这两个因素塑造我们的 Flux 架构并没有什么不妥。在现有 API 的情况下,我们将有一个起点,从这里我们可以开始编写我们的状态转换函数,为用户提供他们所需的信息。在保持现有用户体验的情况下,我们已经知道我们的目标信息的形状,我们可以从不同的角度工作转换函数。

当 Flux 架构完全是绿色地带时,我们可以让它指导用户体验和需要实现的 API。我们发现自己构建骨架架构的任何场景都不太可能是非黑即白的。这些只是我们可能发现自己处于的起点。话虽如此,是时候开始实现一些骨架存储了。

将存储付诸实践

在本节中,我们将在我们骨架架构中实现一些存储。它们不会是完整的存储,能够支持端到端的工作流程。然而,我们将能够看到存储在我们应用程序的上下文中的位置。

我们将从所有商店动作中最基本的开始,即用一些数据填充它们;这通常是通过通过某些 API 获取数据来完成的。然后,我们将讨论更改远程 API 数据的状态。最后,我们将查看不使用 API 而更改商店本地状态的动作。

获取 API 数据

无论是否有准备好的应用程序数据 API 可供消费,我们都知道最终我们将以这种方式填充我们的商店数据。因此,将这视为实现骨架商店的第一个设计活动是有意义的。

让我们为我们的应用程序的主页创建一个基本的商店。用户在这里想要看到的最明显的信息是当前登录的用户、一个导航菜单,以及可能是一些与用户相关的最近事件的总结列表。这意味着获取这些数据将是我们的应用程序必须做的第一件事。这是我们的商店的第一个实现:

// Import the dispatcher, so that the store can
// listen to dispatch events.
import dispatcher from '../dispatcher';

// Our "Home" store.
class HomeStore {
  constructor() {

    // Sets a default state for the store. This is
    // never a bad idea, in case other stores want to
    // iterate over array values - this will break if
    // they're undefined.
    this.state = {
      user: '',
      events: [],
      navigation: []
    };

    // When a "HOME_LOAD" event is dispatched, we
    // can assign "payload" to "state".
    dispatcher.register((e) => {
      switch (e.type) {
        case 'HOME_LOAD':
          Object.assign(this.state, e.payload);
          break;
      }
    });
  }
}

export default new HomeStore();

这相当容易理解,所以让我们指出重要的部分。首先,我们需要导入分发器,以便我们可以注册我们的商店。当商店被创建时,默认状态存储在state属性中。当HOME_LOAD动作被分发时,我们改变商店的状态。最后,我们将商店实例作为默认模块成员导出。

如动作名称所暗示的,当商店的数据加载完成时,会触发HOME_LOAD。我们可能将从某些 API 端点拉取这些数据用于主商店。让我们继续在main.js模块中使用这个商店——我们的应用程序入口点:

// Imports the "dispatcher", and the "homeStore".
import dispatcher from './dispatcher';
import homeStore from './stores/home';

// Logs the default state of the store, before
// any actions are triggered against it.
console.log(`user: "${homeStore.state.user}"`);
// → user: ""

console.log('events:', homeStore.state.events);
// → events: []

console.log('navigation:', homeStore.state.navigation);
// → navigation: []

// Dispatches a "HOME_LOAD" event, when populates the
// "homeStore" with data in the "payload" of the event.
dispatcher.dispatch({
  type: 'HOME_LOAD',
  payload: {
    user: 'Flux',
    events: [
      'Completed chapter 1',
      'Completed chapter 2'
    ],
    navigation: [
      'Home',
      'Settings',
      'Logout'
    ]
  }
});

// Logs the new state of "homeStore", after it's
// been populated with data.
console.log(`user: "${homeStore.state.user}"`);
// → user: "Flux"

console.log('events:', homeStore.state.events);
// → events: ["Completed chapter 1", "Completed chapter 2"]

console.log('navigation:', homeStore.state.navigation);
// → navigation: ["Home", "Settings", "Logout"]

这是对我们主页商店的一些相当直接的使用。我们正在记录商店的默认状态,使用一些新的有效载荷数据分发HOME_LOAD动作,并再次记录状态以确保商店的状态确实发生了变化。所以问题是,这段代码与 API 有什么关系?

这是我们骨架架构的一个很好的起点,因为在我们开始实现 API 调用之前,有许多事情需要考虑。我们甚至还没有开始实现动作,因为如果我们这样做,它们只是另一个干扰。此外,一旦我们完善了我们的商店,动作和真实的 API 调用就很容易实现了。

关于main.js模块的第一个问题可能是HOME_LOADdispatch()调用的位置。在这里,我们正在将数据引导到商店中。这是否是做这件事的正确地方?当main.js模块运行时,我们是否总是需要这个商店被填充?这是否是我们想要将数据引导到所有商店的地方?我们不需要立即回答这些问题,因为这可能会让我们在一个架构方面停留得太久,而且还有许多其他问题需要考虑。

例如,我们的存储耦合是否合理?我们刚刚实现的首页存储有一个navigation数组。这些现在只是简单的字符串,但它们很可能会变成对象。更大的问题是,导航数据甚至可能不属于这个存储——几个其他存储可能也需要导航状态数据。另一个例子是我们设置存储新状态的方式。使用Object.assign()是有优势的,因为我们可以用只有一个状态属性的负载派发HOME_LOAD事件,而一切都将继续正常工作。实现这个存储几乎花了我们很少的时间,但我们提出了一些非常重要的问题,并学习了一种强大的分配新存储状态的技术。

这是骨架架构,所以我们不关心实际获取 API 数据的机制。我们更关心的是由于 API 数据到达浏览器而派发的行动;在这种情况下,它是HOME_LOAD。在骨架 Flux 架构的上下文中,信息通过存储流动的机制才是重要的。关于这一点,让我们稍微扩展一下我们存储的能力:

// We need the "dispatcher" to register our store,
// and the "EventEmitter" class so that our store
// can emit "change" events when the state of the
// store changes.
import dispatcher from '../dispatcher';
import { EventEmitter } from 'events';

// Our "Home" store which is an "EventEmitter"
class HomeStore extends EventEmitter {
  constructor() {

    // We always need to call this when extending a class.
    super();

    // Sets a default state for the store. This is
    // never a bad idea, in case other stores want to
    // iterate over array values - this will break if
    // they're undefined.
    this.state = {
      user: '',
      events: [],
      navigation: []
    };

    // When a "HOME_LOAD" event is dispatched, we
    // can assign "payload" to "state", then we can
    // emit a "change" event.
    dispatcher.register((e) => {
      switch (e.type) {
        case 'HOME_LOAD':
          Object.assign(this.state, e.payload);
          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new HomeStore();

存储仍然做它之前所做的一切,但现在存储类从EventEmitter继承,当派发HOME_LOAD行动时,它使用存储状态作为事件数据发出一个change事件。这让我们更接近拥有完整的工作流程,因为视图现在可以监听change事件以获取存储的新状态。让我们更新我们的主模块代码来看看这是如何完成的:

// Imports the "dispatcher", and the "homeStore".
import dispatcher from './dispatcher';
import homeStore from './stores/home';

// Logs the default state of the store, before
// any actions are triggered against it.
console.log(`user: "${homeStore.state.user}"`);
// → user: ""

console.log('events:', homeStore.state.events);
// → events: []

console.log('navigation:', homeStore.state.navigation);
// → navigation: []

// The "change" event is emitted whenever the state of The
// store changes.
homeStore.on('change', (state) => {
  console.log(`user: "${state.user}"`);
  // → user: "Flux"

  console.log('events:', state.events);
  // → events: ["Completed chapter 1", "Completed chapter 2"]

  console.log('navigation:', state.navigation);
  // → navigation: ["Home", "Settings", "Logout"]
});

// Dispatches a "HOME_LOAD" event, when populates the
// "homeStore" with data in the "payload" of the event.
dispatcher.dispatch({
  type: 'HOME_LOAD',
  payload: {
    user: 'Flux',
    events: [
      'Completed chapter 1',
      'Completed chapter 2'
    ],
    navigation: [
      'Home',
      'Settings',
      'Logout'
    ]
  }
});

我们骨架架构中对存储的这种增强带来了更多的问题,即关于在存储上设置事件监听器。正如你所看到的,我们必须确保在派发任何行动之前,处理程序实际上正在监听存储。我们需要解决所有这些关注点,而我们刚刚才开始设计我们的架构。让我们继续到改变后端资源的状态。

更改 API 资源状态

在我们通过向 API 请求一些数据设置初始存储状态后,我们可能最终需要改变后端资源的状态。这是对用户活动的响应。事实上,常见的模式看起来像以下图表:

更改 API 资源状态

让我们在 Flux 存储的上下文中思考这个模式。我们已经看到了如何将数据加载到存储中。在我们构建的骨架架构中,我们实际上并没有进行这些 API 调用,即使它们存在——我们只专注于当前前端产生的信息。当我们派发一个改变存储状态的行动时,我们可能需要更新这个存储的状态,以响应 API 调用的成功完成。真正的问题是,这具体意味着什么?

例如,我们调用更改后端资源状态实际上会返回更新后的资源,还是只返回一个成功的指示?这些类型的 API 模式对我们存储库的设计有重大影响,因为它意味着在必须始终进行二次调用或数据在响应中可用之间的区别。

让我们看看一些代码。首先,我们有一个如下所示的用户存储库:

import dispatcher from '../dispatcher';
import { EventEmitter } from 'events';

// Our "User" store which is an "EventEmitter"
class UserStore extends EventEmitter {
  constructor() {
    super();
    this.state = {
      first: '',
      last: ''
    };

    dispatcher.register((e) => {
      switch (e.type) {
        // When the "USER_LOAD" action is dispatched, we
        // can assign the payload to this store's state.
        case 'USER_LOAD':
          Object.assign(this.state, e.payload);
          this.emit('change', this.state);
          break;

        // When the "USER_REMOVE" action is dispatched,
        // we need to check if this is the user that was
        // removed. If so, then reset the state.
        case 'USER_REMOVE':
          if (this.state.id === e.payload) {
            Object.assign(this.state, {
              id: null,
              first: '',
              last: ''
            });

            this.emit('change', this.state);
          }

          break;
      }
    });
  }
}

export default new UserStore();

我们假设这个单一的用户存储库是我们应用程序中的一个页面,其中只显示单个用户。现在,让我们实现一个用于跟踪多个用户状态的存储库:

import dispatcher from '../dispatcher';
import { EventEmitter } from 'events';

// Our "UserList" store which is an "EventEmitter"
class UserListStore extends EventEmitter {
  constructor() {
    super();

    // There's no users in this list by default.
    this.state = []

    dispatcher.register((e) => {
      switch (e.type) {

        // The "USER_ADD" action adds the "payload" to
        // the array state.
        case 'USER_ADD':
          this.state.push(e.payload);
          this.emit('change', this.state);
          break;

        // The "USER_REMOVE" action has a user id as
        // the "payload" - this is used to locate the
        // user in the array and remove it.
        case 'USER_REMOVE':
          let user = this.state.find(
            x => x.id === e.payload);

          if (user) {
            this.state.splice(this.state.indexOf(user), 1);
            this.emit('change', this.state);
          }

          break;
      }
    });
  }
}

export default new UserListStore();

现在,让我们创建main.js模块,它将与这些存储库一起工作。特别是,我们想看看与 API 交互以更改后端资源的状态将如何影响我们存储库的设计:

import dispatcher from './dispatcher';
import userStore from './stores/user';
import userListStore from './stores/user-list';

// Intended to simulate a back-end API that changes 
// state of something. In this case, it's creating
// a new resource. The returned promise will resolve
// with the new resource data.
function createUser() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        first: 'New',
        last: 'User'
      });
    }, 500);
  });
}

// Show the user when the "userStore" changes.
userStore.on('change', (state) => {
  console.log('changed', `"${state.first} ${state.last}"`);
});

// Show how many users there are when the "userListStore"
// changes.
userListStore.on('change', (state) => {
  console.log('users', state.length);
});

// Creates the back-end resource, then dispatches actions
// once the promise has resolved.
createUser().then((user) => {

  // The user has loaded, the "payload" is the resolved data.
  dispatcher.dispatch({
    type: 'USER_LOAD',
    payload: user
  });
  // Adds a user to the "userListStore", using the resolved
  // data.
  dispatcher.dispatch({
    type: 'USER_ADD',
    payload: user
  });

  // We can also remove the user. This impacts both stores.
  dispatcher.dispatch({
    type: 'USER_REMOVE',
    payload: 1
  });
});

在这里,我们可以看到createUser()函数充当实际 API 实现的代理。记住,这是一个骨架架构,主要关注的是我们存储库构建的信息。实现一个返回 Promise 的函数在这里是完全可接受的,因为一旦我们开始与真实 API 通信,这很容易在以后进行更改。

我们正在寻找我们存储库的有趣方面——它们的状态、状态如何变化以及存储库之间的依赖关系。在这种情况下,当我们创建新用户时,API 返回新对象。然后,这作为USER_LOAD操作被派发。现在我们的userStore已经填充。我们还派发了一个USER_ADD操作,以便将新用户数据添加到这个列表中。假设这两个存储库服务于我们应用程序的不同部分,但更改后端中某物状态的相同 API 调用是相关的。

我们能从所有这些中学到关于我们架构的什么?首先,我们可以看到 Promise 回调将不得不为多个存储库派发多个操作。这意味着我们可能期望在创建资源的类似 API 调用中看到更多相同的情况。那么,修改用户的调用会看起来相似吗?

我们在这里缺少的是更新userListStore中用户数组中用户对象状态的操作。或者,我们也可以让这个存储库也处理USER_LOAD操作。任何方法都可以,构建骨架架构的练习应该帮助我们找到最适合我们应用程序的方法。例如,我们在这里也派发了一个单一的USER_REMOVE操作,并且这两个存储库都能轻松处理。这可能就是我们要找的方法?

本地操作

我们将用查看本地操作来结束存储库操作的章节。这些操作与 API 无关。本地操作通常是响应用户交互的,派发它们将对 UI 产生可见的影响。例如,用户想要切换页面上某些组件的可见性。

典型的应用程序会执行一个 jQuery 单行代码来定位 DOM 中的元素并做出适当的 CSS 更改。这种类型的事情在 Flux 架构中行不通,而且我们应该在我们的应用程序骨架架构阶段开始考虑这种类型的事情。让我们实现一个简单的存储库来处理本地动作:

import dispatcher from '../dispatcher';
import { EventEmitter } from 'events';

// Our "Panel" store which is an "EventEmitter"
class PanelStore extends EventEmitter {
  constructor() {

    // We always need to call this when extending a class.
    super();

    // The initial state of the store.
    this.state = {
      visible: true,
      items: [
        { name: 'First', selected: false },
        { name: 'Second', selected: false }
      ]
    };

    dispatcher.register((e) => {
      switch (e.type) {

        // Toggles the visibility of the panel, which is
        // visible by default.
        case 'PANEL_TOGGLE':
          this.state.visible = !this.state.visible;
          this.emit('change', this.state);
          break;

        // Selects an object from "items", but only
        // if the panel is visible.
        case 'ITEM_SELECT':
          let item = this.state.items[e.payload];

          if (this.state.visible && item) {
            item.selected = true;
            this.emit('change', this.state);
          }

          break;
      }
    });
  }
}

export default new PanelStore();

PANEL_TOGGLE动作和ITEM_SELECT动作是这个存储库处理的两个本地动作。它们是局部的,因为它们很可能是用户点击按钮或选择复选框触发的。让我们分派这些动作,以便我们可以看到我们的存储库如何处理它们:

import dispatcher from './dispatcher';
import panelStore from './stores/panel';

// Logs the state of the "panelStore" when it changes.
panelStore.on('change', (state) => {
  console.log('visible', state.visible);
  console.log('selected', state.items.filter(
    x => x.selected));
});

// This will select the first item.
dispatcher.dispatch({
  type: 'ITEM_SELECT',
  payload: 0
});
// → visible true
// → selected [ { name: First, selected: true } ]

// This disables the panel by toggling the "visible"
// property value.
dispatcher.dispatch({ type: 'PANEL_TOGGLE' });
// → visible false
// → selected [ { name: First, selected: true } ]

// Nothing the second item isn't actually selected,
// because the panel is disabled. No "change" event
// is emitted here either, because the "visible"
// property is false.
dispatcher.dispatch({
  type: 'ITEM_SELECT',
  payload: 1
});

这个例子说明了为什么在骨架架构实现阶段,我们应该考虑所有与状态相关的事项。仅仅因为我们现在没有实现实际的 UI 组件,并不意味着我们不能猜测一些常见构建块的潜在状态。在这段代码中,我们发现ITEM_SELECT动作实际上依赖于PANEL_TOGGLE动作。这是因为我们实际上不希望在面板禁用时选择一个项目并更新视图。

建立在这一点上,其他组件是否应该首先能够分派这个动作?我们刚刚发现一个潜在的存储依赖,其中依赖的存储库在实际上启用 UI 元素之前会查询panelStore的状态。所有这些都是从甚至不与 API 交谈的本地动作中发现的,而且没有实际的用户界面元素。我们可能会在我们的骨架架构过程中发现更多这样的项目,但不要陷入寻找所有事物的困境。我们的想法是在有机会的时候学习我们能学到的东西,因为一旦我们开始实现真实的功能,事情就会变得更加复杂。

存储和特性领域

在更传统的前端架构中,直接映射到 API 返回结果的模型为我们的 JavaScript 组件提供了一个清晰简洁的数据模型。正如我们所知,Flux 更倾向于用户方向,并专注于他们需要看到和交互的信息。这不应该成为我们的巨大头痛,特别是如果我们能够将用户界面分解为领域。将领域想象成一个非常大的特性。

在本节中,我们将讨论识别构成我们 UI 核心的顶级特性。然后,我们将努力从方程式中去除无关的 API 数据。我们将以查看我们的存储数据结构及其在骨架架构设计中的作用来结束本节。

识别顶级特性

在我们 Flux 项目的骨架架构阶段,我们应该积极参与并开始编写 store 代码,就像我们在本章中所做的那样。我们一直在思考用户可能需要的信息以及我们如何最好地将这些信息传递给用户。我们一开始并没有花太多时间去尝试识别应用程序的最高级功能。这是可以的,因为在本章中我们已经完成的练习通常是确定如何组织用户界面的先决条件。

然而,一旦我们确定了如何实现一些低级 store 机制来获取我们所需的信息,我们就需要开始考虑这些最高级功能。这样做有一个很好的理由——我们最终维护的 store 将映射到这些功能上。当我们说最高级时,很容易以导航作为参考点。实际上,使用页面导航作为指南并没有什么不妥;如果它足够大,可以用于主要导航,那么它可能是一个值得拥有自己 Flux store 的最高级功能。

除了是一个最高级功能之外,我们还需要考虑 store 的作用——为什么它存在?它为用户增加了什么价值?这些问题之所以重要,是因为我们可能会最终有六个页面,它们都可以使用同一个 store。因此,这是一个在将价值整合到一个 store 和确保 store 不是太大且通用之间取得平衡的问题。

应用程序很复杂,有很多动态部分驱动着许多功能。我们的用户界面可能拥有 50 个令人惊叹的功能。但这不太可能需要 50 个令人惊叹的最高级导航链接和 50 个 Flux store。我们的 store 将不得不在某个时候用它们的数据表示这些功能的复杂细节。但这将在稍后进行,现在我们只需要大致了解我们正在处理多少个 store,以及它们之间有多少依赖关系。

无关的 API 数据

用之或不弃之——这是 Flux store 数据的座右铭。API 数据的挑战在于它是对后端资源的表示——它不会返回专门为我们 UI 所需的数据。API 的存在是为了让多个 UI 可以基于它构建。然而,这意味着我们经常在我们的 store 中遇到无关数据。例如,如果我们只需要从 API 资源中获取几个属性,我们不想存储 36 个属性。尤其是当其中一些本身可以是集合时。这在内存消耗方面是浪费的,在存在性方面也是令人困惑的。实际上,后一点更令人担忧,因为我们很容易误导其他在这个项目上工作的程序员。

一种可能的解决方案是排除 API 响应中的这些未使用值。许多 API 今天支持这一点,允许我们选择我们想要返回的属性。如果这意味着大幅减少网络带宽,这可能是一个好主意。然而,这种方法也可能存在错误,因为我们必须在 AJAX 调用级别执行此过滤,而不是在存储级别。让我们看看一个采用不同方法的例子,通过指定存储记录:

import dispatcher from '../dispatcher';
import { EventEmitter } from 'events';

class PlayerStore extends EventEmitter {
  constructor() {
    super();

    // The property keys in the default state are
    // used to determine the allowable properties
    // used to set the state.
    this.state = {
      id: null,
      name: ''
    };

    dispatcher.register((e) => {
      switch (e.type) {
        case 'PLAYER_LOAD':

          // Make sure that we only take payload data
          // that's already a state property.
          for (let key in this.state) {
            this.state[key] = e.payload[key];
          }

          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new PlayerStore();

在这个例子中,默认的 state 对象除了提供默认状态值外,还扮演着重要的角色,它还提供了存储记录。换句话说,默认状态使用的属性键决定了在派发 PLAYER_LOAD 动作时的允许值。让我们看看它是否按预期工作:

import dispatcher from './dispatcher';
import playerStore from './stores/player';

// Logs the state of the player store when it changes.
playerStore.on('change', (state) => {
  console.log('state', state);
});

// Dispatch a "PLAYER_LOAD" action with more payload
// data than is actually used by the store.
dispatcher.dispatch({
  type: 'PLAYER_LOAD',
  payload: {
    id: 1,
    name: 'Mario',
    score: 527,
    rank: 12
  }
});
// → state {id: 1, name: "Mario"}

结构化存储数据

本章中迄今为止展示的所有示例都在存储中具有相对简单的状态对象。一旦我们构建起骨架架构,这些简单的对象将变得更为复杂。记住,存储的状态反映了用户查看的信息的状态。这包括页面上的某些元素的状态。

这是我们需要关注的事情。仅仅因为我们完成了骨架架构练习,并不意味着一个想法在我们开始实现更复杂的功能时仍然有效。换句话说,如果一个存储状态变得太大——太嵌套和深入——那么是时候考虑稍微移动我们的存储了。

理念是我们不希望有太多的存储驱动我们的视图,因为它们在这个阶段更像是 MVC 架构中的模型。我们希望存储代表应用程序的特定功能。这并不总是行得通,因为我们可能会在存储中为该功能结束一些复杂和混乱的状态。在这种情况下,我们的顶级功能需要以某种方式拆分。

这无疑会在我们使用 Flux 的时间段的某个时刻发生,而且没有规则规定何时应该重构存储。相反,如果状态数据的大小让你感觉可以舒适地与之工作,那么你很可能对当前的存储状态感到满意。

简洁的视图

我们在骨架存储方面取得了一些进展,现在我们准备开始查看骨架视图。这些是简单的类,与存储的精神非常相似,但我们实际上并没有将任何内容渲染到 DOM 中。这些简洁视图的目的是确认我们架构的坚实基础,并确保这些视图组件确实获得了他们期望的信息。这是至关重要的,因为视图是 Flux 数据流中的最后一项,所以如果它们没有得到他们需要的信息,或者在他们需要的时候没有得到,我们就需要回去修复我们的存储。

在本节中,我们将讨论我们的基本视图如何帮助我们更快地识别存储缺失特定信息的情况。然后,我们将探讨这些视图如何帮助我们识别 Flux 应用程序中的潜在动作。

寻找缺失数据

我们将使用基本视图执行的第一项活动是确定存储是否传递了视图所需的所有基本信息。就基本而言,我们谈论的是如果它们不存在,就会对用户造成问题的东西。例如,我们正在查看一个设置页面,有一个整个部分缺失。或者,有一个选项列表可供选择,但我们实际上没有字符串标签来显示,因为它们是某个其他 API 的一部分。

一旦我们确定这些关键信息缺失,下一步就是确定它们是否可能,因为如果它们不可能,我们就避免了花费大量时间实现一个完整的视图。然而,这些是罕见的情况。通常,回到有问题的存储并添加缺失的转换,以计算和设置我们正在寻找的缺失状态,并不是什么大问题。

我们需要在这些基本视图上花费多少时间?可以这样想——当我们开始实现实际视图,这些视图将渲染到 DOM 中时,我们会发现更多从存储中缺失的状态。然而,这些只是表面的,并且容易修复。对于基本视图,我们更关注挖掘缺失的关键部分。完成这些视图后,我们能做什么?它们是垃圾吗?不一定,根据我们如何实现我们的生产视图,我们可以调整它们成为 ReactJS 组件,或者我们可以在基本视图内嵌入实际视图,使其更像是一个容器。

识别动作

如本章前面所述,给定 Flux 架构将要分发的第一组动作将与从后端 API 获取数据有关。最终,这些是导致视图的数据流的开始。有时,这些仅仅是加载类型的动作,我们明确表示去获取资源并填充我们的存储。有时,我们可能有更抽象的动作,这些动作描述了用户采取的动作,导致多个存储从许多不同的 API 端点被填充。

这将用户带到我们可以开始考虑他们如何与这些信息交互的点。他们唯一能这样做的方式就是通过分发更多的动作。让我们创建一个带有一些动作方法的视图。本质上,目标是能够从浏览器 JavaScript 控制台访问我们的视图。这让我们可以在任何给定点查看与视图关联的状态信息,以及调用方法来分发给定的动作。

要做到这一点,我们需要稍微调整我们的 Webpack 配置:

output: {
  …
  library: 'views'
}

这一行将导出一个全局的views变量到浏览器窗口中,其值将是我们的main.js模块导出的内容。现在让我们来看看这一点:

import settingsView from './views/settings';
export { settingsView as settings };

嗯,这看起来很有趣。我们只是将视图导出为settings。因此,当我们创建骨架架构中的基本视图时,我们只需在main.js中遵循此模式,不断将视图添加到浏览器控制台以进行实验。现在让我们来看看设置视图本身:

import dispatcher from '../dispatcher';
import settingsStore from '../stores/settings';

// This is a "bare bones" view because it's
// not rendering anything to the DOM. We're just
// using it to validate our Flux data-flows and
// to think about potential actions dispatched
// from this view.
class SettingsView {
  constructor() {

    // Logs the state of "settingsStore" when it
    // changes.
    settingsStore.on('change', (state) => {
      console.log('settings', state);
    });

    // The initial state of the store is logged.
    console.log('settings', settingsStore.state);
  }

  // This sets an email value by dispatching
  // a "SET_EMAIL" action.
  setEmail(email) {
    dispatcher.dispatch({
      type: 'SET_EMAIL',
      payload: 'foo@bar.com'
    });
  }

  // Do all the things!
  doTheThings() {
    dispatcher.dispatch({
      type: 'DO_THE_THINGS',
      payload: true
    })
  }
}

// We don't need more than one of these
// views, so export a new instance.
export default new SettingsView();

现在剩下的唯一事情就是看看当我们加载这个页面时浏览器控制台中有什么。我们应该有一个全局的views变量,并且这个变量应该有我们每个视图实例作为属性。现在,我们可以像用户在 DOM 中点击一样玩转视图派发的操作。让我们看看这看起来怎么样:

views.settings.setEmail()
// → settings {email: "foo@bar.com", allTheThings: false}

views.settings.doTheThings()
// → settings {email: "foo@bar.com", allTheThings: true}

端到端场景

在某个时候,我们将不得不结束项目骨架架构阶段,并开始实现真实的功能。我们不希望骨架阶段拖得太久,因为这会导致我们对实现现实的假设过多。同时,在我们继续前进之前,我们可能想要走几个端到端场景。

本节的目标是向您提供一些高级要点,以便在每一层架构中留意。这些不是严格的标准,但它们确实可以帮助我们制定自己的衡量标准,以确定通过构建骨架是否充分回答了关于信息架构的问题。如果我们感到自信,那么是时候全力以赴,详细阐述应用程序的细节了——本书的后续章节将深入探讨实现 Flux 的细节。

操作清单

当我们实现操作时,以下项目值得思考:

  • 我们的功能是否有通过从 API 获取数据来引导存储数据的操作?

  • 我们是否有改变后端资源状态的操作?这些更改如何在我们的前端 Flux 存储中反映?

  • 给定的功能是否有任何本地操作,并且它们是否与发出 API 请求的操作不同?

存储清单

在实现存储时,以下项目值得思考:

  • 存储是否映射到我们应用程序中的顶级功能?

  • 存储的数据结构是否满足使用它的视图的需求?结构是否过于复杂?如果是这样,我们能否将存储重构为两个存储?

  • 存储是否丢弃了未使用的 API 数据?

  • 存储是否将 API 数据映射到用户所需的相关信息?

  • 当我们开始添加更复杂视图功能时,我们的存储结构是否易于更改?

  • 我们是否有太多的存储?如果是这样,我们是否需要重新思考我们构建顶级应用程序功能的方式?

视图清单

在实现视图时,以下项目值得思考:

  • 视图是否从存储中获取所需的信息?

  • 哪些操作会导致视图渲染?

  • 视图在响应用户交互时派发了哪些动作?

摘要

本章是关于通过构建一些骨架组件来开始使用 Flux 架构。目标是思考信息架构,而不受其他实现问题的干扰。我们可能会发现自己处于 API 已经定义好或者用户体验已经到位的情况。这两个因素中的任何一个都将影响我们存储的设计,以及最终我们向用户展示的信息。

我们实现的存储是基本的,当应用程序启动时加载数据,并响应 API 调用更新其状态。然而,我们确实学会了提出关于我们存储的相关问题,例如,如何解析新数据并将其设置为存储的状态,以及这种新状态将如何影响其他存储。

然后,我们思考了构成我们应用程序核心的顶级特性。这些特性为我们架构所需存储提供了良好的指示。在骨架架构阶段的后期,我们希望通过几个端到端场景来验证我们选择的信息设计是否合理。我们查看了一些高级清单项,以确保我们没有遗漏任何重要内容。在下一章中,我们将更深入地探讨动作及其分发方式。

第四章:创建操作

在上一章中,我们致力于构建我们的 Flux 应用的骨架架构。操作是由分发器直接分发的。现在我们已经掌握了骨架 Flux 架构,是时候更深入地研究操作了,特别是操作的创建方式。

我们将从讨论我们赋予操作的名称以及用于识别系统中可用操作的常量开始。然后,我们将实现一些操作创建函数,并考虑如何保持这些模块化。尽管我们可能已经完成了骨架架构的实现,但我们可能仍然需要模拟一些 API 数据——我们将通过操作创建函数介绍如何完成这项工作。

典型的操作创建函数是无状态的——输入数据,输出数据。我们将讨论一些操作创建函数实际上依赖于状态的情况,例如涉及长时间运行连接的情况。我们将以参数化操作创建函数的介绍来结束本章,这将允许我们为不同的目的重用它们。

操作名称和常量

任何大型 Flux 应用都会有很多操作。这就是为什么拥有操作常量和合理的操作名称很重要的原因。本节的重点是讨论操作的可能命名约定,并整理我们的操作。常量有助于减少重复且易出错的字符串,但我们也需要考虑如何最好地组织我们的常量。我们还将查看静态操作数据——这也有助于我们减少必须编写的操作分发代码的数量。

操作名称约定

Flux 系统中的所有操作都有一个名称。名称很重要,因为它告诉查看它的人很多关于它做什么的信息。如果一个应用中少于十个操作,不太可能对命名约定有强烈的要求,因为我们很容易就能弄清楚这些操作做什么。然而,同样不太可能我们会使用 Flux 来实现一个小型应用——Flux 是用于需要扩展的系统。这意味着有很多操作的可能性很大。

操作名称可以分为两个部分——主题操作。例如,一个名为 ACTIVATE 的操作不会非常有帮助——我们在激活什么?在名称中添加一个主题通常就足以提供所需的大量上下文。以下是一些示例:

  • ACTIVATE_SETTING

  • ACTIVATE_USER

  • ACTIVATE_TAB

主题只是我们系统中的一种抽象类型的东西——它甚至不需要对应一个具体的软件实体。然而,如果我们的系统中有很多具有相似操作的主题,我们可能想要改变操作名称的格式,例如:

  • SETTING_ACTIVATE

  • USER_ACTIVATE

  • TAB_ACTIVATE

最后,这实际上是一个个人(或团队)偏好问题,只要名称足够描述性,足以向查看代码的人提供意义即可。如果主题和操作不够用怎么办?例如,可能有几个相似的主题,这可能会引起混淆。然后,我们可以在名称中添加另一个主题层——将其视为动作的命名空间。

注意

尽量不要在 Flux 动作名称中超过三个部分。如果你觉得需要这样做,那么你的架构中可能还有其他地方需要关注。

静态动作数据

一些动作与其他动作非常相似,相似之处在于发送到存储器的有效负载数据具有许多相同的属性。如果我们直接使用分发器实例分发这些动作,那么我们通常需要重复对象字面量代码。让我们看看一个动作创建函数:

import dispatcher from '../dispatcher';

// The action name.
export const SORT_USERS = 'SORT_USERS';

// This action creator function hard-codes
// the action payload.
export function sortUsers() {
  dispatcher.dispatch({
    type: SORT_USERS,
    payload: {
      direction: 'asc'
    }
  });
}

这个动作的目标非常直接——对用户列表进行排序,这些用户可能是 UI 组件。所需的有效负载数据仅是一个排序方向,该方向在 direction 属性中指定。这个动作创建函数的问题在于这个有效负载数据是硬编码的。例如,这里讨论的有效负载数据似乎相当通用,其他对数据进行排序的动作创建函数应该遵循这个模式。但这同时也意味着它们各自都会有自己的硬编码值。

我们可以做的关于这一点的事情是在 actions 目录中创建一个模块,该模块导出可以在多个动作创建函数之间共享的任何默认有效负载数据。继续使用排序示例,该模块可能开始看起来像这样:

// This object is used by several action
// creator functions as part of the action
// payload.
export const PAYLOAD_SORT = {
  direction: 'asc'
};

这很容易扩展。当需要新的属性或旧默认值需要更改时,我们可以扩展 PAYLOAD_SORT。当需要时,添加新的默认有效负载也很容易。让我们看看另一个使用此默认有效负载的动作创建函数:

import dispatcher from '../dispatcher';
import { PAYLOAD_SORT } from './payload-defaults';

// The action name.
export const SORT_TASKS = 'SORT_TASKS';

// This action creator function is using
// the "PAYLOAD_SORT" default object as the
// payload.
export function sortTasks() {
  dispatcher.dispatch({
    type: SORT_TASKS,
    payload: PAYLOAD_SORT
  });
}

如我们所见,PAYLOAD_SORT 对象被 sortTasks() 函数使用,而不是在动作创建器中硬编码有效负载。这减少了我们需要编写的代码量,并将通用有效负载数据放在一个中心位置,使我们能够轻松地更改许多动作创建函数的行为。

注意

你可能已经注意到,默认的有效负载对象是直接传递给 dispatch() 的。大多数情况下,我们会有部分有效负载对象在多个函数中是通用的,而另一部分则是动态的。我们将在本章的最后部分构建本节的示例,届时将考虑参数化动作创建函数。

现在,让我们来看看这两个动作创建函数的实际应用,以确保我们得到预期的结果。而不是为这个设置存储,我们只需直接监听分发器:

import dispatcher from './dispatcher';

// Gets the action constant and creator function
// for "SORT_USERS".
import {
  SORT_USERS,
  sortUsers
} from './actions/sort-users';

// Gets the action constant and creator function
// for "SORT_TASKS".
import {
  SORT_TASKS,
  sortTasks
} from './actions/sort-tasks';

// Listen for actions, and log some information
// depending on which action was dispatched.
// Note that we're using the action name constants
// here, so there's less chance of human error.
dispatcher.register((e) => {
  switch (e.type) {
    case SORT_USERS:
      console.log(`Sorting users "${e.payload.direction}"`);
      break;
    case SORT_TASKS:
      console.log(`Sorting tasks "${e.payload.direction}"`);
      break;
  }
});

sortUsers();
// → Sorting users "asc"

sortTasks();
// → Sorting tasks "asc"

组织动作常量

你可能已经注意到,在之前的例子中已经有一些关于操作常量的组织性。例如,SORT_USERS常量是在与sortUsers()操作创建函数相同的模块中定义的。这通常是一个好主意,因为这两者之间关系密切。然而,这也存在一个缺点。想象一个需要处理大量操作的更复杂的存储器。如果每个单独的操作常量都在自己的模块中声明,存储器将不得不执行大量的导入,只是为了获取这些常量。如果有多个复杂的存储器,每个都需要访问大量操作,导入的数量开始真正增加。这个问题在这里得到了说明:

import { ACTION_A } from '../actions/action-a';
import { ACTION_B } from '../actions/action-b';
import { ACTION_C } from '../actions/action-c';
// …

如果我们发现自己处于这种情况下,即几个存储器需要访问几个模块,可能我们需要在actions目录中创建一个constants.js模块。这个模块将暴露系统中的每一个操作。以下是这个模块可能的样子:

export { ACTION_A } from './action-a';
export { ACTION_B } from './action-b';
export { ACTION_C } from './action-c';

随着我们的系统增长和新操作的添加,这就是我们集中管理操作常量的地方,以便于需要大量常量的存储器轻松访问。它们在这里没有定义;这只是一个代理,减少了从存储器中导入的数量,因为存储器从不需要操作创建函数。让我们看看从需要操作常量的存储器的角度来看,情况是否有所改善:

import {
  ACTION_A,
  ACTION_B,
  ACTION_C
} from './actions/constants';

console.log(ACTION_A);
// → ACTION_A

console.log(ACTION_B);
// → ACTION_B

console.log(ACTION_C);
// → ACTION_C

这样更好。只有一个import语句就能获取我们所需的一切,而且代码依然清晰易读。我们可以有几种方法来调整这种做法,使其更符合我们的需求。例如,我们可能不想创建一个大的常量模块,而是希望将我们的操作分组到逻辑模块中,这些模块更接近我们的功能,同样也适用于我们的操作创建函数。我们将在下一节讨论与我们的应用程序功能相关的操作模块化。

功能操作创建函数

操作创建函数需要组织,就像操作常量一样。在本章前面的代码示例中,我们已经将我们的操作常量和操作创建函数组织到模块中。这使我们的操作代码保持整洁且易于遍历。在本节中,我们将从功能的角度来构建这个想法。我们将探讨为什么一开始就值得考虑这个问题,然后我们将讨论这些想法如何使整体架构更加模块化。

当需要模块化时

我们是否需要在 Flux 项目的开始阶段就深入思考模块化的操作创建函数?当项目规模还较小时,如果所有操作创建函数都是单一的操作创建模块的一部分,这是可以接受的——这根本不会对架构产生有意义的影响。只有当我们有十几个或更多的操作时,我们才需要开始考虑模块化,特别是功能。

我们可以将动作创建者模块拆分为几个更小的模块,每个模块都有自己的动作创建者函数。这当然是一个正确的方向,但从本质上讲,我们只是将问题移动到了目录级别。因此,我们不再有一个单一的模块,而是一个包含许多文件的单一目录。这个目录在这里得到了说明:

当需要模块化时

这种布局本身并没有什么固有的错误——只是没有指示哪个功能包含特定的操作。这可能甚至不是必要的,但当架构增长到一定规模时,通常有助于按功能分组操作模块。这个概念在这里得到了说明:

当需要模块化时

一旦我们能够以这种方式组织系统的操作,使它们反映任何给定功能的任何行为,我们就可以开始考虑与模块化相关的其他架构挑战。我们将在下一节讨论这些内容。

模块化架构

当 Flux 架构中的模块开始呈现出我们应用提供的功能形状时,这是一件好事。这在架构的其他地方也有影响。例如,如果我们按功能组织操作,那么我们是否也应该按功能组织存储和视图?存储很容易——它们不能精确地分解成更小的存储;它们自然地代表了一个功能的全貌。另一方面,视图可能包含许多 JavaScript 模块来组织在一个功能内。以下是一个 Flux 功能的潜在目录结构:

模块化架构

这是一个结构紧凑的布局——所有需要执行这些操作的视图都在同一个父目录中。同样,通知视图关于状态变化的存储也在同一个地方。我们可以通过遵循类似的模式来处理我们所有的功能,这还有一个额外的优点,就是促进了一致性。

我们将在本书的结尾重新审视功能模块的结构。现在,我们主要关心的是其他功能可能对一组操作具有的依赖关系。例如,我们的功能定义了几个由视图分发的操作。那么,其他想要对这些操作做出响应的功能应该怎么办——它们需要依赖这个功能来执行操作吗?还有关于动作创建者本身的问题,以及其他功能是否可以分发它们。答案是响亮的“是”,原因很简单——动作是 Flux 架构中事情发生的方式。没有事件总线,模块可以以“点火并忘记”的方式发布事件。动作在我们的 Flux 架构的模块化中起着至关重要的作用。

模拟数据

Flux 架构中的调度器是新数据进入系统的单一入口点。这使得我们能够轻松地制造模拟数据以帮助我们更快地生成功能。在本节中,我们将讨论模拟现有 API,以及是否值得将其构建到与它们通信的动作创建器函数中。然后,我们将介绍为尚未存在的 API 实现模拟,接着探讨用模拟动作创建器替代真实动作创建器的策略。

模拟现有 API

为了在 Flux 系统中模拟数据,需要分发的动作必须将这个模拟数据传递给存储。这是通过创建一个替代的动作创建器函数实现,该函数分发动作。当动作创建器已经有一个可以针对的 API 时,我们不一定需要在开发特定功能时模拟数据——数据已经存在了。然而,动作创建器使用的 API 的存在不应排除存在一个模拟版本的可能性。

我们想要这样做的主要原因是因为在我们产品的生命周期中的任何时刻,都可能存在一个我们需要但尚未出现的 API。正如我们将在下一节中看到的,显然我们想要模拟这个 API 返回的数据,以便我们能够继续实现我们正在工作的功能。但我们真的想要模拟一些动作而不模拟其他动作吗?这个想法在这里得到了说明:

模拟现有 API

这种方法——模拟一些动作而实际实现其他动作——的挑战在于一致性。当我们模拟进入系统的数据时,我们必须意识到一组数据与另一组数据之间的关系。从我们存储的角度来看——它们之间可能存在依赖关系。我们能否使用模拟数据和实际数据混合来捕捉这些依赖关系?以下是一个模拟整个系统动作的示例:

模拟现有 API

在我们尝试新功能时,最好对所使用的数据有完全的控制。这消除了由于我们数据中的一些不一致性而导致的错误行为。构建这样的模拟数据需要更多的努力,但当我们添加新功能时,这会带来回报,因为我们每次只需要模拟一个新动作,就像它被添加到系统中一样。正如我们将在本节后面看到的那样,用模拟动作创建器替代生产动作创建器很容易。

模拟新 API

在实现新功能的过程中,当我们缺少 API 功能时,我们必须进行模拟。我们可以使用这个新的模拟与我们已经创建的其他模拟一起支持应用程序中的其他功能。这样做的好处是,它允许我们立即创建一些东西,我们可以向利益相关者展示。将 API 作为动作创建者函数进行模拟的另一个好处是,它们可以帮助将 API 引导到正确的方向。没有 UI,API 没有任何东西可以基于其设计,所以这是一个征求与我们要构建的应用程序最兼容的设计的好机会。

让我们看看一些模拟作为动作有效载荷分发的数据的动作创建者函数。我们将从一个基本的加载函数开始,这个函数将一些数据引导到存储中供我们使用:

import dispatcher from '../dispatcher';

// The action identifier...
export const LOAD_TASKS = 'LOAD_TASKS';

// Immediately dispatches the action using an array
// of task objects as the mock data.
export function loadTasks() {
  dispatcher.dispatch({
    type: LOAD_TASKS,
    payload: [
      { id: 1, name: 'Task 1', state: 'running' },
      { id: 2, name: 'Task 2', state: 'queued' },
      { id: 3, name: 'Task 3', state: 'finished'}
    ]
  });
}

这相当简单。我们想要模拟的数据是函数的一部分,作为动作有效载荷。现在让我们看看另一个模拟动作创建者,它是在数据已经被引导后操纵存储状态的:

import dispatcher from '../dispatcher';

// The action identifier...
export const RUN_TASK = 'RUN_TASK';

// Uses "setTimeout()" to simulate latency we'd
// likely see in a real network request.
export function runTask() {
  setTimeout(() => {
    dispatcher.dispatch({
      type: RUN_TASK,

      // Highly-specific mock payload data. This
      // mock data doesn't necessarily have to
      // be hard-coded like this, but it does make
      // experimentation easy.
      payload: {
        id: 2,
        state: 'running'
      }
    });
  }, 1000);
}

再次强调,我们在这里使用的是非常具体的模拟数据,这是可以接受的,因为它直接耦合到正在分发动作的动作创建者函数——这也是数据进入系统的唯一方式。这个函数与其他函数不同的另一个特点是,它通过在setTimeout()回调在 1 秒后触发之前不分发动作来模拟延迟。

注意

我们将在后面的章节中更详细地探讨异步动作,包括延迟、承诺和多个 API 端点。

到目前为止,我们有可用的两个模拟动作创建者函数。但在我们开始使用这些函数之前,让我们创建一个任务存储,以确保正确的信息被存储:

import EventEmitter from 'events';
import dispatcher from '../dispatcher';
import { LOAD_TASKS } from '../actions/load-tasks';
import { RUN_TASK } from '../actions/run-task';

// The store for tasks displayed in the application.
class TaskStore extends EventEmitter {
  constructor() {
    super();

    this.state = [];

    dispatcher.register((e) => {
      switch(e.type) {

        // In the case of "LOAD_TASKS", we can use the
        // "payload" as the new store state.
        case LOAD_TASKS:
          this.state = e.payload;
          this.emit('change', this.state);
          break;

        // In the case of "RUN_TASK", we need to look
        // up a specific task object and change it's state.
        case RUN_TASK:
          let task = this.state.find(
            x =>x.id === e.payload.id);

          task.state = e.payload.state;

          this.emit('change', this.state);

          break;
      }
    });
  }
}

export default new TaskStore();

现在我们有一个存储来处理我们刚刚实现的所有动作,让我们在应用程序的main.js模块中使用存储和动作:

import taskStore from './stores/task';
import { loadTasks } from './actions/load-tasks';
import { runTask } from './actions/run-task';

// Logs the state of the store, as a mapped array
// of strings.
taskStore.on('change', (state) => {
  console.log('tasks',
    state.map(x => `${x.name} (${x.state})`));
});

loadTasks();
// →
// tasks [
//   "Task 1 (running)",
//   "Task 2 (queued)",
//   "Task 3 (finished)"
// ]

runTask();
// →
// tasks [
//   "Task 1 (running)",
//   "Task 2 (running)",
//   "Task 3 (finished)"
// ]

正如你所见,通过调用loadTasks(),任务已成功引导到存储中,当我们调用runTask()时,第二个任务的状态被更新。这个后者的更新直到一秒后才会被记录。

替换动作创建者

到目前为止,我们有一个工作的动作创建者函数,它将模拟有效载荷数据的动作分发到系统中。回想一下,我们不一定想摆脱这些动作创建者函数,因为当我们需要实现新功能时,我们还想再次使用这些模拟。

我们真正需要的是一个全局开关,可以切换系统的模拟模式,这将改变使用的动作创建者函数的实现。以下是一个显示这可能如何工作的图解:

替换动作创建者

这里的想法是在模块内部存在同一动作创建函数的模拟版本和生产版本。这是容易的部分;困难的部分是实现一个全局模拟开关,以便根据应用程序的模式导出正确的函数:

import { MOCK } from '../settings';
import dispatcher from '../dispatcher';

// The action identifier...
export const LOAD_USERS = 'LOAD_USERS';

// The mock implementation of the action creator.
function mockLoadUsers() {
  dispatcher.dispatch({
    type: LOAD_USERS,
    payload: [
      { id: 1, name: 'Mock 1' },
      { id: 2, name: 'Mock 2' }
    ]
  });
}

// The production implementation of the action creator.
function prodLoadUsers() {
  dispatcher.dispatch({
    type: LOAD_USERS,
    payload: [
      { id: 1, name: 'Prod 1' },
      { id: 2, name: 'Prod 2' }
    ]
  });
}

// Here's where the "loadUsers" value is determined, based
// on the "MOCK" setting. It's always going to be exported
// as "loadUsers", meaning that no other code needs to change.
const loadUsers = MOCK ? mockLoadUsers : prodLoadUsers;
export { loadUsers as loadUsers };

在开发过程中,这非常方便,因为我们的模拟函数的范围仅限于动作创建模块,并且由一个设置控制。让我们看看这个动作创建函数是如何使用的,无论导出的是模拟还是生产实现:

import dispatcher from './dispatcher';

// This code never has to change, although the actual
// function that's exported will change, depending on
// the "MOCK" setting.
import { loadUsers } from './actions/load-users';

dispatcher.register((e) => {
  console.log('Users', e.payload.map(x =>x.name));
});

loadUsers();
// → Users ["Mock 1", "Mock 2"]
// When the "MOCK" setting is set to true...
// → Users ["Prod 1", "Prod 2"]

有状态动作创建者

在本章中我们讨论的动作创建函数相对简单——当被调用时,它们会分发一些动作。但在那之前,这些动作创建者通常会联系某个 API 端点以检索一些数据,然后使用这些数据作为有效载荷分发动作。这些被称为无状态动作创建函数,因为它们没有中间状态——换句话说,没有生命周期。

在本节中,我们将思考那些有状态的事物,以及我们如何将它们集成到我们的 Flux 架构中。我们可能面临的另一个挑战是将我们的 Flux 应用程序集成到另一个架构中。首先,我们将介绍一些关于有状态动作创建者的基础知识,然后我们将通过使用 Web sockets 的实例来查看一个具体的例子。

与其他系统集成

大多数情况下,Flux 应用程序在浏览器中是独立的。也就是说,它们不是更大机器上的一个齿轮。然而,我们可能会遇到需要我们的 Flux 架构适应更大系统的案例。例如,如果我们需要与使用完全不同框架的组件进行接口,那么我们需要想出一个方法来嵌入我们的软件,同时不破坏 Flux 模式。或者,也许我们的应用程序与我们集成的应用程序之间的耦合稍微松散一些,就像在与另一个浏览器标签页通信时那样。无论情况如何,我们必须能够向这个外部系统发送消息,并且我们需要能够从它那里接收消息,将它们转换为操作。以下是这个想法的说明:

与其他系统集成

如您所见,这里描述的 Flux 架构不是一个封闭系统。主要影响是,我们习惯于在系统中调用的典型动作创建函数并不一定在系统中被调用。也就是说,它们正在处理外部消息流,使用与另一个系统的有状态连接。这正是 Web sockets 的工作方式。接下来,我们将查看这些有状态的动作创建函数。

Web socket 连接

WebSocket 连接在现代 Web 应用程序中越来越普及,如果我们正在构建 Flux 架构,那么我们很可能需要构建 WebSocket 支持。当后端的状态发生变化时,WebSocket 连接是通知客户端这种变化的好方法。例如,想象一个 Flux 存储正在管理某些后端数据的状态,如果有什么原因导致其状态发生变化——我们难道不希望存储知道这一点吗?

挑战在于,我们需要一个有状态的连接来接收 WebSocket 消息并将它们转换为 Flux 操作。这就是 WebSocket 数据进入系统的方式。让我们看看一些 socket 监听器代码:

// Get the action constants and action functions
// that we need.
import { ONE, one } from './one';
import { TWO, two } from './two';
import { THREE, three } from './three';

var ws;
var actions = {};

// Create a mapping of constants to functions
// that the web socket handler can use to call
// the appropriate action creator.
actions[ONE] = one;
actions[TWO] = two;
actions[THREE] = three;

// Connects the web socket...
export default function connect() {
  ws = new WebSocket('ws://127.0.0.1:8000');

  ws.addEventListener('message', (e) => {

    // Parses the message data and uses the
    // "actions" map to call the corresponding
    // action creator function.
    let data = JSON.parse(e.data);
    actionsdata.task;
  });
}

在这里我们只是创建了一个简单的 actions 映射。这是根据接收到的消息的 task 属性调用正确的操作创建函数的方法。这种方法的优点是,为了使它工作,所需的额外功能非常少;前面的代码就是全部。实际的操作创建函数、常量等等,都是典型的 Flux 项目。让我们看看生成这些 WebSocket 消息的服务器代码,以便了解实际上传递给 socket 监听器代码的内容:

// The HTTP server...
var server = require('http').createServer();

// The web socket server...
var ws = new require('ws').Server({
  server: server,
});

// Makes life worth living...
var express = require('express');
var app = express();

// So we can serve "index.html"...
app.use(express.static(__dirname));

// Handler for when a client connects via web socket.
ws.on('connection', function connection(ws) {
  let i = 0;
  const names = [ null, 'one', 'two', 'three' ];

  // Sends the client 3 messages, spaced by 1 second
  // intervals.
  function interval() {
    if (++i< 4) {
      ws.send(JSON.stringify({
        value: i,
        task: names[i]
      }));

      setTimeout(interval, 1000);
    }
  }

  setTimeout(interval, 1000);
});

// Fire up the HTTP and web socket servers.
server.on('request', app);
server.listen(8000, () => {
  console.log('Listening on', server.address().port)
});

在接下来的三秒钟内,我们将看到三个 WebSocket 消息被发送到客户端。每个消息都有一个 task 属性,这是我们用来确定要派发哪个操作的值。让我们看一下 main.js 前端模块,确保一切按预期工作:

import dispatcher from './dispatcher';
import connect from './actions/socket-listener';
import { ONE } from './actions/one';
import { TWO } from './actions/two';
import { THREE } from './actions/three';

// Logs the web socket messages that have been
// dispatched as Flux actions.
dispatcher.register((e) => {
  switch (e.type) {
    case ONE:
      console.log('one', e.payload);
      break;
    case TWO:
      console.log('two', e.payload);
      break;
    case THREE:
      console.log('three', e.payload);
      break;
  }
});
// →
// one 1
// two 2
// three 3

// Establishes the web socket connection. Note
// that it's important to connect after everything
// with the Flux dispatcher is setup. 
connect();

如您所见,connect() 函数负责建立 WebSocket 连接。这是一个简单的实现,缺乏一些生产级功能,例如重新连接丢失的连接。然而,这里需要注意的是,这个监听器实际上位于其他操作模块相同的目录中。我们实际上希望这里有一个紧密耦合,因为 socket 监听器的主要目标是通过转换 WebSocket 消息来派发操作。

参数化操作创建者

本章的最后一节重点介绍了参数化操作创建者。到目前为止,本章中我们看到的操作创建者函数都是不接受任何参数的基本函数。这很好,除非我们开始积累几个几乎相同的独特操作。如果没有参数化操作创建者函数,我们很快就会有无穷无尽的函数;这并不适合扩展。

首先,我们将确定传递参数到操作创建函数的目标,然后是一些实现通用操作创建函数的示例代码。然后我们将探讨创建部分函数,通过组合操作创建者来进一步减少重复性。

移除冗余操作

操作创建者是普通的 JavaScript 函数。这意味着它们在调用时可以接受零个或多个参数。无论是否在 Flux 的上下文中实现函数,其整个目的都是减少我们必须编写的代码量。在 Flux 应用程序中的操作创建者可能会积累,因为它们驱动着我们的应用程序的行为。如果发生任何事情,都可以追溯到操作。因此,在一天之内引入几个新的操作是很常见的。

一旦我们的应用程序实现了几个功能,我们肯定会有一大堆操作。其中一些操作将服务于一个特定的目的,而其他操作将彼此非常相似。换句话说,一些操作开始感觉重复。目标是通过引入参数来移除重复的操作创建函数。

我们在重构操作创建函数的方式上应该谨慎行事。有一个强有力的论点支持为系统中的每种类型的操作保留一个专门的函数。也就是说,一个操作创建函数应该只派发一种类型的操作,而不是几种选项中的一种。否则,我们代码的可追溯性将会降低。我们应该努力减少系统中的操作总数。

保持操作通用

当操作通用时,架构对它们的需求更少。这是一件好事,因为它意味着我们在编写代码时需要记住的知识更少。让我们看看几个本质上做同样事情的几个操作;换句话说,它们根本不是通用的。第一个操作如下:

import dispatcher from '../dispatcher';
import sortBy from 'lodash/sortBy';

// The action identifier...
export const FIRST = 'FIRST';

export function first() {

  // The payload data.
  let payload = [ 20, 10, 30 ];

  // Dispatches the "FIRST" action with
  // the payload sorted in ascending order.
  dispatcher.dispatch({
    type: FIRST,
    payload: sortBy(payload)
  });
}

简单到足以——这是使用 lodash 的 sortBy() 函数在派发操作之前对有效负载进行排序。

注意

注意,我们实际上不会在操作创建函数中这样排序有效负载数据。把这看作是一个 API 模拟。重点是操作创建函数正在向 Flux 之外请求数据。

让我们看看另一个类似但不同的操作:

import dispatcher from '../dispatcher';
import sortBy from 'lodash/sortBy';

// The action identifier...
export const SECOND = 'SECOND';

export function second() {

  // The payload data.
  let payload = [ 20, 10, 30 ];

  // Dispatches the action, with the
  // payload sorted in descending order.
  dispatcher.dispatch({
    type: SECOND,
    payload: sortBy(payload, x => x * -1)
  });
}

这里的唯一区别是我们如何排序数据。如果这是一个生产操作创建函数,我们会告诉 API 按降序排序数据,而不是在操作创建函数中使用 lodash 来做。我们需要两个不同的操作来处理这两种排序方向吗?或者我们可以通过一个接受排序方向参数的通用操作来消除这两个操作?以下是通用实现的示例:

import dispatcher from '../dispatcher';
import sortBy from 'lodash/sortBy';

// The action identifier...
export const THIRD = 'THIRD';

// Accepts a sort direction, but defaults
// to descending.
export function third(dir='desc') {

  // The payload data.
  let payload = [ 20, 10, 30 ];

  // The iteratee function that's passed
  // to "sortBy()".
  let iteratee;

  // Sets up the custom "iteratee" if we
  // want to sort in descending order.
  if (dir === 'desc') {
    iteratee = x => x * -1;
  }

  // Dispatches the action, sorting the payload
  // based on "dir".
  dispatcher.dispatch({
    type: THIRD,
    payload: sortBy(payload, iteratee)
  });
}

这里是所有三个正在使用的操作。请注意,第三个操作涵盖了两种情况,而且无论传递什么参数,基本的排序操作都是相同的。您可以在存储回调函数中看到,存储器会更愿意监听一个操作而不是两个或更多:

import dispatcher from './dispatcher';
import { FIRST, first } from './actions/first';
import { SECOND, second } from './actions/second';
import { THIRD, third } from './actions/third';

// Logs the specific action payloads as
// they're dispatched.
dispatcher.register((e) => {
  switch(e.type) {
    case FIRST:
      console.log('first', e.payload);
      break;
    case SECOND:
      console.log('second', e.payload);
      break;
    case THIRD:
      console.log('third', e.payload);
      break;
  }
});

first();
// → first [10, 20, 30]

second();
// → second [30, 20, 10]

third();
// → third [30, 20, 10]

third('asc');
// → third [10, 20, 30]

创建操作部分

在某些情况下,函数参数是直接的——比如有一个或两个参数。在其他情况下,参数列表可能会令人望而生畏,尤其是当我们反复使用相同的一小部分参数调用它们时。Flux 应用中的动作创建函数也不例外。会有一些情况,我们有一个通用函数,它支持偶尔的情况,即不是提供一个新的动作创建函数,而是提供一个不同的参数。但在最常见的情况下,必须始终提供相同的参数,这可能会变得重复,以至于失去了通用函数的意义。

让我们看看一个通用的动作创建函数,它接受可变数量的参数。由于在大多数情况下,相同的参数会被传递给函数,因此我们还会导出一个部分应用了这些参数的函数的版本。

注意

在 ES2015 语法中,默认参数是创建部分函数的良好替代品,但只有当参数数量固定时。

import dispatcher from '../dispatcher';
import partial from 'lodash/partial';

// The action identifier...
export const FIRST = 'FIRST';

// The generic implementation of the action creator.
export function first(...values) {

  // The payload data.
  let defaults = [ 'a', 'b', 'c' ];

  // Dispatches the "FIRST" action with
  // the "values" array concatenated to
  // the "defaults" array.
  dispatcher.dispatch({
    type: FIRST,
    payload: defaults.concat(values)
  });
}

// Exports a common version of "first()" with
// the common arguments already applied.
export const firstCommon = partial(first, 'd', 'e', 'f');

现在,让我们看看这两个相同动作创建函数版本的使用方法:

import dispatcher from './dispatcher';
import { FIRST, first, firstCommon } from './actions/first';

// Logs the specific action payloads as
// they're dispatched.
dispatcher.register((e) => {
  switch(e.type) {
    case FIRST:
      console.log('first', e.payload);
      break;
  }
});

// Calls the action creator with a common set
// of arguments. This is the type of code we
// want to avoid repeating all over the place.
first('d', 'e', 'f');
// → first ["a", "b", "c", "d", "e", "f"]

// The exact same thing as the "fist()" call above.
// The common arguments have been partially-applied.
firstCommon();
// → first ["a", "b", "c", "d", "e", "f"]

注意

重要的是要注意,first()firstCommon() 函数是同一个动作创建函数,这就是为什么它们被定义在同一个模块中的原因。如果我们把 firstCommon() 定义在另一个动作模块中,这会导致混淆,因为它们都使用相同的动作类型——FIRST

摘要

在本章中,你学习了 Flux 应用利用的动作创建函数,以便分发动作。如果没有动作创建函数,我们就必须直接在我们的代码中与分发器接口,这使得架构更难推理。

我们首先思考了动作命名约定和我们的动作模块的一般组织结构。按功能分组动作创建函数对模块化也有影响,尤其是在它如何影响架构的其他方面。

接下来,我们讨论了使用动作创建函数来模拟数据。在 Flux 应用中模拟数据很容易做到,并且是被鼓励的。动作是数据进入系统的唯一方式,这使得我们能够轻松地在模拟动作数据和我们的生产实现之间切换。我们通过查看监听诸如 WebSocket 连接等内容的可状态动作创建函数,以及查看将重复代码保持在最低限度的参数化动作创建函数来结束这一章。

在下一章中,我们将解决动作创建函数的另一个关键方面——异步性。

第五章。异步动作

在第四章,“创建动作”中,我们详细考察了 Flux 动作——特别是动作创建函数。动作创建者我们没有涉及的一个方面是异步行为。异步性对于任何 Web 应用程序都是核心的,在本章中,我们将思考这对于 Flux 架构意味着什么。

我们将首先介绍 Flux 的同步性质,因为打破这种同步性会破坏整个架构。接下来,我们将深入研究一些进行 API 调用的代码和一些在实际上派发动作之前需要同步多个 API 调用的动作创建者。然后,我们将介绍动作创建函数的返回值作为承诺。

保持 Flux 同步

我们可能听起来会想保持架构同步——尤其是在 Web 上。当所有操作都是同步执行时,会发生延迟的用户体验怎么办?

就只是 Flux 的数据流是同步的,而不是整个应用程序。在本节中,我们将探讨为什么保持我们架构的核心数据流机制同步是一个好主意。接下来,我们将讨论我们应该如何封装应用程序中的异步行为。最后,我们将概述异步动作创建函数的一般语义。

为什么是同步的?

简单的答案是,任何异步操作都会引入一种不确定性,否则不会存在。鉴于 Web 浏览器中所有的新潮功能,可能会让人想将所有事情都并行执行——尽可能利用尽可能多的并发 Web 请求和处理器核心。一旦我们走上这条路,就很难回头,而且我们走得越远,同步语义就越复杂。

让我们暂时思考一下 DOM API。JavaScript 应用程序使用这个 API 来改变页面上的元素状态。当这些变化发生时,浏览器的渲染引擎会介入并更新屏幕,以便用户实际上可以看到这些变化。DOM API 并不直接与屏幕上显示的内容接口——渲染引擎为我们处理了一大堆棘手的细节。这个想法在这里得到了说明:

为什么是同步的?

这里的要点是,不是我们组件做出的单个更新导致渲染引擎更新屏幕。JavaScript 引擎是运行到完成,这意味着它在将控制权交给渲染引擎之前,会等待所有这些组件完成它们对更新 DOM(以及它们正在运行的任何其他代码)的调用。这意味着用户看到的任何更新在本质上都是同步的——世界上所有的并发代码都不会改变 JavaScript 引擎和渲染引擎之间的同步通信路径。

你可能现在会想知道这与 Flux 有什么关系。实际上,这非常相关,因为 Flux 的作者理解这种同步 DOM 更新机制,所以他们不是在复杂异步代码的每个地方与之抗争,而是提出了拥抱 DOM 更新同步特性的数据流语义。

Flux 用于同步数据流的核心理念是更新轮次,这一概念在第二章 Flux 原理 中被引入。没有任何东西可以中断一个更新轮次,因为参与其中的每个组件都没有异步行为。如果 Flux 有一个杀手级特性,那就是这个。更新轮次是 Flux 架构的一个关键特性,我们必须特别小心地维护它。它就像一个伞形概念——由异步行为引起的数十个小边缘情况都超出了它的范围。

封装异步行为

由于 Flux 更新轮次是同步的,我们应该在哪里放置我们的异步代码呢?让我们稍微思考一下。抛开 Flux 架构不谈,任何异步行为在动作完成并与其他代码同步时,都会以某种方式更新系统的状态。在某些架构中,这种情况无处不在,而且没有任何机制来防止这些类型的异步动作从不应调用它们的地方被调用。

例如,Flux 更新轮次绝不应该导致新的异步行为运行。我们知道更新轮次是同步的,所以这是一个不可能的情况。尽管如此,我们确实需要以某种方式封装我们的异步行为。这正是动作创建函数擅长的——在异步部分完成后执行异步工作并管理动作派发。以下是一个动作创建函数封装异步调用的可视化:

封装异步行为

将异步行为保持在动作创建函数中有两个好处。首先,我们知道调用动作创建函数时没有涉及任何同步语义——这一切都由函数为我们处理。第二个优点是,我们所有的异步行为都可以在单个架构层中找到。也就是说,如果有什么是异步的,比如进行 API 调用,我们知道在哪里查找这段代码。

异步动作语义

我们的动作创建函数负责在派发任何动作之前执行任何同步操作。一个动作创建函数有两个部分。第一部分是异步调用(如果有),第二部分是实际的动作派发。这些动作创建函数的职责是将异步调用与 Flux 派发器同步,这意味着函数必须在动作可以派发之前等待某种响应。

这是因为异步操作有有效负载数据。让我们看看一个例子,好吗?这里有一个调用 API 来加载用户对象列表的动作创建函数:

import dispatcher from '../dispatcher';

// The action identifier...
export const LOAD_USERS = 'LOAD_USERS';

// Performs some asynchronous behavior, and once
// complete, dispatches the action.
export function loadUsers() {

  // Creates a new promise, intended to simulate
  // a call to some API function, which would likely
  // also return a promise.
  let api = new Promise((resolve, reject) => {

    // Resolves the promise with some data after half
    // a second.
    setTimeout(() => {
      resolve([
        { id: 1, name: 'User 1' },
        { id: 2, name: 'User 2' },
        { id: 3, name: 'User 3' }
      ]);
    }, 500);
  });

  // When the promise resolves, the callback that's
  // passed to "then()" is called with the resolved
  // value. This is the payload that's dispatched.
  api.then((response) => {
    dispatcher.dispatch({
      type: LOAD_USERS,
      payload: response
    });
  });
}

如您所见,我们使用了一个承诺来代替实际的 API 调用。一般来说,我们的应用程序可能有一个返回承诺的 API 函数调用。这正是我们在做的事情——在我们实际上只是在处理一个承诺时,让它看起来像我们正在与 API 交谈。不管setTimeout()还是实际的 AJAX 响应解决了承诺,其机制都是相同的。

需要注意的重要一点是,loadUsers()函数在承诺解决后负责分发动作。可以这样想——除非我们有新数据供系统使用,否则调度器永远不会被调用。等待部分超出了 Flux 更新轮次,这就是为什么将所有内容都放在这样一个函数中很棒。以下是使用loadUsers()函数的方法:

import dispatcher from './dispatcher';
import { 
  LOAD_USERS, 
  loadUsers
} from './actions/load-users';

// Logs the specific action payloads as
// they're dispatched.
dispatcher.register((e) => {
  switch(e.type) {
    case LOAD_USERS:
      console.log('users', e.payload.map(x =>x.id));
      break;
  }
});

loadUsers();
// → users [1, 2, 3]

注意

在这个例子中可能遗漏的是任何错误处理方式。例如,如果 API 有问题,调用loadUsers()而它默默地失败将是不愉快的。我们将在本章的最后部分更深入地讨论错误处理。

调用 API

在本节中,我们将介绍 Flux 架构中异步行为的常见情况——通过网络调用 API。然后,我们将讨论异步行为在用户交互和 Flux 工具可用性方面的含义。

APIs are the common case

Flux 架构是针对 Web 应用程序的前端。话虽如此,我们的架构的一些组件和后端 API 之间将会有大量的网络通信。这是异步行为的常见情况,不仅限于 Flux,而且在大多数 JavaScript 应用程序中都是如此。因此,在设计直接与这些 API 端点异步通信的动作创建者时,应该在这里强调。以下是 Flux 应用程序中最常见的通信路径:

APIs are the common case

存储需要填充数据,这是获取数据最常见的方式——通过从 API 获取。事实上,用户可能花费更多的时间消费信息而不是与 UI 元素交互。正如您在上一个部分所看到的,使用承诺与调度器同步响应并不困难。

这些类型的 API 调用并不是 Flux 架构中异步数据的唯一来源。例如,使用文件 API 读取文件需要使用异步函数调用。与 Web Workers 交互是另一种异步通信形式——你要求工作者计算某些内容,并通过回调函数的形式获得响应。尽管不如 HTTP 调用常见,但这些异步接口可以像这里所示的那样以相同的方式处理。

APIs are the common case

同样的同步机制——承诺(promises)——可以用于所有这些类型的异步通信通道。就动作创建函数而言,它们都具有相同的接口——一个在稍后时间解决的承诺值。这里的分发语义也是相同的。

由于所有内容都封装在动作创建函数内部,因此 Flux 更新周期中没有异步行为。此外,可能需要多个 API 才能获取动作负载所需的所有数据。我们稍后将讨论这个问题。现在,让我们关注异步动作创建者如何影响用户交互。

API 调用和用户交互

异步调用和用户界面元素的主要挑战是我们必须管理请求的状态,这反过来又反映了 UI 元素的状态。例如,当用户提交表单时,我们必须提供某种视觉指示,表明请求已被提交并且正在处理。此外,我们还需要防止用户在收到请求状态的响应之前与某些 UI 元素交互。

在 Flux 架构中,存储库包含所有应用程序状态,包括我们想要跟踪的任何网络请求的状态。这可以帮助我们协调与给定请求相关的 UI 元素的状态。让我们看看一个发送异步 API 请求以启动某项操作的动作创建者:

import dispatcher from '../dispatcher';

// The action identifier...
export const START = 'START';

export function start() {

  // Simulate an async API call that starts
  // something. The promise resolves after
  // one second.
  let api = new Promise((resolve, reject) => {
    setTimeout(resolve, 1000);
  });

  // Dispatches the action after the promise
  // has resolved.
  api.then((response) => {
    dispatcher.dispatch({ type: START });
  });
}

如您所见,start() 函数在承诺解决后分发 START 动作。就像真正的 API 调用一样,这种延迟为用户提供了足够的时间在调用返回之前与 UI 交互。因此,我们必须采取措施防止这种情况发生。让我们看看另一个动作创建函数,它告诉系统我们刚刚发起的 API 请求的状态:

import dispatcher from '../dispatcher';

export const STARTING = 'STARTING';

export function starting() {
  dispatcher.dispatch({ type: STARTING });
}

通过调用 starting(),我们可以通知可能正在监听的任何存储库,我们即将发起一个 API 调用以启动某项操作。这可能包括我们需要处理 UI 元素的状态,以通知用户请求正在进行中,并在请求发生时禁用用户不应触摸的元素。让我们看看一个处理这些类型操作的存储库。

注意

存储库还处理 STOPSTOPPING 动作。这些模块在这里没有单独列出,因为它们与 STARTSTARTING 动作几乎相同。

import dispatcher from '../dispatcher';
import {
  START,
  STARTING,
  STOP,
  STOPPING
} from '../actions/constants';

import { EventEmitter } from 'events';

class MyStore extends EventEmitter {
  constructor() {
    super();

    this.state = {
      startDisabled: false,
      stopDisabled: true
    };

    dispatcher.register((e) => {
      switch(e.type) {

        // If starting or stopping, we don't want any
        // buttons enabled.
        case STARTING:
        case STOPPING:
          this.state.startDisabled = true;
          this.state.stopDisabled = true;
          this.emit('change', this.state);
          break;

        // Disable the stop button after being started.
        case START:
          this.state.startDisabled = true;
          this.state.stopDisabled = false;
          this.emit('change', this.state);
          break;

        // Disabled the start button after being stopped.
        case STOP:
          this.state.startDisabled = false;
          this.state.stopDisabled = true;
          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new MyStore();

存储器对startstop按钮的禁用状态有明确的表示。如果STARTINGSTOPPING动作被分发,则可以将两个按钮都标记为禁用。在STARTSTOP的情况下,我们可以将适当的按钮标记为禁用,而将另一个标记为启用。现在存储器已经拥有了它们所需的所有状态,让我们现在看看一个实际渲染按钮元素的视图。

注意

你可能想知道为什么我们将这两个动作分别分离成两个动作创建函数——start()starting()。原因很简单:一个动作创建函数分发一个动作。然而,这并不是固定不变的,而是个人偏好的问题。例如,start()可以在实际进行 API 调用之前分发STARTING动作。这里的优点是只有一个函数负责所有事情。缺点是我们失去了动作创建函数与动作之间的一对一对应关系,这可能会引起混淆。

import myStore from '../stores/mystore';
import {
  start,
  starting,
  stop,
  stopping
} from '../actions/functions';

class MyView {
  constructor() {

    // The elements our view interacts with...
    this.start = document.getElementById('start');
    this.stop = document.getElementById('stop');

    // The start button was clicked. Dispatch the
    // "STARTING" action, and the "START" action
    // once the asynchronous call resolves.
    this.start.addEventListener('click', (e) => {
      starting();
      start();
    });

    // The stop button was clicked. Dispatch the
    // "STOPPING" action, and the "STOP" action
    // once the asynchronous call resolves.
    this.stop.addEventListener('click', (e) => {
      stopping();
      stop();
    });

    // When the store state changes, update the UI
    // by enabling or disabling the buttons,
    // depending on the store state.
    myStore.on('change', (state) => {
      this.start.disabled = state.startDisabled;
      this.stop.disabled = state.stopDisabled;
    });
  }
}

export default new MyView();

注意,点击处理程序的主要任务是调用动作创建函数。它们不会执行额外的状态检查以确保可以调用动作,等等。这类事情不应该放在视图中,而应该放在存储器中。我们在这里遵循这种策略,通过改变特定状态来禁用存储器中的按钮。如果我们检查视图事件处理程序中的这类事情,最终会导致状态与其操作逻辑解耦,而在 Flux 中这并不是一个好现象。

组合 API 调用

随着开发进程的推进和功能的日益复杂,我们不可避免地面临复杂的 API 场景。这意味着不再有一个简单的 API 端点可以通过一次调用提供所有功能所需的数据。相反,我们的代码必须将来自不同端点的两个或多个资源拼接在一起,才能获取功能所需的数据。

在本节中,我们将探讨从多个异步资源获取数据并将其作为有效负载数据传递给存储器的动作创建函数。然后,这些存储器将这些数据转换为功能所需的信息。接下来,我们将探讨一种替代方法,即通过较小的动作创建函数组合动作创建函数,每个函数从自己的异步资源中获取数据。

复杂的动作创建函数

有时,单个 API 端点可能没有我们为给定存储器所需的所有数据。这意味着我们必须从多个 API 端点获取数据。挑战在于这些是异步资源,在将它们作为动作有效负载分发之前,需要将它们同步传递给存储器。让我们看看一个从三个异步 API 端点获取数据的动作创建函数。但首先,这是我们将用于模拟异步网络调用的 API 函数:

// API helper function - resolves the given
// "data" after the given MS "delay".
function api(data, delay=1000) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(data);
    }, delay);
  });
}

// The first API...
export function first() {
  return api([ 'A', 'B', 'C' ], 500);
}

// The second API...
export function second() {
  return api([ 1, 2, 3 ]);
}

// The third API...
export function third() {
  return api([ 'D', 'E', 'F' ], 1200);
}

因此,我们从这些 API 函数中获得了一致的返回值——承诺。从给定函数返回的每个承诺都负责同步那个 API 调用。但是,当我们的存储需要将这些已解析的值组合起来形成存储的状态时怎么办呢?现在让我们看看一个处理这个问题的动作创建函数:

import dispatcher from '../dispatcher';

// The mock API functions we need.
import {
  first,
  second,
  third
} from './api';

// The action identifier...
export constMY_ACTION = 'MY_ACTION';

export function myAction() {

  // Calls all three APIs, which all resolve
  // after different delay times. The "Promise.all()"
  // method synchronizes them and returns a new promise.
  Promise.all([
    first(),
    second(),
    third()
  ]).then((values) => {

    // These are the resolved values...
    let [ first, second, third ] = values;

    // All three API calls have resolved, meaning we
    // can now dispatch "MY_ACTION" with the three
    // resolved async values as the payload.
    dispatcher.dispatch({
      type: MY_ACTION,
      payload: {
        first: first,
        second: second,
        third, third
      }
    });
  });
}

只有当所有三个异步值都已解析后,才会分派MY_ACTION动作,因为存储依赖于这三个值。当动作被分派时,所有三个值在单个更新轮次内都可用于存储。关于这段代码,虽然不那么明显但同样重要的是,我们在分派有效载荷之前没有在动作创建函数内部执行任何数据转换。相反,我们以有效载荷属性的形式提供已解析的 API 数据。这确保存储是唯一负责其信息状态的组件。让我们看看存储现在如何使用这个有效载荷:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/myaction';

class MyStore extends EventEmitter {
  constructor() {
    super();

    this.state = [];

    dispatcher.register((e) => {
      switch(e.type) {
        case MY_ACTION:

          // Get the resolved async values from the
          // action payload.
          let { first, second, third } = e.payload;

          // Zip the three arrays and set the resulting
          // array as the store state.
          this.state = first.map((item, i) =>
            [ item, second[i], third[i] ]);

          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new MyStore();

如你所见,存储在有效载荷中拥有执行必要转换所需的一切。让我们调用动作创建函数,看看这个存储是否按预期工作:

import { myAction } from './actions/myaction';
import myStore from './stores/mystore';

myStore.on('change', (state) => {
  console.log('changed', state);
});

myAction();
// → changed
// [
//   [ 'A', 1, 'D' ],
//   [ 'B', 2, 'E' ],
//   [ 'C', 3, 'F' ]
// ]

组合动作创建者

正如你在本章前面看到的,当涉及到用户交互时,我们的动作创建函数调用可能会变得相当冗长。这是因为我们必须对动作创建函数进行两个或更多的调用。一个调用确保在用户等待异步操作完成时 UI 元素处于适当的状态。另一个调用调用异步行为。为了避免在所有地方都进行两次调用,我们可以在动作创建函数中分派两个动作。然而,这并不总是理想的,因为我们可能在某个时候需要调用第一个动作创建函数而不调用第二个动作创建函数。这更多是一个粒度问题。

简单的解决方案是从两个函数中组合出一个函数。这样,我们保持了粒度,同时减少了在许多地方需要调用的函数数量。让我们回顾一下我们之前的代码,当时我们必须手动调用starting()然后start()

import { start as _start } from './start';
import { starting } from './starting';
import { stop as _stop } from './stop';
import { stopping } from './stopping';

// The "start()" function now automatically
// calls "starting()".
export function start() {
  starting();
  _start();
}

// The "stop()" function now automatically
// calls "stopping()"
export function stop() {
  stopping();
  _stop();
}

// Export "starting()" and "stopping()" so
// that they can still be used on their
// own, or composed into other functions.
export { starting, stopping };

现在视图可以直接调用start()stop(),并将必要的状态更改应用到相关的 UI 元素上。这是因为第一个动作创建函数是同步的——这意味着在执行异步调用之前,完整的 Flux 更新轮次就已经完成。这种行为是一致的,无论什么情况。我们开始遇到问题的地方是当我们开始从几个异步动作创建者中组合函数时,如这里所示:

组合动作创建者

这里的问题是,我们用来组合action()结果的每个asyncAction()函数都会导致一个更新轮次。首先发生的更新轮次是一个竞争条件。我们不能将它们组合成一个对多个 API 端点发出请求的单个动作创建器,因为它们服务于两个不同的存储。Flux 的一切都是关于可预测的数据流,这意味着始终知道更新轮次的顺序。在下一节中,我们将重新审视动作创建器函数中的承诺,以帮助我们解决这些棘手的异步动作创建器场景。

返回承诺

本章到目前为止,我们查看的所有动作创建器函数都没有返回任何值。这是因为它们的主要任务是分发动作,同时隐藏任何并发同步语义。另一方面,动作创建器函数可以返回一个承诺,这样我们就可以组合跨越多个存储的更复杂的异步行为。在上一节中,我们看到了使用动作创建器函数组合异步行为可能会很困难,甚至不可能。

在本节中,我们将重新审视在组合更大功能时异步行为带来的挑战。然后,我们将创建一个示例实现,使用返回承诺的动作创建器,并通过它们相互同步。最后,我们将看看从动作创建器返回承诺是否可以帮助我们处理在与我们通信的异步资源中发生的错误。

无承诺的同步

Flux 架构的一个优点是它的大部分都是同步的。例如,当我们用一个新动作和一个新有效负载调用分发器时,我们可以确信调用将阻塞,直到更新轮次完成,UI 中的所有内容都反映了当前的状态。在异步行为中,情况就不同了——特别是在 Flux 架构中,这种类型的行为严格限于动作创建器函数。因此,我们面临着从大量的异步资源中拼凑复杂系统的必然挑战。

我们在本章早期看到了如何部分实现这一点。单个动作创建器函数可以将几个异步资源的解决值组合成一个动作和一个有效负载。然后,存储中的逻辑可以找出如何利用这些数据并更新其状态。当只有一个存储参与时,这工作得很好,但当我们试图在多个存储和功能之间同步资源时,就会失败。

这时,能够同步异步数据和 Flux 更新轮次变得很重要。为了做到这一点,我们的动作创建器函数需要返回在两者都完成时解决的承诺。以下是我们需要完成的工作的说明:

无承诺的同步

组合异步行为

要绕过这些棘手的异步动作创建器场景,这些函数应该返回在异步行为和更新轮完成后解决的承诺。这会让调用者知道更新轮已完成,并且我们现在调用的任何内容都将在此之后发生。我们追求的是一致性,所以让我们看看一个返回承诺的动作创建器函数:

// The action identifier...
export const FIRST = 'FIRST';

// The API function that returns a promise that's
// resolved after 1.5 seconds.
function api() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ first: 'First Name' });
    }, 1500);
  });
}

export function first() {

  // Returns a promise so that the caller
  // knows when the update round is complete,
  // regardless of the asynchronous behavior
  // that takes place before the action is dispatched.
  return new Promise((resolve, reject) => {
    api().then((response) => {

      // Action is dispatched after the asynchronous
      // value is resolved.
      dispatcher.dispatch({
        type: FIRST,
        payload: response
      });

      // Resolve the promise returned by "first()",
      // after the update round.
      resolve();
    });
  });
}

因此,这个动作创建器调用一个在 1.5 秒后解决的异步 API,此时动作有效载荷被分发,返回的承诺被解决。让我们看看另一个使用不同 API 函数的动作创建器:

import dispatcher from '../dispatcher';

// The action identifier...
export const LAST = 'LAST';

// The API function that returns a promise that's
// resolved after 1.5 seconds.
function api() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ last: 'Last Name' });
    }, 1000);
  });
}

export function last() {
  return new Promise((resolve, reject) => {
    api().then((response) => {
      dispatcher.dispatch({
        type: LAST,
        payload: response
      });

      resolve();
    });
  });
}

你可以看到,这两个动作创建器函数——first()last()——通过返回承诺遵循相同的策略。然而,API 函数解析不同的数据,并且只需要 1 秒钟就能完成。让我们看看当我们尝试一起使用这两个函数时会发生什么:

import dispatcher from './dispatcher';
import { FIRST, first } from './actions/first';
import { LAST, last } from './actions/last';

// Logs the payload as actions are dispatched...
dispatcher.register((e) => {
  switch (e.type) {
    case FIRST:
      console.log('first', e.payload.first);
      break;
    case LAST:
      console.log('last', e.payload.last);
      break;
  }
});

// Order of update rounds isn't guaranteed here.
first();
last();
// →
// last Last Name
// first First Name

// With promises, update round order is consistent.
first().then(last);
// →
// first First Name
// last Last Name

处理错误

当 Flux 动作创建器交互的 API 失败时会发生什么?一般来说,当我们进行 AJAX 调用时,我们提供成功和错误回调函数。这样,我们可以优雅地失败。我们必须小心处理 Flux 动作创建器中的失败,因为,就像存储需要知道动作一样,他们也需要知道失败。

所以问题是——我们在动作创建器函数中做了什么不同的事情?当 API 失败时,我们只是在动作创建器内部分发某种错误动作吗?我们确实想分发一个错误动作,以便存储可以相应地调整其状态,但动作创建器的调用者怎么办?例如,我们可能有一个通用的动作创建器函数,它在许多地方使用,并且错误处理可能是上下文相关的。

答案是让动作创建器返回的承诺拒绝。这允许调用者在 API 调用失败的情况下指定自己的行为。让我们看看一个以这种方式处理错误的动作创建器函数:

import dispatcher from '../dispatcher';

// The action identifier...
export const UPDATE_TASK = 'UPDATE_TASK';

// The action error identifier...
export const UPDATE_TASK_ERROR = 'UPDATE_TASK_ERROR';

// Returns a promise that's rejected with an error
// message after 0.5 seconds.
function api() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject('Failed to update task');
    }, 500);
  });
}

export function updateTask() {
  return new Promise((resolve, reject) => {

    // Dispatches the "UPDATE_TASK" action as usual
    // when the promise resolves. Then resolves
    // the promise returned by "updateTask()".
    api().then((response) => {
      dispatcher.dispatch({
        type: UPDATE_TASK
      });

      resolve();

    // If the API promise rejects, reject the promise
    // returned by "updateTask()" as well.
    }, (error) => {
      reject(error);
    });
  });
}

// A basic helper action creator for when the
// "updateTask()" function is rejected.
export function updateTaskError(error) {
  dispatcher.dispatch({
    type: UPDATE_TASK_ERROR,
    payload: error
  });
}

现在让我们调用这个updateTask()函数,看看我们是否可以给它分配错误处理行为:

import dispatcher from './dispatcher';
import {
  UPDATE_TASK,
  UPDATE_TASK_ERROR,
  updateTask,
  updateTaskError
} from './actions/update-task';

// Logs the payload as actions are dispatched...
dispatcher.register((e) => {
  switch (e.type) {
    case UPDATE_TASK:
      console.log('task updated');
      break;
    case UPDATE_TASK_ERROR:
      console.error(e.payload);
      break;
  }
});

// We can tell "updateTask()" how to respond when
// the underlying API call fails.
updateTask().catch(updateTaskError);
// → Failed to update task

摘要

本章重点介绍了 Flux 架构中的异步动作创建器。这些是需要分发动作的函数,但在它们可以这样做之前,它们必须等待某些异步资源解决。我们探讨了同步更新轮的概念,这是任何 Flux 架构的核心。然后,我们讨论了动作创建器如何封装异步行为,以便它们保留同步更新轮。

网络调用是 JavaScript 应用程序中最常见的异步通信形式,包括 Flux 架构。我们涵盖了这些与其他异步通道之间的区别,以及如何使用承诺来弥合它们之间的差距。我们还探讨了承诺如何被动作创建器函数利用,以允许更复杂功能的组合。

在下一章中,我们将更深入地探讨存储以及它们在保持我们的 Flux 架构中一致状态所需做的一切。

第六章。改变 Flux 存储状态

本章讲述了我们 Flux 存储的持续进化,因为我们所实现的应用功能推动了架构的改进。事实上,这正是 Flux 架构擅长的——随着应用的变化而适应变化。本章深入探讨了存储设计的改变,并强调了存储将经常发生变化的观点。对我们存储的更高层次改变可能是必要的,例如引入由多个其他存储共享的通用存储,这些存储针对特定的功能。随着存储的进化,它们之间的依赖关系也会发生变化;我们将探讨如何使用分发器来管理存储间的依赖关系。我们将以讨论如何控制存储复杂性来结束本章。

适应变化的信息

在本书的早期,我提到存储不是 MV*架构中的模型。它们在多个方面都不同,包括它们处理其他架构领域(如 API 和变化的功能需求)中变化模式的能力。在本节中,我们将探讨 Flux 存储适应变化 API 的能力。我们还将讨论变化的相反方向,即当消耗存储数据的视图有变化需求时。最后,我们将讨论其他可能作为存储持续进化的直接结果的组件发生变化。

改变 API 数据

API 数据的变化,尤其是在开发的早期阶段。尽管我们告诉自己,某个 API 将随着时间的推移而稳定下来,但在实践中这很少奏效。或者,如果 API 确实变得稳定且不变,我们最终不得不使用不同的 API。安全的假设是,这些数据将会变化,我们的存储将需要适应这些变化。

Flux 存储的美丽之处在于它们更多地由功能驱动,而不是由 API 驱动。这意味着 API 数据的变化对存储的影响较小,因为它们的任务是转换 API 数据为功能所需的信息。以下是这个想法的可视化:

改变 API 数据

与模型不同,我们并不是试图将 API 数据原封不动地表示在存储中。存储保留着状态,这些状态是客户使用的功能所消耗的信息。这意味着当特定存储所依赖的 API 数据发生变化时,我们只需重新审视创建特征信息的转换函数。另一方面,被应用中许多不同视图和许多不同功能使用的模型在应对这些 API 数据变化时则更加困难。这是因为这些组件与 API 数据的模式有关,而不是与我们需要渲染的 UI 元素相关的实际状态。

在 API 更改发生后,我们是否总能重新创建我们在架构中使用的功能信息?不一定。这需要我们重新审视我们的视图如何与存储交互。例如,如果某个 API 模式中完全删除了属性,这很可能会要求我们在存储中进行比简单转换更新更复杂的更新。但这是一种罕见的情况;最常见的情况是,Flux 存储可以轻松适应变化的 API 数据。

改变功能功能

存储通过变化的 API 数据发生变化和演变。这可能会影响依赖于存储的功能可用的信息。随着我们的应用程序的增长,存储可能会面临相反方向的压力——改变功能功能通常需要新的信息。这个概念在以下图中得到了说明:

改变功能功能

不是 API 数据单独决定transform()函数中发生什么,而是相反。功能和驱动它的信息作为存储转换设计的设计输入。这实际上可能比适应变化的 API 数据更困难。有两个主要原因。

首先,是信息本身。存储可以将资源转换为功能所需的内容。但存储并不是神奇的——API 数据需要在数据方面提供至少基本需求;否则,我们就会陷入死胡同。其次,还有 UI 元素本身,其中一些需要存储来捕获状态。结合这两个因素可能会带来挑战。

最好尽早而不是晚些时候回答这些关于信息的困难功能相关问题。能够朝这个方向工作意味着我们正在让用户关心的信息驱动设计,而不是让可用的 API 决定可能发生的事情。

受影响的组件

正如我们在本节前面所看到的,存储将它们的数据源转换为用户功能可消费的信息。这是 Flux 的一个很好的架构特性,因为它意味着监听这些存储的视图不需要不断改变,以适应对 API 所做的更改。然而,当存储演变时,我们需要保持对其他组件影响的警觉。

让我们暂时思考一下动作。当 API 数据发生变化时,这很可能会产生我们需要分派的新动作吗?不,因为我们很可能会处理现有的系统入口点——这些动作已经存在。关于功能功能,这会导致新的动作吗?这是可能的,因为我们可能会看到新的用户交互被引入到功能中,或者新的数据和 API。现有的动作有效载荷也可以随着响应变化的 UI 元素而演变,例如。

另一个需要考虑的是,存储变化对依赖于它的其他存储的影响。变化后,它是否还能获取所需的信息?视图并不是唯一具有存储依赖的 Flux 组件。我们将在本章后面更深入地探讨存储间的依赖关系。

减少重复存储数据

存储帮助我们将架构中找到的状态分离成特性。这很好,因为我们可以在不同的特性之间有截然不同的数据结构。或者,我们可能会发现,随着新特性的引入,大量相同的数据开始出现在不同的存储中。没有人愿意重复自己——这是低效的,我们可以做得更好。

在本节中,我们将介绍通用存储的概念。这类存储不一定被视图使用,而是由其他存储作为公共数据的一种存储库。然后我们将介绍通用存储的基本设置以及如何在我们的更专业化的存储中使用通用存储。

通用存储数据

通用存储类似于类层次结构中的父类。父类具有在多个子类中找到的常见行为和属性。然而,与类层次结构不同,我们没有多个结构层级。通用存储在 Flux 架构中的目标相当简单——尽可能减少重复。以下是一个通用存储的示例:

通用存储数据

这允许具有特定功能的服务存储共享也通用的状态。否则,每个更新轮次都不得不在不同的存储上执行相同的更新。最好将更新保留在一个地方,让存储查询通用存储以计算它们自己的状态。

注意

需要指出的是,特定存储实际上并没有从通用存储那里继承任何东西,就像子类从其父类继承属性一样。将通用存储视为实例,就像任何其他存储一样。同样,就像任何其他存储一样,通用存储会从分发器接收动作来计算状态变化。

注册通用存储

在数据依赖方面,例如我们最终将在我们的 Flux 架构中的存储中找到的依赖,顺序很重要。例如,如果一个特定存储在更新轮次中先于它所依赖的存储被处理,我们可能会得到意外的结果。通用存储始终需要首先处理动作,以便它有机会在任何依赖存储访问它之前执行任何转换并设置其状态。

让我们看看一个例子。首先,我们将实现一个通用存储,它接受一组文档对象并将其映射到一组文档名称:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { LOAD_DOC } from '../actions/load-doc';

// The generic "Docs" store keeps an index
// of document names, since they're used
// by many other stores.
class Docs extends EventEmitter {
  constructor() {
    super();

    this.state = [];

    dispatcher.register((e) => {
      switch(e.type) {
        case LOAD_DOC:

          // When a "LOAD_DOC" action is dispatched,
          // we take the "payload.docs" data and
          // transform it into the generic state that's
          // required by many other stores.
          for (let doc of e.payload.docs) {
            this.state[doc.id] = doc.name;
          }

          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new Docs();

接下来,我们将实现一个依赖于这个通用Docs存储库的特定存储库。它将是一个特定的文档,用于显示文档名称的页面。这个存储库将必须根据通用存储库中的id属性定位名称:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import docs from './docs';
import { LOAD_DOC } from '../actions/load-doc';

// The specific store that depends on the generic
// "docs" store.
class Doc extends EventEmitter {
  constructor() {
    super();

    this.state = {
      name: ''
    };

    dispatcher.register((e) => {
      switch(e.type) {
        case LOAD_DOC:

          // The "id" of the document...
          let { id } = e.payload;

          // Here's where the generic store data
          // comes in handy - we only care about
          // the document name. We can use the "id"
          // to look this up from the generic store.
          this.state.name = docs.state[id];

          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new Doc();

让我们停下来思考一下我们在这里做了什么,以及我们为什么要这样做。这个通用的Docs存储库实现了一个转换,将文档数据集合映射到名称数组。我们之所以这样做,是因为我们还有几个其他存储库需要通过id查找文档名称。如果只有Doc存储库需要这些数据,那么这几乎不值得实现。我们的想法是减少重复,而不是引入间接性。

说到这里,让我们看看这两个存储库都会监听的动作创建函数:

import dispatcher from '../dispatcher';

// The action identifier...
export constLOAD_DOC = 'LOAD_DOC';

// Loads the name of a specific document.
export function loadDoc(id) {

  // The API data resolves raw document data...
  new Promise((resolve, reject) => {
    resolve([
      { id: 1, name: 'Doc 1' },
      { id: 2, name: 'Doc 2' },
      { id: 3, name: 'Doc 3' }
    ]);
  }).then((docs) => {

    // The payload contains both the raw document
    // collection and the specific document "id".
    // The generic "docs" store uses the raw
    // "docs" data while the specific store depends
    // on this generic collection.
    dispatcher.dispatch({
      type: LOAD_DOC,
      payload: {
        id: id,
        docs: docs
      }
    });
  });
}

如您所见,这个函数接受一个文档id作为参数,并异步调用以加载所有文档。一旦加载完成,就会分发LOAD_DOC动作,两个存储库可以设置它们的状态。那么挑战就变成了——我们如何确保在依赖于它的任何存储库之前更新通用存储库?让我们看看main.js模块,看看这个动作创建函数以及两个存储库是如何被投入使用的:

// We have to import the generic "docsStore", even though
// we're not using it here, so that it can register with
// the dispatcher and respond to "LOAD_DOC" actions.
import docsStore from './stores/docs';
import docStore from './stores/doc';
import { loadDoc } from './actions/load-doc';

// Logs the data our specific store gets from
// the generic store.
docStore.on('change', (state) => {
  console.log('name', `"${state.name}"`);
});

// Load the document with an id of 2.
loadDoc(2);
// → name "Doc 2"

当调用loadDoc(2)时,特定存储库的状态设置正如我们所期望的那样。这仅仅是因为我们将两个存储库导入main.js的顺序。实际上,如果我们交换顺序,在导入docStore之前导入docsStore,那么我们就不会看到我们期望的结果。原因是简单的——存储库注册到调度器的顺序决定了它们处理动作的顺序。在本章的后面,我们将探讨一种处理存储库依赖关系不那么繁琐的方法。

结合通用和特定数据

通用存储库的优点是它们可以直接由视图使用。也就是说,它们不是某种抽象概念。这些相同的存储库也可以由更具体的存储库使用,以扩展它们的数据并将它们的州转换成其他视图所需的东西。让我们看看一个特定存储库如何将更通用存储库的状态与其自己的状态结合的例子。我们将首先查看一个通用组存储库:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { LOAD_GROUPS } from '../actions/load-groups';

// A generic store for user groups...
class Groups extends EventEmitter {
  constructor() {
    super();

    this.state = [];

    dispatcher.register((e) => {
      switch(e.type) {

        // Stores the payload of a group array "as-is".
        case LOAD_GROUPS:
          this.state = e.payload;
          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new Groups();

在状态转换方面,这里并没有太多的事情发生——存储库只是将有效载荷设置为它的状态。现在,我们将看看更具体的用户存储库,它依赖于组存储库:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import groups from './groups';
import { LOAD_USERS } from '../actions/load-users';

// A users store that depends on the generic
// groups store so that it can perform the necessary
// state transformations.
class Users extends EventEmitter {
  constructor() {
    super();

    this.state = [];

    dispatcher.register((e) => {
      switch(e.type) {
        case LOAD_USERS:

          // We only want to keep enabled users.
          let users = e.payload.filter(
            x => x.enabled);

          // Maps to a new users array, each user object
          // containing a new "groupName" property. This
          // comes from the generic group store, and is
          // looked up by id.
          this.state = users.map(
            x =>Object.assign({
              groupName: groups.state.find(
                y =>y.id === x.group
              ).name
            }, x));

          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new Users();

在这个存储库中发生的状态转换要复杂一些。LOAD_USERS有效载荷是一个用户对象的数组,每个对象都有一个group属性。然而,观察这个存储库的视图需要的是组名,而不是id。因此,在这里我们执行映射,创建一个新的用户对象数组,这个数组包含视图所需的groupName属性。下面是loadUsers()动作创建函数的示例:

import dispatcher from '../dispatcher';

// The action identifier...
export constLOAD_USERS = 'LOAD_USERS';

// Dispatches a "LOAD_USERS" action once the
// asynchronous data has resolved.
export function loadUsers() {
  new Promise((resolve, reject) => {
    resolve([
      { group: 1, enabled: true, name: 'User 1' },
      { group: 2, enabled: false, name: 'User 2' },
      { group: 2, enabled: true, name: 'User 3' }
    ]);
  }).then((users) => {
    dispatcher.dispatch({
      type: LOAD_USERS,
      payload: users
    });
  });
}

接下来,这是如何加载通用组的数据,然后是依赖于它的用户数据的:

import groupsStore from './stores/groups';
import usersStore from './stores/users';
import { loadGroups } from './actions/load-groups';
import { loadUsers } from './actions/load-users';

// Log the state of the "usersStore" to make
// sure that includes data from the generic
// "groupsStore"
usersStore.on('change', (state) => {
  state.forEach(({ name, groupName }) => {
    console.log(`${name} (${groupName})`);
  });
});

// We always load the generic data first. Especially
// if it doesn't change often.
loadGroups();
loadUsers();
// →
// User 1 (Group 1)
// User 3 (Group 2)

这样的通用存储数据如果被许多其他特定存储使用,并且其状态不经常改变,特别有用。例如,加载这个通用存储数据可能是应用程序初始化活动的一部分,之后就不需要再接触它了。

处理存储依赖

到目前为止,在这本书中,我们隐式地处理了我们的 Flux 存储依赖。我们导入存储模块的顺序决定了处理动作的顺序,如果我们所依赖的东西还没有更新,这会有影响。是时候开始用更多的严谨性来处理我们的存储依赖了。

在本节中,我们将介绍 Flux 调度器的 waitFor() 机制来管理存储依赖。然后,我们将讨论我们可能遇到的两种存储依赖类型。第一种依赖严格相关于应用程序数据。第二种依赖与 UI 元素相关。

等待存储

调度器有一个内置机制,允许我们显式解决存储依赖。更重要的是,依赖在回调函数中声明,即依赖实际被使用的地方。让我们看看一个突出显示处理存储依赖改进代码的例子。首先,我们有一个基本的存储,它没有做太多:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

class Second extends EventEmitter {
  constructor() {
    super();

    // Registering a callback with the dispatcher
    // returns an identifier...
    this.id = dispatcher.register((e) => {
      switch(e.type) {
        case MY_ACTION:
          this.emit('change');
          break;
      }
    });
  }
}

export default new Second();

你会注意到这个存储看起来略有不同。我们将 dispatcher.register() 的返回值分配给存储的 id 属性。这个值用于识别我们在调度器中刚刚注册的回调函数。现在,让我们定义一个依赖于这个存储的存储,这样我们就可以看到为什么这个 id 属性是相关的:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';
import second from './second';

class First extends EventEmitter {
  constructor() {
    super();

    // Registering a callback with the dispatcher
    // returns an identifier...
    this.id = dispatcher.register((e) => {
      switch(e.type) {
        case MY_ACTION:

          // This tells the dispatcher to process any
          // callback functions that were registered
          // to "second.id" before continuing here.
          dispatcher.waitFor([ second.id ]);
          this.emit('change');
          break;
      }
    });
  }
}

export default new First();

id 属性被 dispatcher.waitFor() 调用所使用。这个调度器的这个方法强制在继续进行状态转换之前,将动作分发给我们所依赖的存储。这确保了我们始终在依赖的存储中使用最新数据。让我们看看 myAction() 函数的使用情况,以及我们两个存储之间的依赖管理是否按预期工作:

// The order of store imports no longer matters,
// since the stores use the dispatcher to
// explicitly handle dependency resolution.
import first from './stores/first';
import second from './stores/second';
import { myAction } from './actions/my-action';

// The first store changed...
first.on('change', () => {
  console.log('first store changed');
});

// The second store changed...
second.on('change', () => {
  console.log('second store changed');
});

// Dispatches "MY_ACTION"...
myAction();

main.js 中,或者架构中的任何其他地方,事情发生的顺序不再重要。依赖在它重要的地方声明,靠近使用依赖数据的代码。这是由调度器组件强制执行的。

注意

注意,waitFor() 方法接受一个 ID 数组。这意味着在更复杂的场景中,如果我们依赖于多个存储的状态,我们可以传递我们依赖的每个存储 ID。然而,更常见的情况是依赖于一个存储的状态。如果架构中到处都是多存储依赖,这可能意味着复杂性过高。

数据依赖

在 Flux 存储中,有两种值得思考的依赖类型。最常见的是数据依赖。当特定存储依赖于通用存储时,就存在这种依赖关系——它有一些通用数据,多个存储需要访问。这种应用程序数据通常来自 API,并最终由视图渲染。然而,当我们谈论数据依赖时,并不局限于通用存储。

例如,假设我们有一个用户界面,主布局通过标签页进行分隔。在我们的 Flux 架构中,存储与这些标签页一致。如果一个存储执行 API 调用,然后进行一些数据转换以设置其状态,另一个存储可以依赖这个存储来使用这些数据吗?共享此类数据是有意义的,否则,我们不得不重复相同的 API 请求、数据转换等等——这会变得重复且我们希望避免这种情况。

然而,当存储明确建模顶级功能,如标签页时,我们开始注意到一些并非严格与数据相关的其他依赖。这些是 UI 依赖,并且完全可能存在。例如,用户在一个标签页中看到的内容可能依赖于另一个标签页中复选框的状态。以下是两种存储依赖类型的说明:

数据依赖

UI 依赖

在典型的前端架构中,UI 元素的状态可能是状态建模中最容易出错的部分。UI 元素的主要问题是,当我们没有明确建模它们的状态时,当这些状态发生变化时,我们很难理解因果关系。当一个 UI 元素的状态依赖于另一个 UI 元素的状态时,这尤其麻烦。我们最终得到的是将这些项目隐式关联在一起的代码。

Flux 存储在处理此类依赖关系方面更胜一筹,因为在存储中,UI 依赖与数据依赖相同——它们都是状态。我们能够轻松地在 Flux 架构中做到这一点是个好事,因为这些类型的依赖关系往往会迅速变得复杂。为了说明 Flux 如何处理 UI 依赖,让我们来看一个例子。我们将为 UI 的不同部分创建两个存储:一个用于复选框,一个用于标签。其理念是标签依赖于复选框的状态,因为当复选框改变时,它们的样式也会改变。

首先,我们有代表复选框元素的存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { FIRST } from '../actions/first';
import { SECOND } from '../actions/second';

class Checkboxes extends EventEmitter {
  constructor() {
    super();

    this.state = {
      first: true,
      second: true
    };

    // Sets the dispatch id of this store
    // so that other stores can depend on it.
    // Depending on the action, this handler
    // changes the boolean UI state of a given
    // checkbox.
    this.id = dispatcher.register((e) => {
      switch(e.type) {
        case FIRST:
          this.state.first = e.payload;
          this.emit('change', this.state);
          break;
        case SECOND:
          this.state.second = e.payload;
          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new Checkboxes();

该存储通过两个复选框元素进行建模——firstsecond。状态是布尔值,选中时为 true,未选中时为 false。默认情况下,两个复选框都被选中,当 FIRSTSECOND 动作被分发时,相应复选框的状态会更新以反映有效载荷。现在让我们看看依赖于 Checkboxes 存储状态的 Labels 存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { FIRST } from '../actions/first';
import { SECOND } from '../actions/second';
import checkboxes from './checkboxes';

class Labels extends EventEmitter {
  constructor() {
    super();

    // The initial state of this store depends
    // on the initial state of the "checkboxes"
    // store.
    this.state = {
      first: checkboxes.state.first ?
        'line-through' : 'none',
      second: checkboxes.state.second ?
        'line-through' : 'none'
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // The "FIRST" action was dispatched, so wait
        // for the "checkboxes" UI state, then update
        // the UI state of the "first" label.
        case FIRST:
          dispatcher.waitFor([ checkboxes.id ]);

          this.state.first = checkboxes.state.first ?
            'line-through' : 'none';

          this.emit('change', this.state);
          break;

        // The "SECOND" action was dispatched, so wait
        // for the "checkboxes" UI state, then update
        // the UI state of the "second" label.
        case SECOND:
          dispatcher.waitFor([ checkboxes.id ]);

          this.state.second = checkboxes.state.second ?
            'line-through' : 'none';

          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new Labels();

你可以看到,这个存储的初始状态实际上也依赖于Checkboxes存储的状态。这个存储中firstsecond状态属性的值实际上是 CSS 值。在这里对这些值进行建模很重要,因为毕竟,这些都是状态——所有状态都进入存储。这意味着以后其他东西可以依赖于这些值。当一切都很明确时,我们知道事情为什么会是这样,这转化为稳定的软件。

现在,让我们看看使用这些存储来渲染 UI 元素和响应用户输入的视图。首先,是Checkboxes视图:

import checkboxes from '../stores/checkboxes';
import { first } from '../actions/first';
import { second } from '../actions/second';

class Checkboxes {
  constructor() {

    // The DOM elements our view manipulates (these
    // are checkboxes).
    this.first = document.getElementById('first');
    this.second = document.getElementById('second');

    // Dispatch the appropriate action when either
    // of the checkboxes change. The action payload
    // is the "checked" property of the UI element.
    this.first.addEventListener('change', (e) => {
      first(e.target.checked);
    });

    this.second.addEventListener('change', (e) => {
      second(e.target.checked);
    });

    // When the "checkboxes" store changes state,
    // render the view using the new state.
    checkboxes.on('change', (state) => {
      this.render(state);
    });

  }

  // Sets the "checked" properties of the checkbox
  // UI elements. By default, we use the initial
  // state of the "checkboxes" store. Otherwise,
  // we use whatever state is passed.
  render(state=checkboxes.state) {
    this.first.checked = state.first;
    this.second.checked = state.second;
  }
}

export default new Checkboxes();

这里使用了两个复选框元素,在视图的构造函数中做的第一件事是为复选框设置change事件处理器。这些处理器将根据复选框及其选中状态分派适当的操作——FIRSTSECONDrender()函数实际上根据状态更新 DOM。现在,让我们看看Labels视图:

import labels from '../stores/labels';

class Labels {
  constructor() {

    // The DOM elements this view manipulates (these
    // are labels).
    this.first = document.querySelector('[for="first"]');
    this.second = document.querySelector('[for="second"]');

    // When the "labels" store changes, render
    // the view using the new state.
    labels.on('change', (state) => {
      this.render(state);
    });
  }

  // Updates the "textDecoration" style of our
  // label UI elements, using the "labels" store
  // state as the default. Otherwise, we use whatever
  // state is passed in.
  render(state=labels.state) {
    this.first.style.textDecoration = state.first;
    this.second.style.textDecoration = state.second;
  }
}

export default new Labels();

这个视图的工作方式与Checkboxes视图类似。主要区别在于这里没有用户交互,并且对 UI 元素所做的更改是Labels存储中设置的样式属性值。这些最终取决于Checkboxes存储的状态,因此当用户更改复选框的状态时,他们会看到相应的标签样式发生变化。

如果这感觉像是一大堆代码来完成一个简单的任务,那是因为确实如此。记住,我们在这里实际上完成的事情远不止简单的复选框切换和标签样式更新。我们已经在 UI 的两个不同部分之间建立了明确的 UI 状态依赖关系。这对我们的架构来说是一个胜利,因为当给定的架构在扩展时遇到困难时,我们无法弄清楚为什么会发生这种情况。在整个 Flux 架构的生命周期中,我们积极采取措施确保这种情况不会发生,就像我们刚才在这里所展示的那样。

查看更新顺序

虽然能够使用waitFor()显式控制存储的依赖性很方便,但视图没有这样的奢侈。在本节中,我们将查看我们的视图渲染 UI 元素的顺序。首先,我们将查看存储在视图更新顺序中扮演的角色。然后,我们将讨论视图顺序实际上影响用户体验的情况,以及那些顺序并不重要的情况。

存储注册顺序

将操作分派给存储的顺序很重要。当存储转换其状态时,它也会通知任何监听存储的视图。这意味着如果某个视图正在监听首先注册到调度器的存储,那么这个视图将在任何其他视图之前渲染。这个想法在这里得到了说明:

存储注册顺序

如您所见,调度器内存储回调函数的顺序明显影响了视图的渲染顺序。存储依赖也可以影响视图渲染的顺序。例如,如果存储 A 依赖于存储 B,那么任何监听存储 B 的视图将会首先渲染。这可能是无关紧要的,或者可能会有一些有趣的副作用。我们将在下一节中查看这两种结果。

优先渲染视图

由于构成我们 Flux 架构核心的存储也可以确定我们视图的渲染顺序,我们必须注意根据用户体验来优先处理。例如,我们可能有一个表示页面顶部区域头部存储的存储,另一个存储用于主要内容区域。现在,如果主要内容区域首先渲染,在页面顶部留下一个明显的空白,我们将想要修复这个问题。

由于用户将从页面顶部开始并向下工作,我们必须确保首先注册头部内容的存储。我们如何做到这一点?再一次,我们又回到了处理存储依赖时的那个地方。我们必须注意以正确的顺序导入我们的视图——一个反映渲染顺序的顺序。正如我们所看到的,这并不是一个理想的情况。

一个解决方案是引入存储依赖。即使内容存储实际上并没有使用来自头部存储的任何数据,它仍然可以为了渲染顺序的目的而依赖它。通过使用waitFor()方法,我们会知道任何监听头部存储的视图将会首先渲染,从而消除与渲染顺序相关的可用性问题。当然,这里的风险与任何存储依赖相同——复杂性。当我们达到有太多存储依赖难以理解的程度时,就是时候重新思考我们的存储设计了。

处理存储复杂性

Flux 存储复杂性的主要原因是依赖管理。尽管有调度器作为管理这些依赖的工具,但当依赖太多时,总会有些东西会丢失。在本章的最后部分,我们将讨论在架构中拥有太多存储的后果以及可以采取哪些措施来解决这个问题。

存储过多

我们应用程序的最高级特性在为我们的存储和它们封装的状态提供边界方面做得相当不错。存储的挑战在于当它们太多时。例如,随着我们的应用程序随着时间的推移而增长,将会有更多特性被构建,这转化为更多存储被投入到架构中。此外,现有的存储也可能会变得更加复杂,因为它们必须找到与其他应用程序中所有变化特性相协调的方法。

这导致了一个复杂的情况——商店的复杂性不断增长,总体上商店数量也在增加。这几乎肯定会导致依赖关系的爆炸性增长,因为我们会挖掘出用户界面的所有边缘情况。由许多特定商店共享的通用商店也可能成为问题的来源。例如,我们可能会拥有过多的通用商店,最终导致所有状态数据都是间接的。

当我们达到架构中商店数量无法承受的程度时,是时候开始重新思考构成功能的内容了。

重新思考功能域

当顶层功能映射到我们的商店通常足够好,直到我们有很多顶层功能。在这个时候,是时候重新评估功能映射到商店的策略了。例如,如果我们有很多顶层功能,那么很可能有很多可以合并到单个商店中的相似数据,这个商店可以驱动许多功能。减少驱动我们功能的商店数量的另一个潜在影响是移除通用商店。通用商店只有在我们有太多重复数据时才有效,而当商店数量减少时,这通常不是一个问题。以下是一个展示商店如何成为多个功能驱动者的图表:

重新思考功能域

我们也可能发现自己处于相反的情况,即商店的复杂性过于巨大,我们需要通过重构将其分解成多个商店来减少其责任。为了解决这个问题,我们必须考虑如何将一个大功能分解成两个更小的功能。如果我们想不出一个好的方法来划分功能,那么商店的复杂性可能就是我们能做的最好的,它应该保持原样。

摘要

在本章中,我们详细探讨了 Flux 架构中的商店,从我们离开骨架架构阶段后最有可能发生变化的角度开始。然后我们介绍了通用商店的概念,其目的是减少单个商店需要保持的状态量。通用商店的尴尬之处在于它们引入的依赖关系,为了处理这些依赖关系,你学习了如何使用派发器的waitFor()机制。

商店之间的依赖关系有两种类型,数据依赖和 UI 依赖,你了解到 UI 依赖是任何 Flux 架构的关键部分。最后,我们讨论了商店在复杂性方面可能失控的一些方式,以及如何应对这种情况。在下一章中,我们将探讨 Flux 架构中的视图组件。

第七章:视觉信息

视图层是 Flux 架构中数据流的最后一个停止点。视图是我们应用程序的本质,因为它们直接向用户提供信息并直接响应用户交互。本章将详细探讨在 Flux 架构背景下视图组件。

我们将从讨论如何获取视图数据以及它们拥有数据后可以做什么开始。接下来,我们将查看一些强调 Flux 视图无状态特性的示例。然后,我们将回顾 Flux 架构中视图的责任,这与其他类型的前端架构中的视图责任不同。

我们将用使用 ReactJS 组件作为视图层的例子来结束本章。让我们开始吧!

向视图传递数据

视图没有它们自己的数据源来渲染 UI 元素。相反,它们依赖于 Flux 存储的状态,并监听状态的变化。在本节中,我们将介绍存储将发出以表示视图可以渲染自己的变化事件。我们还将讨论最终由视图决定何时以及如何渲染 UI 的想法。

通过变化事件传递数据

在本书中我们迄今为止看到的视图组件都依赖于存储在状态发生变化时发出的变化事件。这就是视图知道它可以渲染到 DOM 的原因——因为存在新的存储状态,这意味着可能有一个我们希望用户看到的视觉变化。

你可能已经注意到,从早期示例中,所有监听变化事件的处理器函数都有一个状态参数——这是存储的状态。问题是——为什么我们需要包含这些状态数据?视图为什么不能直接引用存储来引用状态数据?这个想法在这里得到了说明:

通过变化事件传递数据

即使视图直接引用存储的状态,变化事件仍然是必要的——否则它如何知道要渲染呢?变化事件被发出,然后视图知道它引用的状态也发生了变化。这种方法存在一个潜在问题,这与不可变性有关。让我们看看一些代码来更好地理解这个问题。这是一个具有name属性作为其状态的存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { NAME_CAPS } from '../actions/name-caps';

class First extends EventEmitter {
  constructor() {
    super();

    // The default state is a "name" property
    // with a lower-case string.
    this.state = {
      name: 'foo'
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // Mutates the "name" property, keeping
        // the "state" object intact.
        case NAME_CAPS:
          let { state } = this;
          state.name = state.name.toUpperCase();
          this.emit('change', state);
          break;
      }
    });
  }
}

export default new First();

当这个存储响应NAME_CAPS动作时,它的任务是使用简单的toUpperCase()调用转换name属性的状态。然后,变化事件以状态作为事件数据被发出。让我们看看另一个执行相同操作但使用不同方法更新state对象的存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { NAME_CAPS } from '../actions/name-caps';

class Second extends EventEmitter {
  constructor() {
    super();

    // The defaul state is a name property
    // with a lower-case string.
    this.state = {
      name: 'foo'
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // Assigns a new "state" object, invalidating
        // any references to any previous state.
        case NAME_CAPS:
          this.state = {
            name: this.state.name.toUpperCase()
          };
          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new Second();

如您所见,这两个商店基本上是相同的,当NAME_CAPS动作被分发时,它们会产生相同的结果。然而,请注意,这种转换不会改变state对象。相反,它会替换它。这种方法使状态对象不可变,这意味着商店永远不会改变其任何属性。差异在视图层感受到,并突出了在更改事件处理程序中需要状态参数的需求:

import first from './stores/first';
import second from './stores/second';
import { nameCaps } from './actions/name-caps';

// Setup references to the state of the
// two stores.
var firstState = first.state;
var secondState = second.state;

first.on('change', () => {
  console.log('firstState', firstState.name);
});
// → firstState FOO

second.on('change', () => {
  console.log('secondState', secondState.name);
});
// → secondState foo

second.on('change', (state) => {
  console.log('state', state.name);
});
// → state FOO

nameCaps();

这就是为什么我们不能对商店的状态做出假设。在前面的代码中,我们犯了一个关键的错误,假设我们可以保留secondStore.state引用。结果证明,这个对象是不可变的,因此视图访问新状态的唯一方法是通过更改处理程序中的状态参数。

视图决定何时渲染

Flux 商店的工作主要集中在为视图生成正确的信息。不属于商店工作描述的一部分是知道视图是否实际上需要更新。这意味着当商店触发一个更改事件时,决定发生什么的是视图——可能 DOM 中没有任何东西需要更新。那么问题就变成了——如果没有任何变化,为什么商店会发出更改事件?

简单的答案是商店没有做足够的账目记录来确定是否有什么变化。商店知道如何执行正确的状态转换,但它不一定跟踪以前的状态以进行差异比较——尽管它当然可以这么做。

让我们看看一个不改变其状态的商店。相反,当某个东西被转换时,它会创建新的状态:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { NAME_CAPS } from '../actions/name-caps';

class MyStore extends EventEmitter {
  constructor() {
    super();

    this.state = {
      name: 'foo'
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {
        case NAME_CAPS:

          // Convert to upper-case.
          let name = this.state.name.toUpperCase();

          // Only assign the new state object if
          // the "name" isn't already in upper-case.
          this.state = this.state.name === name ?
            this.state : {
              name: this.state.name.toUpperCase()
            };

          // Tell views about the state change, even
          // if the state object is the same.
          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new MyStore();

这个商店正在监听来自上一个示例的相同NAME_CAPS消息。它的任务仍然是相同的——将name属性转换为大写。然而,这段代码与商店的上一版本不同。它是不可变的,因为它不会改变state对象——它会替换它。但它只会在值实际发生变化时这么做。否则,state对象保持不变。这里的想法不是表明商店应该检测单个属性的状态变化,而是表明即使在状态没有变化的情况下,也可以发出更改事件。换句话说,我们的视图不应该假设仅仅因为更改事件,渲染到 DOM 就是必要的。

现在,让我们把注意力转向视图。计划很简单——除非必要,否则不要渲染:

import myStore from '../stores/my-store';

class MyView {
  constructor() {

    // The view keeps a copy of the previous
    // store state.
    this.previousState = null;

    myStore.on('change', (state) => {

      // Make sure we have a new state before
      // rendering. If "state" is equal to
      // "previousState", then we know there's
      // nothing new to render.
      if (state !== this.previousState) {
        console.log('name', state.name);
      }

      // Keep a reference of the state, so that
      // we can use it in the next "change"
      // event.
      this.previousState = state;
    });
  }
}

export default new MyView();

你可以看到previousState属性保留了对存储状态的引用。但是等等,根据前面的章节,这不是一件坏事吗?嗯,不是的,因为我们实际上并没有使用这个引用做任何其他事情,只是进行严格的相等性检查。这是用来确定视图是否需要渲染的。由于存储状态是不可变的,我们可以断言,如果相同的引用作为参数传递给更改事件处理器,实际上并没有发生任何变化,我们可以安全地忽略该事件。让我们看看当我们连续多次调用同一个动作会发生什么:

import myView from './views/my-view';
import { nameCaps } from './actions/name-caps';

// Despite repeated calls to "nameCaps()",
// "myView" is only rendered once.
nameCaps();
nameCaps();
nameCaps();
// → name FOO

在本章稍后当我们查看 ReactJS 时,我们将看到只渲染所需内容的视图的更高级场景。在本书稍后当我们查看Immutable.js时,我们将处理更高级的状态变化检测。

保持视图无状态

视图不能完全无状态,因为它们与 DOM 交互,并且与视图关联的 DOM 元素将始终具有状态。然而,我们可以在 Flux 架构的上下文中采取步骤将视图视为无状态实体。在本节中,我们将讨论无状态视图的两个方面。

首先,我们将介绍 Flux 架构中的所有状态都属于存储的想法,包括我们可能倾向于保存在我们的视图组件中的任何 UI 状态。其次,我们将探讨 DOM 查询以及为什么我们想要避免在 Flux 视图中进行这种操作。

UI 状态属于存储

如同你在上一章所学到的,UI 状态就像是从应用数据派生出来的状态——它们都属于存储。UI 状态包括按钮的disabled属性或应用于div的类的名称。这些状态片段属于存储的原因是其他存储可能依赖于它们。这反过来又影响了其他视图的渲染结果。这种依赖关系如下所示:

UI 状态属于存储

如果其他存储可能依赖的 UI 状态没有保存在存储中,那么它们将不得不依赖于视图或 DOM 本身。这是不一致的,并且与 Flux 所代表的原则相悖——严格的更新顺序和将状态限制在范围内。

不查询 DOM

当 UI 状态保存在 Flux 存储中时,无需查询 DOM 以确定按钮是否被禁用。想想 jQuery 处理应用状态的方法。首先,我们必须发出一个 DOM 查询来获取相关的 DOM 元素,然后我们必须通过读取它们的某些属性来确定它们是否处于适当的状态。然后,我们可以在应用程序的其他地方进行更改。或者可能有一些状态直接保存在 DOM 中,以及一些 JavaScript 对象。

一致性是 Flux 架构中最大的区别制造者,因为我们不需要查询 DOM 来获取链接的href属性。保留 UI 状态的存储已经拥有了这些信息。这始终是这种情况——永远不是在 DOM 或其他组件中寻找信息的问题。

在我们的存储中拥有所有必要的 UI 状态以做出渲染决策的另一个优点是,没有性能瓶颈。查询 DOM 一次或两次并不是什么大问题,而且如果我们想要向用户显示更改,这确实需要发生。我们不希望有一系列冗长的 DOM 查询调用,而这些调用甚至没有导致任何渲染。换句话说,当信息已经在存储中时,没有必要查询 DOM 来提取信息。

这与虚拟 DOM 树技术(如 ReactJS)所使用的相同策略,其中 DOM 数据全部存储在 JavaScript 对象中。从 JavaScript 对象中查找一些 UI 状态比查找 DOM 元素属性要快得多,这就是 ReactJS 能够表现得如此出色的原因——通过最小化给定 UI 更改的 DOM 交互次数。

视图职责

到这本书的这一部分,你可能已经很好地掌握了视图组件在 Flux 架构中的作用。简单来说,它们的任务是通过将其插入 DOM 来向用户显示存储信息。在本节中,我们将这个核心视图概念分为三个部分。

首先是视图的输入——存储数据。接下来是视图本身的架构,以及它可以分解成更小视图的各种方式。最后是用户交互。视图组件的这三个领域都与我们的 Flux 架构中的数据流有关。现在让我们看看每一个。

渲染存储数据

如果存储将数据转换成用户需要的信息,那么为什么还需要视图呢?为什么不直接让存储将信息渲染到 DOM 中呢?我们需要视图有几个原因。首先,存储实际上可以在多个地方使用,由多个视图渲染。其次,Flux 并不一定关心信息的视觉显示。例如,如果我们设计了一些不渲染 HTML 而渲染其他显示格式的视图,那也是完全可以的。

视图不保留任何状态或对存储信息进行任何转换。然而,它们确实需要稍微转换信息,将其转换为浏览器或任何其他运行我们应用程序的显示介质中显示的有效标记。但除了标记从存储返回的信息之外,视图几乎没有其他事情要做。在标记 JavaScript 对象并将它们插入 DOM 方面,视图技术(如 ReactJS)做了大部分基础工作。以下是展示该过程的图解:

渲染存储数据

子视图结构

Flux 架构中商店的目标是结构化它们,使得每个顶级特性只有一个商店。这使我们绕过了由大量数据结构层次结构引起的问题。另一方面,视图可以从一点层次结构中受益。仅仅因为顶级特性是由单个商店的信息驱动的,并不意味着只有一个视图可以驱动用户体验。

在本书的早期,我们讨论了层次结构的概念以及如何在 Flux 架构中避免它。这在一定程度上对视图仍然适用,因为无论如何划分,深层层次结构都是难以理解的。视图确实需要在一定程度上进行分解,否则我们最终会将所有标记复杂度放在一个地方。HTML 标记本质上是层次化的,因此从某种程度上说,我们的视图应该模仿这种结构,如图所示:

子视图结构

与商店可以通用一样,视图也可以通用。多个特性可以使用通用组件以通用显示模式展示信息。例如,考虑一种可展开/可折叠的面板,它被我们所有的特性使用——将其插入到我们的更大特性中而不是重复功能,不是更有意义吗?我们使用的视图技术也是我们想要将视图分解成更小、可重用部分的决策因素,因为某些框架比其他框架更容易做到这一点。例如,我们将在下一节中看到,ReactJS 使得从更小的、更精细的视图组合出粗粒度视图变得容易,因为它们在很大程度上是自包含的。

注意

在组合此类视图层次结构时需要注意的事项——注意数据流。例如,当 Flux 商店发生变化时,它会发出变化事件,以便顶级视图可以渲染自身。然后它渲染其直接子视图,这些子视图再渲染它们的直接子视图,依此类推。随着商店状态通过这些视图流动,沿途不应发生任何数据转换。换句话说,树中的叶视图应该获得与根视图相同的信息。

用户交互

我们需要考虑的最后一个视图责任领域是用户交互。除了被动地观察屏幕上的信息随我们架构的底层商店处理动作而变化之外,他们还需要做一些事情。如果至少,用户需要能够导航到应用程序的不同功能。为了处理这类事情,渲染 UI 的视图组件也应该拦截它们被触发时的 DOM 事件。这通常会导致发出新的动作,正如我们在本书前面已经看到的。

关于这些事件处理程序,要记住的关键点是它们应该基本上只有一个职责——调用正确的动作创建函数。这些事件处理程序应该避免尝试执行任何逻辑——这属于库,以及受逻辑影响的州。这是 Flux 的基础,所以我很可能在书中至少重复它十二次。一旦我们在库之外的地方引入逻辑,我们就失去了对某物状态的推理能力——状态在很大程度上决定了用户看到的内容。

注意

直接将动作创建函数作为事件处理程序传递给 DOM 节点是完全可能的。这实际上可能对我们有所帮助,因为它提供了非常低的逻辑被引入错误位置的风险。

使用 ReactJS 与 Flux

ReactJS 是一个用于创建视图组件的库。实际上,React 甚至不将自己标榜为视图库——它是一套用于创建渲染 UI 元素组件的工具。这个简单的原则易于理解且功能强大——完美地适合作为我们 Flux 架构中的视图技术。

在本节中,我们将探讨将 ReactJS 作为我们 Flux 应用程序中视图的首选技术,从将状态信息从库传递到 React 组件开始。接下来,我们将讨论视图的组合,以及 Flux 状态如何从库流向父视图再到子视图。最后,我们将使用 React 机制和 react-router 库中的路由器在我们的视图中实现一些事件处理功能。

设置视图状态

根据我们 Flux 库的状态渲染 React 组件有两种方式。这涉及到两种不同类型的组件——有状态和无状态——我们都会在这里讨论。首先,让我们看看包含驱动我们视图的状态的库:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { ADD } from '../actions/add';

class MyStore extends EventEmitter {
  constructor() {
    super();

    // The "items" state is an empty array
    // by default...
    this.state = {
      items: []
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // Push the "payload" to the "items"
        // array when the "ADD" action is
        // dispatched.
        case ADD:
          let { state } = this;

          state.items.push(e.payload);
          this.emit('change', state);
          break;
      }
    });
  }
}

export default new MyStore();

这里的想法很简单——每当分发 ADD 动作时,我们都会将动作有效载荷推送到 items 数组。任何希望响应这种库状态变化的 React 组件都可以通过监听变化事件来实现。首先,让我们看看渲染项目列表的有状态 React 组件:

import { default as React, Component } from 'react';
import myStore from '../stores/my-store';

// A stateful React component that relies on
// it's on state in order to render updates.
export default class Stateful extends Component {
  constructor() {
    super();

    // When "myStore" changes, we set the state of
    // this component by calling "setState()", causing
    // a render to happen.
    myStore.on('change', (state) => {
      this.setState(state);
    });

    // The initial state of the component is
    // taken from the initial state of the Flux store.
    this.state = myStore.state;
  }

  // Renders a list of items.
  render() {
    return (
      <ul>
        {this.state.items.map(item =>
          <li key={item}>{item}</li>)}
      </ul>
    );
  }
}

这是一个典型的 React 组件,使用 ES2015 类语法创建,并扩展了基本的 React Component 类。这种做法对于有状态的组件是必要的。正如你所看到的,这个组件的构造函数直接与 Flux 库交互。当库发生变化时,它会调用 setState(),这就是组件如何渲染以反映新的库状态。构造函数还通过设置 state 属性来设置初始状态。接下来,我们有 render() 方法,它根据这个状态返回 React 元素。

注意,我们的 React 组件正在使用 JSX 语法来定义元素。我们不会在这本书中介绍它是如何工作的,也不会详细介绍 React 的其他方面。这是一本关于 Flux 架构的书,我们将介绍在 Flux 环境中相关的 React 部分。如果你想要对 React 本身进行更深入的技术研究,有大量的免费资源,以及许多关于这个主题的其他书籍。

现在,让我们看看这个组件的另一种实现,这意味着完全相同的输出。这是 React 组件/视图的无状态方法:

import React from 'react';

// The stateless version of the React
// component is a much stripped-down
// version of a class component. Since
// it only relies on properttes passed
// into it, it can be a basic arrow function
// that returns a React element.
export default ({ items }) => (
  <ul>
    {items.map(item =>
      <li key={item}>{item}</li>)}
  </ul>
);

等等,这是什么?这是完全相同的组件,只是它不依赖于状态。如果我们将其作为 Flux 架构中的视图组件实现,这可能是个好事。这个实现最引人注目的是,注释比代码多,这是好事,因为它让我们能够关注生成的 DOM 结构。你会注意到在这个模块中没有与 Flux 存储的交互。记住,这是一个无状态的 React 组件,一个简单的箭头函数,这意味着我们不需要定义任何生命周期方法,包括初始状态。这是可以的;让我们看看我们如何在main.js模块中使用这两种类型的组件:

import React from 'react';
import { render } from 'react-dom';

import Stateful from './views/stateful';
import Stateless from './views/stateless';
import myStore from './stores/my-store';
import { add } from './actions/add';

// These are the DOM element "containers" that
// our React components are rendered into.
var statefulContainer =
  document.getElementById('stateful');
var statelessContainer =
  document.getElementById('stateless');

// Sets up the store change listener for our
// "Stateless" React component. This is simple
// "render()" call, React efficiently handles
// the DOM diffing semantics.
myStore.on('change', (state) => {
  render(
    <Stateless items={myStore.state.items}/>,
    statelessContainer
  );
});

// Initial rendering of our two components.
render(
  <Stateful/>,
  statefulContainer
);

render(
  <Stateless items={myStore.state.items}/>,
  statelessContainer
);

// Dispatch some actions, causing our store to change,
// and our React components to re-render.
add('first');
add('second');
add('third');

这里的关键区别在于,Stateless 视图需要在这里手动设置与存储的交互。有状态组件通过在构造函数中设置更改监听器来封装这一点。

一种方法是否优于另一种方法?在 Flux 架构中,无状态 React 组件通常比它们的有状态对应物有优势。这仅仅是因为它们强制执行状态属于存储,而不属于其他地方的理念。当我们的 React 组件是简单的函数时,我们别无选择,只能找出正确的方式将存储状态转换成可以被消费为简单不可变属性的东西。

组合视图

就像我们的应用程序状态是由存储组成的一样,该状态的观点也是以某种程度进行分层组合的。我说到某种程度,是因为我们希望避免在深度上分解我们的 UI 结构,因为这只会使其难以理解。视图组合真正重要的时候是我们有较小的部分被许多较大的组件使用时。React 擅长在不引入太多复杂性的情况下组合视图。特别是,无状态视图是保持单向数据流在穿越视图层级时保持一致的好方法。让我们看看一个例子。这是一个具有一些初始状态的存储,它会在特定操作上对这个状态进行排序:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { SORT_DESC } from '../actions/sort-desc';

class MyStore extends EventEmitter {
  constructor() {
    super();

    // The default store state has an array of
    // strings.
    this.state = {
      items: [
        'First',
        'Second',
        'Third'
      ]
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // The "SORT_DESC" action sorts the
        // "items" array in descending order.
        case SORT_DESC:
          let { state } = this;

          state.items.sort().reverse();
          this.emit('change', state);
          break;
      }
    });
  }
}

export default new MyStore();

在这种情况下,我们预计当SORT_DESC操作被分派时,数组将是第三第二第一(按字母顺序)。现在,让我们看看监听此存储的主要视图组件:

import React from 'react';
import Item from './item';

// The application view. Renders a list of
// "Item" components.
export default ({ items }) => (
  <ul>
    {items.map(item =>
      <Item key={item}>{item}</Item>)}
  </ul>
);

再次,我们有一个简单的函数视图,它不保留任何状态,因为没有这个必要——所有状态都保留在 Flux 存储中。而不是在这里使用li元素,我们使用了一个自定义的Item React 组件,这是我们为应用程序实现的。这是更大的App视图的一部分,也许也是其他更大视图的一部分。结果是代码重用和简化了聚合视图。现在让我们看看Item组件:

import React from 'react';

// An "li" component with "strong" text.
export default (props) => (
  <li>
    <strong>{props.children}</strong>
  </li>
);

这不是世界上最令人兴奋的视图,在实践中,你会发现比这更复杂的原子视图。但理念是相同的——props.children的值最终来自 Flux 存储,并且它通过父视图传递到这里。让我们看看所有这些部分如何在main.js中组合在一起:

import React from 'react';
import { render } from 'react-dom';

import myStore from './stores/my-store';
import App from './views/app';
import { sortDesc } from './actions/sort-desc';

// The containiner element for our application.
var appContainer = document.getElementById('app');

// Renders the "App" view component when
// the store state changes.
myStore.on('change', (state) => {
  render(
    <App {...state}/>,
    appContainer
  );
});

// Initial rendering...
render(
  <App {...myStore.state}/>,
  appContainer
);

// Perform the descending sort...
sortDesc();

对事件做出反应

React 组件自身包含一个事件系统。实际上,它们是 DOM 事件系统的包装器,这使得我们更容易将事件处理函数作为组件 JSX 标记的一部分。这对我们的 Flux 架构也有影响,因为这些事件通常直接转换为动作创建函数调用。

为了在 Flux 环境中感受 React 事件,让我们基于之前的示例继续构建。我们将添加一个按钮来切换项目的排序顺序。但在做之前,我们先看看支持这种新行为所需的存储修改:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { SORT } from '../actions/sort';

// Constants for the direction label
// of the sort button.
const ASC = 'sort ascending';
const DESC = 'sort descending';

class MyStore extends EventEmitter {
  constructor() {
    super();

    // We have some "items", and a "direction"
    // as the default state of this store.
    this.state = {
      direction: ASC,
      items: [
        'Second',
        'First',
        'Third'
      ]
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {
        case SORT:
          let { state } = this;

          // The "items" are always sorted.
          state.items.sort()

          // If the current "direction" is ascending,
          // then update it to "DESC". Otherwise, it's
          // updated to "ASC" and the order is reversed.
          if (state.direction === ASC) {
            state.direction = DESC;
          } else {
            state.direction = ASC;
            state.items.reverse();
          }

          this.emit('change', state);
          break;
      }
    });
  }
}

export default new MyStore();

MyStore中有一个新的状态片段——direction。它与项目的排序方向以及视图中的排序按钮的文本内容相关。现在让我们看看新的应用程序视图:

import React from 'react';
import Sort from './sort';
import Item from './item';

// The application view. Renders a sort
// button and a list of "Item" components.
export default ({ items, direction }) => (
  <div>
    <Sort direction={direction}/>
    <ul>
      {items.map(item =>
        <Item key={item}>{item}</Item>)}
    </ul>
  </div>
);

你可以看到,这个无状态函数返回的元素是一个div。虽然从标记的角度来看不是严格必要的,但从React组件的角度来看是必要的——渲染函数只能返回一个元素。我们添加在列表上方的Sort元素代表排序按钮。现在让我们看看这个组件:

import React from 'react';
import { sort } from '../actions/sort';

// Some inline styles for the React view...
var style = {
  textTransform: 'capitalize'
};

// Renders a "button" element, with the
// "direction" store state as the label
// and the "sort()" action creator function
// is called when the button is clicked.
export default ({ direction }) => (
  <button
    style={style}
    onClick={sort}>{direction}
  </button>
);

这个元素是一个简单的button HTML 元素,其样式将使direction标签大写。你还可以看到,onClick属性用于指定事件处理器。在这种情况下,它是简单的——当按钮被点击时,我们直接调用sort()动作创建函数。

注意

在实践中,可能需要与其他状态处理动作一起调度SORT动作。例如,可能需要一个PRE_SORT动作来处理按钮状态。

路由和动作

react-router库是 ReactJS 项目的既定路由解决方案。如果我们使用 React 组件作为 Flux 架构视图层的组件,那么我们很可能希望在这个应用程序中使用这个包进行路由。然而,在使用 Flux 环境中的react-router时,有一些细微之处需要注意。在本章的最后部分,我们将通过在 Flux 架构中实现它来讨论我们需要与react-router做出的权衡。

react-router的基本前提是它最初吸引人的原因。路由器及其内部的路线本身就是我们可以渲染到 DOM 中的 React 组件。我们可以声明,当路线激活时,应该渲染给定的 React 组件。路由器为我们处理所有琐碎的细节。问题是,在 Flux 应用程序的上下文中,它是如何工作的?正如我们所知,存储是我们应用程序中状态所在的地方。这意味着它们可能还想了解路由器的状态。

让我们从main.js模块开始看起,在那里声明并渲染了路由组件:

import React from 'react';
import { render } from 'react-dom';
import { Router, Route, IndexRoute } from 'react-router';

import App from './views/app';
import First from './views/first';
import Second from './views/second';
import { routeUpdate } from './actions/route-update';

// The containiner element for our application.
var appContainer = document.getElementById('app');

// Called by the "Router" whenever a route changes.
// This is where we call the action creator
// "routeUpdate()", passing it the path of the
// new route.
function onUpdate() {
  routeUpdate(this.state.location.pathname);
}

// Renders the router components. Each route
// has an associated React component that's
// rendered when the route is activated.
render((
  <Router onUpdate={onUpdate}>
    <Route path="/" component={App}>
      <IndexRoute component={First}/>
      <Route path="first" components={First}/>
      <Route path="second" component={Second}/>
    </Route>
  </Router>
), appContainer);

你可以看到这里有三个主要路线,默认的/路线,然后是/first/second路线。每个路线都有一个相应的组件,当路线变为活动状态时渲染。这些路线声明有趣的地方在于,FirstSecond组件是App的子组件。这意味着当它们的路线激活时,它们实际上是在App内部渲染的。现在让我们看看App组件:

import React from 'react';
import { Link } from 'react-router';

// Renders some links to the routes in the app.
// The "props.children" are any sub-components.
export default (props) => (
  <div>
    <ul>
      <li><Link to="/first">First</Link></li>
      <li><Link to="/second">Second</Link></li>
    </ul>
    {props.children}
  </div>
);

这个组件渲染了一个指向我们的两个路线——firstsecond——的链接列表。它还通过props.children渲染子组件。这就是子组件被渲染的地方。现在让我们转向routeUpdate()动作创建函数。这个函数由Router组件在路线更改时调用:

import dispatcher from '../dispatcher';

// The action identifiers...
export const ROUTE_UPDATE = 'ROUTE_UPDATE';
export const PRE_ROUTE_UPDATE = 'PRE_ROUTE_UPDATE';

export function routeUpdate(payload) {

  // Immediately dispatch the "PRE_ROUTE_UPDATE"
  // action, giving stores a chance to adjust
  // their state while asynchronous activities happen.
  dispatcher.dispatch({
    type: PRE_ROUTE_UPDATE,
    payload: payload
  });

  // Dispatches the "ROUTE_UPDATE" action
  // after one second.
  setTimeout(() => {
    dispatcher.dispatch({
      type: ROUTE_UPDATE,
      payload: payload
    });
  }, 1000);
}

实际上,这个函数派发了两个动作。首先,有PRE_ROUTE_UPDATE动作,它被派发,以便存储有机会为更改的路线做准备。然后,我们使用setTimeout()执行一些异步行为,然后派发ROUTE_UPDATE动作。现在让我们看看我们组件使用的其中一个存储。然后我们将看看监听这个存储的一个视图。存储和视图几乎相同,所以没有必要查看每个都超过一个:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';

// We need a couple action constants from
// the same action module.
import {
  PRE_ROUTE_UPDATE,
  ROUTE_UPDATE
} from '../actions/route-update';

class First extends EventEmitter {
  constructor() {
    super();

    // The "content" state is initially an
    // empty string.
    this.state = {
      content: ''
    };

    this.id = dispatcher.register((e) => {
      let { state } = this;

      switch(e.type) {

        // The "PRE_ROUTE_UPDATE" action means the
        // route is about to change once the
        // asynchronous code in the action creator
        // resolves. We can update the "content"
        // state here.
        case PRE_ROUTE_UPDATE:
          if (e.payload.endsWith('first')) {
            state.content = 'Loading...';
            this.emit('change', state);
          }
          break;

        // When the "ROUTE_UPDATE" action is dispatched,
        // we can change the content to show that it has
        // loaded.
        case ROUTE_UPDATE:
          if (e.payload.endsWith('first')) {
            state.content = 'First Loaded';
            this.emit('change', state);
          }
          break;
      }
    });
  }
}

export default new First();

根据动作类型和当前路线,存储更新其content状态。这很重要,因为应用程序的其他区域可能想知道这个存储正在等待路线更新完成。现在让我们看看监听这个存储的视图:

import { default as React, Component } from 'react';
import first from '../stores/first';

export default class First extends Component {
  constructor() {
    super();

    // The initial state comes from the store.
    this.state = first.state;

    // The store "change" callback function is
    // defined here so that it can be bound to
    // "this" and set the component state.
    this.onChange = (state) => {
      this.setState(state);
    };
  }

  // Renders the HTML using "content".
  render() {
    return (
      <p>{this.state.content}</p>
    );
  }

  // Sets up the store "change" listener.
  componentWillMount() {
    first.on('change', this.onChange);
  }

  // Removes the store "change" listener.
  componentWillUnmount() {
    first.removeListener('change', this.onChange);
  }
}

这个组件是状态化的,因为它必须如此。由于它是路由器最初渲染该组件,所以我们不能通过其他方式重新渲染它,只能通过设置其状态。这就是我们能够通过设置更改事件处理程序来重新渲染组件以反映存储状态的方式。这个组件还有生命周期方法来监听存储的更改事件,以及移除监听器。如果我们不移除它,它将尝试在一个未挂载的组件上设置状态。

摘要

本章详细介绍了 Flux 架构的视图层。从将信息引入视图开始,你了解到变化事件是反映存储在视图中的状态的基本方式,并且视图通常在它们的初始渲染期间直接从存储中读取。然后,我们讨论了视图是无状态的这一观点。给定 UI 元素的状态属于存储,因为应用程序的其他部分可能依赖于这个状态,我们不希望不得不查询 DOM。

接下来,我们讨论了视图组件的一些高级职责。这包括渲染存储信息,将较小的视图组件组合成较大的视图结构,以及处理用户交互。我们以使用 ReactJS 组件作为 Flux 架构中的视图技术为例,结束了这一章。在下一章中,我们将深入探讨 Flux 组件的生命周期以及它们与其他架构的不同之处。

第八章:信息生命周期

任何信息系统都有其生命周期。这些系统中的单个组件也有它们自己的生命周期。累积起来,这些可能很容易处理,也可能非常困难。在前端 JavaScript 架构中,趋势是后者。原因很简单,我们的组件经历的生命周期,从根本上改变了信息随时间流动的方式,这是几乎无法预测的。

本章讨论的是 Flux 架构中的信息生命周期。Flux 与其他架构的不同之处在于,它强调信息的扩展而不是 JavaScript 组件。我们将从审视多年来的困难开始,这些困难是使用现代 JavaScript 框架中典型的组件生命周期所面临的。然后,我们将这种做法与 Flux 的方法进行对比,在 Flux 中,高级组件相对静态。

接下来,我们将探讨信息扩展的概念以及它是如何导致更合理的架构的,这些架构比其他方法更容易维护。我们将以讨论不活跃的存储结束本章——这些存储不活跃地服务于带有数据的视图。让我们开始吧。

组件生命周期困难

前端架构扩展的一个方面是清理未使用的资源。这为新创建的资源释放内存,这些资源是在用户与应用程序交互时创建的。JavaScript 是垃圾回收的,这意味着一旦一个对象没有任何引用指向它,它就符合在下一次回收器运行时被收集的条件。这让我们前进了一半;也就是说,没有必要手动分配/释放内存。然而,我们还有另一类扩展问题,它们都与组件的生命周期有关。

在本节中,我们将讨论我们想要回收未使用资源的场景以及这通常在前端架构中是如何发生的。然后,我们将从生命周期管理的角度审视组件依赖带来的挑战。最后,我们将探讨内存泄漏场景。即使有最好的工具,我们的代码也可能做了某些事情来规避内存管理。

回收未使用资源

在应用程序的整个过程中,经常发生的事情是,在旧资源被销毁的同时,新资源被创建。这是对用户交互的反应——当他们遍历应用程序的功能时,会创建新的组件来展示新的信息。JavaScript 对象和 DOM 元素的这种创建和销毁的大部分对我们来说是透明的——我们使用的工具可以为我们处理这些。以下图表捕捉了组件在改变状态时释放内部资源的概念:

回收未使用资源

关键在于我们组件的生命周期。根据负责管理此生命周期的框架,不同时间可能会发生不同的事情。例如,当父组件创建时,你的组件被实例化并存储。当你的组件被渲染时,它会插入新的 DOM 元素并保持对它们的引用。最后,当组件的父组件被销毁时,我们的组件被指示移除其 DOM 元素并释放对它们的任何引用。这是一个过于简化的工作流程,但无论有多少移动部件,基本思想都是相同的。我们使用的工具的职责是以一种回收未使用组件的方式处理我们组件的生命周期。

为什么回收未使用的组件如此重要?我们面临的基本限制是内存是有限的,我们正在尝试构建一个健壮的应用程序,它能很好地扩展。当组件不再需要时,从内存中移除组件,为需要时创建新组件腾出空间。那么,如果我们使用一个为我们的组件定义了良好生命周期的框架,并且为我们处理了很多繁琐的细节,这有什么大不了的?

这种方法的限制因素之一是,对于具有许多移动部件的复杂应用程序,框架会不断创建和销毁对象。这不可避免地导致垃圾收集器频繁调用,导致主 JavaScript 执行线程暂停。在最坏的情况下,这可能导致由于用户事件无响应而导致的用户体验暂停。自动管理组件生命周期的另一个潜在陷阱是,框架并不总是知道我们在想什么,这可能导致最终破坏组件创建/销毁生命流程的隐藏依赖项。

隐藏的依赖项

定义特定类型组件生命周期的模式是好事——前提是我们组件始终百分之百遵守其生命周期。这很少能成功,因为我们试图构建一些独特的东西,为我们的用户提供解决方案,而不是仅仅为了与框架友好相处而构建的软件。这里最大的风险是我们可能会意外地通过引入依赖项阻止框架正确释放资源。这些依赖项在我们的应用程序的上下文中可能完全合理,但就框架而言,它并不知道它们,这会导致不可预测的方式中断。看看下面的图示:

隐藏的依赖项

我们实际面临的情况将比这里描述的场景更为复杂。总体来说,主题是管理生命周期的框架非常严格。只需一个放置错误的位置的依赖项,就可以完全无效化框架为应用程序所做的一切。然而,这正是我们最初为架构组件设置生命周期的成本/收益。好处是我们需要回收组件以腾出空间给新的组件,如果框架能为我们自动化这项艰巨的任务,那就更好了。风险是,每次创建和销毁事物时,都有可能没有正确完成,从而导致内存泄漏。

内存泄漏

当我们的代码不断创建和销毁对象时,JavaScript 垃圾回收器会变得不稳定,我们可能会遇到性能问题。然而,与那些从未完全被垃圾回收的泄漏 JavaScript 组件相比,这只是一个小问题。这种情况通常发生在我们的应用程序代码有一些与负责管理组件生命周期的框架不完全匹配的想法时。显然,内存泄漏是一个巨大的可扩展性问题,我们无论如何都要避免。

因此,我们在架构组件的生命周期方面有两个相关的可扩展性问题。首先,我们不希望不断创建和销毁对象,因为这会有垃圾回收暂停的问题。其次,我们不希望通过引入框架未知的隐藏依赖而泄漏内存,破坏预期的生命周期。正如我们将在下一节中看到的,Flux 架构有助于解决组件生命周期问题的两个方面。在 Flux 架构中,组件的创建/销毁并不多。这减少了引入逻辑破坏特定组件生命周期的可能性。在本章的后面部分,我们将看到 Flux 如何专注于信息而不是 JavaScript 组件来实现可扩展性。

Flux 结构是静态的

既然不断创建和销毁对象的需要为可扩展性问题提供了机会,那么我们似乎应该尽可能少地创建和销毁。结果证明,在这一点上,Flux 架构有所不同,因为大部分组件基础设施是静态的。

在本节中,我们将探讨 Flux 在这一点上与其他架构的不同之处,从许多模块使用的单例模式开始。然后,我们将比较传统的 MVC 模型方法与 Flux 存储。最后,我们将查看静态视图组件,看看这是否是一个值得追求以实现可扩展性的想法。

单例模式

如您现在可能已经注意到的,我们在这本书中迄今为止使用的多数模块都导出了一个单一实例。分发器暴露了来自 Facebook Flux 包的Dispatcher类的一个单一实例。这就是单例模式的应用。

基本思想是,一个类只有一个实例,创建更多是不必要的,因为第一个实例就是我们需要的所有。这与我们在本章中讨论的扩展问题相吻合,其中不断的创建和销毁使我们的代码容易出错。这些错误最终阻止了应用程序的扩展,因为内存泄漏或性能问题。

相反,Flux 架构倾向于在启动时组装组件之间的管道,并且这些管道永久地保持原位。想想您居住的地方的物理管道,当它没有被使用时,它就处于闲置状态。然而,拆除物理管道以回收空间,以及当需要时更换它的成本,简单地说是不划算的。在我们的墙壁内拥有静态管道结构的开销并不是我们日常生活中的扩展瓶颈。

因此,虽然我们可以通过遵循单例模式避免一些对象的创建和销毁,但这是有代价的。例如,单例模式并不一定是一个好的模式。至少在我们所有的模块中,一切都是一个类,而且一切只实例化一次。让我们看看存储模块,看看我们是否可以实施实际上不需要存储的东西。首先,让我们实现一个典型的存储模块,以便进行比较:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// A typical Flux store class. This module
// exports a singleton instance of it.
class SingletonStore extends EventEmitter {
  constructor() {
    super();

    this.state = {
      pending: true
    };

    this.id = dispatcher.register((e) => {
      switch(e.type) {
        case MY_ACTION:
          this.state.pending = false;
          this.emit('change', this.state);
          break;
      }
    });
  }
}

export default new SingletonStore();

外部世界从这个模块中需要的属性只有少数几个。它需要状态,以便其他组件可以读取它。它需要调度器注册的标识符,以便其他组件可以使用waitFor()依赖它。此外,它还需要EventEmitter,以便其他组件可以监听存储状态的变化。现在让我们实现一个实际上不需要实例化新类的存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// Exports the state of this store...
export var state = {
  pending: true
};

// Exports the "id" of the dispatcher registration
// so that other stores can depend on this module.
export var id = dispatcher.register((e) => {
  switch(e.type) {
    case MY_ACTION:
      state.pending = false;
      emitter.emit('change', state);
      break;
    }
});

// We need to create a new "EventEmitter" here
// since there's no class to extend it.
const emitter = new EventEmitter();

// Exports the minimal interface that views
// require to listen/unlisten to stores.
export const on = emitter.on.bind(emitter);
export const off = emitter.removeListener.bind(emitter);

如您所见,我们正在导出基本需求,使其他组件可以将此模块视为存储。实际上,它确实是一个存储,只是结构不同。我们不是导出一个具有基本存储接口的单例类实例,而是直接导出接口的各个部分。这两种方法有任何根本性的优势吗?没有,没有。如果您喜欢类以及扩展基类的功能,那么请坚持使用单例模式。如果您觉得类很丑陋且不必要,请坚持使用模块方法。

最终,架构结果是相同的。存储简单地存在。当用户与应用程序交互时,没有必要创建和销毁存储。没有任何东西阻止我们这样做——随着应用程序状态的变化,设置和拆除存储。但正如我们将在本章后面看到的那样,这样做实际上并没有优势,就像在下水道不运行时拆掉墙壁一样没有优势。

让我们看看这两个存储的实际效果。除了它们如何导入之外,它们是无法区分的:

import { myAction } from './actions/my-action';
import singletonStore from './stores/singleton-store';

// Note that "moduleStore" is a module, with everything
// that it exports, not a class instance.
import * as moduleStore from './stores/module-store';

// Registers a "change" callback with the singleton
// store...
singletonStore.on('change', (state) => {
  console.log('singleton', state.pending);
});

// Registers a "change" callback with the module
// store. Not that it looks and feels exactly
// like a class instance.
moduleStore.on('change', (state) => {
  console.log('module', state.pending);
});

// Triggers the "MY_ACTION" action.
myAction();

与模型的比较

记得存储代表我们应用程序顶级功能的想法吗?嗯,顶级功能通常不会在整个应用程序生命周期中不断创建和销毁。另一方面,模型,我们来自 MV* 架构家族的朋友,通常代表更细粒度的数据域。正因为如此,它们需要出现和消失。

例如,假设我们正在应用程序的搜索页面,并且显示了一堆结果。单个结果可能是模型,代表 API 返回的某些结构。渲染搜索结果的视图可能知道如何显示这些模块。当结果发生变化或用户导航到应用程序的另一个部分时,模型不可避免地会被销毁。这是我们在本章前面讨论的整个生命周期讨论的一部分。这不是一个简单的删除——需要执行一些清理步骤。

使用 Flux 存储,我们不需要相同级别的复杂性。存在一些视图会监听特定的存储,但仅此而已。当存储的状态发生变化时,例如当某些搜索结果数据从存储状态中删除时,视图会被通知。然后视图需要通过重新渲染 UI 来反映这些变化的数据。在 Flux 中,清理工作只是一个简单的删除问题,无论是从 DOM 的角度来看还是从存储的角度来看。在我们用户与应用程序交互时不会删除整个存储的事实意味着我们的架构组件之间出现不同步的机会更小。

静态视图

由于视图是负责渲染用户可以看到的信息的组件,当用户没有在查看它时清理视图似乎是有意义的,对吧?嗯,不一定。回顾一下管道类比,当我们离开厨房时,我们会关闭水槽的龙头。我们不会拿一个工具箱开始拆除管道。Flux 架构中的视图可以是静态的这一观点实际上是可行的。我们需要关闭的是水,而不是管道,以便进行扩展。

让我们看看一些在启动时创建且在用户与应用程序交互时不会被销毁的视图。首先,我们将实现一个基于类的静态视图:

import myStore from '../stores/my-store';

class ClassView {
  constructor() {

    // The "container" DOM element for this view.
    this.container =
      document.getElementById('class-view');

    // Render the new state when "myStore" changes.
    myStore.on('change', (state) => {
      this.render(state);
    });
  }

  render({ classContent } = myStore.state) {

    // Sets the content of the container element.
    // This is done by reducing the "classContent"
    // array to a single string. If it's empty,
    // any existing DOM elements are removed from
    // the container.
    this.container.innerHTML = classContent.reduce(
      (x, y) => `<strong>${x + y}</strong>`, '');
  }
}

export default new ClassView();

这看起来就像你在 Flux 架构中会找到的典型类。它是在模块内部实例化并导出的。内容本身是通过将数组减少到一个 <strong> 标签来渲染的。当我们查看存储时,我们会看到为什么要以这种方式渲染这样的标签。但首先,让我们看看另一个以函数形式存在的静态视图:

import React from 'react';

// Renders the view content using a functional
// React component.
export default ({content}) => (
  <strong>{content}</strong>
);

这是你在上一章中介绍到的 React 组件的函数式风格。正如你所见,并没有什么特别的,因为 React 为我们处理了很多繁重的工作。现在让我们看看这两个视图都依赖的信息存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { SHOW_CLASS, SHOW_FUNCTION } from '../actions/show';

class MyStore extends EventEmitter {
  constructor() {
    super();

    // The two content properties of this store's state
    // empty arrays, meaning empty content.
    this.state = {
      classContent: [],
      functionContent: []
    };

    this.id = dispatcher.register((e) => {
      let {state} = this;

      switch(e.type) {

        // If the "SHOW_CLASS" action was dispatched,
        // the "classContent" state gets a single item
        // array and the "functionContent" state gets
        // and empty array.
        case SHOW_CLASS:
          Object.assign(state, {
            classContent: [ 'Class View' ],
            functionContent: []
          });

          this.emit('change', state);
          break;

        // If the "SHOW_FUNCTION" action was dispatched,
        // the "functionContent" state gets a single item
        // array and the "classContent" state gets an
        // empty array.
        case SHOW_FUNCTION:
          Object.assign(state, {
            classContent: [],
            functionContent: [ 'Function View' ]
          });

          this.emit('change', state);
          break;
      }
    });
  }
}

export default new MyStore();

您可以看到,这两个动作——SHOW_CLASSSHOW_FUNCTION——被以相同的方式处理。一个动作设置一个状态项,同时删除另一个。让我们在这里讨论一下这种方法。classContentfunctionContent状态属性都使用包含字符串值的单元素数组。我们的两个视图都会遍历这些数组——使用map()reduce()。我们这样做的原因是让逻辑不进入视图。在存储上操作的业务逻辑应该留在存储中。然而,视图需要知道显示什么和删除什么。通过始终遍历一个集合,如数组,内容生成是一致的且无逻辑的。让我们看看这两个视图如何在main.js中使用:

import React from 'react';
import { render } from 'react-dom';

import { showClass, showFunction } from './actions/show';
import myStore from './stores/my-store';
import classView from './views/class';
import FunctionView from './views/function';

// The DOM element used by our "FunctionView"
// component.
var functionContainer = document
  .getElementById('function-view');

// Utility to render the "FunctionView" React
// component. Called by the store "change"
// handler and to perform the initial rendering.
function renderFunction(state) {
  render(
    <FunctionView
      content={state.functionContent}/>,
    functionContainer
  );
}

// Sets up the "change" handler for "FunctionView"...
myStore.on('change', renderFunction);

// Perform the initial rendering of both views...
classView.render();
renderFunction(myStore.state);

// Dispatch the "SHOW_CLASS" action.
showClass();

// Wait one second, then dispatch the "SHOW_FUNCTION"
// action.
setTimeout(() => {
  showFunction();
}, 1000);

classView的使用非常简单。它被导入并渲染。存储状态处理封装在view模块中。另一方面,FunctionView React 组件需要设置一个处理函数,当myStore状态改变时会被调用。技术上讲,这并不是一个静态视图,因为它是一个在每次调用React.render()时都会被调用的函数。然而,在 Flux 的上下文中,它确实表现得像静态视图,因为 React 渲染系统负责创建和销毁视图组件——我们的代码并没有创建或销毁任何东西——只是将组件传递给render()

信息扩展

如您在本章中迄今为止所见,Flux 不会尝试扩展那些不需要扩展的事物。例如,存储和视图通常在启动时只创建一次。随着应用程序随时间变化状态,试图反复清理这些组件是容易出错的。如果我们不小心,扩展通过我们的 Flux 组件流动的信息将会使我们的系统崩溃。

我们将从这个部分开始,看看我们的 Flux 架构如何在没有大量数据进入系统的情况下很好地扩展。这也旨在说明这些实际上是两个独立的问题——扩展我们的 Flux 组件的基础设施与扩展我们的架构能够处理的数据量。然后,我们将讨论为更少的信息设计我们的用户界面,以使可扩展组件的设计过程简单化。我们将探讨在将我们的系统扩展到下一个级别时 Flux 动作的作用。

什么可以很好地扩展?

随着我们的应用程序增长,它需要响应新功能请求和增长的数据集等问题进行扩展。问题是,这些扩展问题中哪一个最值得我们关注?应该是那些最有潜力使我们的系统崩溃的问题。一般来说,这更多与输入数据有关,而不是与我们的 Flux 组件的配置有关。例如,如果我们以多项式时间而不是对数时间处理输入数据,就存在潜在的扩展问题。

正因如此,我们的 Flux 架构不需要像其他架构那样关注生命周期和组件之间的管道维护。拥有大量组件是否会占用比它们需要的更多内存,并且在性能上是否昂贵?当然,这始终是一个考虑因素——我们不希望拥有比我们需要的更多组件。在实践中,这种开销几乎不会被用户注意到。让我们看看大型组件基础设施对性能的影响。首先,看看视图:

// A really simple view...
export default class MyView {
  constructor(store) {

    // Do nothing except verify that there's
    // a "result" state property.
    store.on('change', ({ result }) => {
      console.assert(
        Number.isInteger(result),
        'MyView'
      );
    });
  }
}

这个视图没有什么特别的,因为不需要有。我们不是在测试视图本身的渲染性能——我们是在测试架构的可扩展性。所以所需的就是视图存在并且可以监听一个存储。我们通过构造函数传递存储实例,因为我们正在创建几个监听不同存储的视图实例,正如我们稍后将看到的。让我们看看存储代码:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// We're exporting the store class instead of
// an instance from this module because the
// main module will create a bunch of them.
export default class MyStore extends EventEmitter {
  constructor() {
    super();

    // The EventEmitter thinks we're leaking memory
    // there's too many listeners. This circumvents
    // the limitation.
    this.setMaxListeners(5000);

    this.state = {};

    this.id = dispatcher.register((e) => {
      let {state} = this;

      // Perform some basic arithmetic before emitting
      // the "change" event with the "result" state
      // property.
      switch(e.type) {
        case MY_ACTION:
          state.result = 100000 * e.payload;
          this.emit('change', state);
          break;
      }
    });
  }
}

这是一个相当基本的存储,当MY_ACTION被分发时,它会进行相当基本的计算。再次强调,这是故意的。现在让我们看看这些组件如何在 Flux 架构中几乎没有数据的情况下进行扩展:

import MyStore from './stores/my-store';
import MyView from './views/my-view';
import { myAction } from './actions/my-action';

// Holds onto our store and view references...
var stores = [];
var views = [];

// How many items to create and actions to
// dispatch...
var storeCount = 100;
var viewCount = 1000;
var actionCount = 10;

// Setup our Flux infrastructure. This establishes
// all the relevant store listeners and view
// listeners. They all stay active throughout the
// lifetime of the application.
console.time('startup');
for (let i = 0; i < storeCount; i++) {
  let store = new MyStore();
  stores.push(store);

  for (let i = 0; i < viewCount; i++) {
    views.push(new MyView(store));
  }
}
console.timeEnd('startup')
// → startup: 26.286ms

console.log('stores', stores.length);
console.log('views', views.length);
console.log('actions', actionCount);
// →
// stores 100
// views 100000
// actions 10

// Dispatches the actions. This is where we either
// succeed or fail at scaling the architecture.
console.time('dispatch');
for (let i = 0; i < actionCount; i++) {
  myAction();
}
console.timeEnd('dispatch');
// → dispatch: 443.929ms

我们正在衡量创建这些组件并设置它们的监听器的启动成本,因为这通常会增加 Flux 应用程序的启动成本。但正如我们所看到的,准备所有这些组件在用户体验方面并不重要。真正的考验是在动作被分发时。

这种设置会导致发生一百万次视图渲染调用,并且大约需要半秒钟。这是我们应用程序墙上的管道,实际上我们并不想把它拆下来然后再重新搭建起来。这个架构方面具有良好的可扩展性。真正具有挑战性的是进入系统的数据以及对其操作的逻辑。如果我们必须再次运行这个相同的测试,并且有一个由商店排序的 1000 项数组的操作有效载荷,我们可能会遇到问题。

注意

我们将在第十三章测试和性能中讨论更多细粒度的性能测试场景。

所需的最小信息

正如你所看到的,Flux 组件及其连接可以静态定义的观点是有效的。至少,在可扩展性挑战方面,在系统中放置静态管道不会成为我们在尝试扩展系统时将其击垮的东西。是流入系统的数据以及我们将其转换为用户信息的手段。这是非常难以扩展的事情,因此,我们最好尽可能少做。

起初这可能听起来微不足道,但显示更少的信息可以很好地扩展。这很容易被忽视,因为我们致力于构建功能,而不是衡量我们从视图中输出的信息量。有时,这是最有效的方法,或者可能是唯一的方法,来修复扩展问题。

当我们为应用程序设计 Flux 架构时,我们必须考虑到信息可扩展性。通常,从 UI 本身的角度来看问题是最好的角度。如果我们能砍掉某些东西,以减少杂乱,我们也会减少视图需要生成的信息量。潜在地,我们可以通过改变用户看到的内容,简单地从我们的应用程序中移除整个数据流。精简的用户界面可以很好地扩展。

另一个需要注意的问题是存储组件中泄露出的信息。通过这个,我指的是存储组件出于没有实际目的而生成的信息。这可能曾经与视图的工作方式相关,但特征改变后,我们忘记了移除相关信息。或者,这可能是设计上的疏忽——我们生成视图实际上不需要的信息,而且这种情况从第一天开始就是这样。这些问题很难被发现,但很容易解决。唯一万无一失的方法是定期审计我们的视图,以确保它们只消耗它们需要的信息,没有更多。

可扩展的动作

动作是任何想要进入我们的 Flux 系统的数据的守门人——如果不是动作有效载荷,那么就不是我们关心的数据。动作创建者函数在扩展上没有问题,因为它们做得不多。动作创建者函数最复杂的方面是管理异步行为,如果需要的话。但这不是一个基本的扩展问题,每个 JavaScript 应用程序都有异步组件。动作可以阻碍我们扩展努力的两个基本方法。

第一种情况是动作过多。这意味着由于所有可能性的存在,程序员出错的机会更多。在哪个上下文中应该使用哪个动作创建者变得不那么明显。当动作很少而动作创建者参数很多时,同样的问题也可能发生。这直接阻碍了我们将正确数据输入应用程序存储的能力。

当我们尝试扩展我们的系统时,动作可能遇到的第二种问题是动作创建者函数做得太多。例如,一个动作创建者函数可能试图过滤掉一些 API 响应数据,以减少通过调度器传递给存储的数据量。这是一个大问题,因为它违反了 Flux 规则,即所有状态和所有改变状态的逻辑都属于存储。

虽然当面临扩展应用程序的压力时,这种事情发生是可以理解的,因为在数据问题的源头进行修复是最明显的地方。在这种情况下,源头是 AJAX 响应的处理程序。更好的处理方式是调整 API 本身,并让动作创建函数提供适当的参数以获取更小的数据集。当状态转换移出前端存储之外时,我们增加了其他问题发生的可能性,从而降低了成功扩展的可能性。

无活动存储

在上一节中,我们探讨了在我们的 Flux 架构中可以有一个相对静态的组件基础设施的想法。这不是引起我们对可扩展性担忧的事情。相反,是我们存储中持有的大量数据。在本节的最后,我们将讨论一些场景,在这些场景中,我们有一个存储,其状态包含大量数据,我们不想让我们的应用程序变得内存膨胀。

第一种方法涉及从存储中删除数据,释放资源。我们可以通过向我们的存储逻辑添加启发式方法来进一步采取这种方法,该方法确定没有变化,并且不需要通过触发更改事件来触摸 DOM。最后,我们将讨论删除存储数据引起的某些副作用以及如何处理它们。

删除存储数据

我们必须仔细思考我们的 Flux 组件的一个问题是进入系统的数据最终将如何退出系统。如果我们只放入数据而不取出任何数据,我们就有了问题。事实上,这种活动是 Flux 架构的基本,因为从存储状态中删除数据也是删除其他数据结构,如 DOM 节点和事件处理函数的方式。

在本章的早期,我们看到通过清空数组,我们可以告诉视图删除 UI 元素。这正是我们扩展 Flux 应用程序的方式——通过删除可能导致扩展问题的数据。想象一下,一个存储中有一个包含数千个项目的集合。这个集合不仅处理起来成本高昂,而且还有可能变得更大。

简单的解决方案是在不再需要时清空这个集合。让我们回顾一下这种方法。首先,这是视图的外观:

import React from 'react';
import { hideAll, hideOdd } from '../actions/hide';

// The view function, renders a button
// that deletes store data by dispatching
// the "HIDE_ALL" action, and renders a list
// of items. The hide odds button only deletes
// some store data by dispatching the "HIDE_ODD"
// action.
export default ({ items }) => (
  <div>
    <button onClick={hideAll}>Hide All</button>
    <button onClick={hideOdd}>Hide Odd</button>
    <ul>
      {items.map(item =>
        <li key={item}>{item}</li>
      )}
    </ul>
  </div>
);

几个按钮和项目列表——相当简单。当按钮被点击时,它会调用一个动作创建函数。现在让我们把注意力转向存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { HIDE_ALL, HIDE_ODD } from '../actions/hide';

class MyStore extends EventEmitter {
  constructor() {
    super();

    // The initial state is an "items" array
    // of 100 numbers.
    this.state = {
      items: new Array(100)
        .fill(null)
        .map((x, y) => y)
    };

    this.id = dispatcher.register((e) => {
      let { state } = this;

      switch(e.type) {

        // When the "HIDE_ALL" action is dispatched,
        // the "items" state is reset back to
        // an empty array.
        case HIDE:
          state.items = []
          this.emit('change', state);
          break;

        // When the "HIDE_ODD" action is dispatched,
        // the "items" state is filtered to include
        // only even numbers.
        case HIDE_ODD:
          state.items = state.items.filter(
            x => !(x % 2));
          this.emit('change', state);
          break;
      }
    });
  }
}

export default new MyStore();

HIDE_ALL动作简单地通过分配一个空数组来删除所有项目。这正是我们想要的——在不再需要时删除数据。这是真正的扩展挑战,清理可能很大且处理成本高昂的数据。HIDE_ODD动作是一个过滤偶数的变体。最后,让我们看看所有这些在main.js中的组合方式:

import React from 'react';
import { render } from 'react-dom';

import myStore from './stores/my-store';
import MyView from './views/my-view';

// The DOM container for the React view...
var container = document.getElementById('my-view');

// Renders the functional "MyView" React
// component.
function renderView(state) {
  render(
    <MyView
      items={state.items}/>,
    container
  );
}

// Re-render the React component when the store
// state changes.
myStore.on('change', renderView);

// Perform the initial render.
renderView(myStore.state);

优化无活动存储

在前一个示例中,我们使用的设置可能存在的一个潜在扩展问题是视图本身执行了一些昂贵的计算。例如,我们不能排除这样的可能性,即即使提供空数组作为渲染信息,视图本身也存在一些实现问题。在 Flux 架构中,这是一个问题,因为动作总是被分发到存储,存储随后通知监听它们的视图。因此,视图必须快速。

这正是 React 在 Flux 中发挥出色的地方。React 组件旨在以自顶向下的方式重新渲染,从根组件一直到底层组件。它能够高效地做到这一点,因为它使用虚拟 DOM 来计算补丁,然后将这些补丁应用到真实 DOM 上。这消除了许多性能问题,因为发出大量的 DOM API 调用是一个性能瓶颈。另一方面,假设存储会向高效的 React 组件发布变更,这会稍微有些天真。

存储在适当的时候负责发出变更事件。因此,我们可以在存储中确定,当给定动作被分发时,没有必要发出变更事件。这可能涉及某种启发式方法,该方法将确定视图已经根据存储的状态显示适当的信息,并且现在发出变更事件将没有任何价值。通过这样做,我们可以避免视图中的任何性能挑战。这种方法的缺点是我们正在我们的存储中构建复杂性。我们可能最好一致地发出变更事件,并处理那些效率低下的视图。或者如果我们还没有使用 React 作为视图层,这可能是一个支持这样做的好论据。

注意

在下一章中,我们将探讨在视图组件中实现高级变更检测启发式方法。

保存存储数据

在本章中,你已经看到了如何以可扩展的方式从存储中删除数据。如果用户已经从一个用户界面部分移动到另一个部分,那么我们可能希望删除在这个新部分中不再需要的任何存储数据。想法是,而不是移除所有的 JavaScript 组件,我们专注于存储中的数据,这是我们的应用程序中最难扩展的部分。然而,这种方法存在一个潜在问题,我们需要考虑。

如果另一个存储依赖于我们刚刚删除的数据,会发生什么?例如,用户正在一个由存储 A 的状态驱动的页面上。然后他们转到另一个页面,该页面由存储 B 驱动,存储 B 依赖于存储 A。但我们刚刚删除了存储 A 中的状态——这不会对存储 B 造成问题吗?

这不是一个常见的情况——我们的大多数存储都不会有任何依赖关系,我们应该可以安全地删除未使用的数据。然而,我们需要为具有依赖关系的存储制定一个游戏计划。让我们通过一个例子来了解一下,从视图开始。首先,我们有单选按钮视图,这是一个简单的控件,允许用户在用户列表和组列表之间切换:

import React from 'react';
import { id } from '../util';
import { showUsers, showGroups } from '../actions/show';

// This react view displays the two radio
// buttons that determine which list to display.
// Note that they're both using "map()" even
// though it's a single item array. This is to
// keep the logic in the store and out of the view.
export default ({ users, groups }) => (
  <div>
    {users.map(user =>
      <label key={id.next()}>
        {user.label}
        <input
          type="radio"
          name="display"
          checked={user.checked}
          onChange={showUsers}
        />
      </label>
    )}
    {groups.map(group =>
      <label key={id.next()}>
        {group.label}
        <input
          type="radio"
          name="display"
          checked={group.checked}
          onChange={showGroups}
        />
      </label>
    )}
  </div>
);

两个单选按钮的变化事件都连接到了一个动作创建函数,它会影响我们另外两个视图的显示——我们将在下面查看这些视图,从用户列表视图开始:

import React from 'react';

// A simple React view that displays a list of
// users.
export default ({ users }) => (
  <ul>
    {users.map(({ name, groupName }) =>
      <li key={name}>{name} ({groupName})</li>
    )}
  </ul>
);

非常直接,你可以看到这里存在一个组依赖关系,因为我们正在显示用户所属的组。我们稍后会深入探讨这个依赖关系,但现在,让我们看看组列表视图:

import React from 'react';

// A simple React view that displays a list
// of groups...
export default ({ groups }) => (
  <ul>
    {groups.map(group =>
      <li key={group}>{group}</li>
    )}
  </ul>
);

现在,让我们看看驱动这些视图的存储,从单选按钮存储开始:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';

import { SHOW_USERS, SHOW_GROUPS } from '../actions/show';

class Radio extends EventEmitter {
  constructor() {
    super();

    // This store represents radio buttons for
    // the "users" and "groups" display. Each
    // is represented as an array so that we can
    // easily take the take the button out of
    // the view by emptying the array.
    this.state = {
      users: [{
        label: 'Users',
        checked: true
      }],
      groups: [{
        label: 'Groups',
        checked: false
      }]
    };

    this.id = dispatcher.register((e) => {

      // Easy access to the state properties
      // we need in this handler. See the two
      // getter methods below.
      let { users, groups } = this;

      switch(e.type) {

        // Mark the "users" display as "checked".
        case SHOW_USERS:
          users.checked = true;
          groups.checked = false;

          this.emit('change', this.state);
          break;

        // Mark the "groups" display as "checked".
        case SHOW_GROUPS:
          users.checked = false;
          groups.checked = true;

          this.emit('change', this.state);
          break;
      }
    });
  }

  // A shortcut for easy access to the "users" state.
  get users() {
    return this.state.users[0]
  }

  // A shortcut for easy access to the "groups" state.
  get groups() {
    return this.state.groups[0]
  }
}

export default new Radio();

你可以在这里看到我们再次使用了单元素数组技术。这就是为什么在视图中使用这个存储数据的map()调用。想法是,为了隐藏这些按钮中的一个,我们可以在存储中直接将其设置为空集合——将逻辑保持在视图之外。注意,我们已经设置了一些基本的获取函数,以便更容易地处理这些单元素数组。现在让我们检查一下组存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { SHOW_GROUPS } from '../actions/show';

class Groups extends EventEmitter {
  constructor() {
    super();

    // The default "_group" state is an array of group
    // names.
    this.state = {
      _groups: [
        'Group 1',
        'Group 2'
      ]
    };

    // The "groups" state is what's actually used
    // by views and is an empty array by default
    // because nothing is displayed by default.
    this.state.groups = [];

    this.id = dispatcher.register((e) => {
      let { state } = this;

      switch(e.type) {

        // The "SHOW_GROUPS" action will map the
        // "_groups" state to the "groups" state
        // so that the view has something to display.
        case SHOW_GROUPS:
          state.groups = state._groups.map(x => x);
          this.emit('change', state);
          break;

        // By default, the "groups" state is emptied,
        // which clears out the view's elements. The
        // "_groups" state, however, remains intact.
        default:
          state.groups = [];
          this.emit('change', state);
          break;
      }
    });
  }
}

export default new Groups();

这个存储有两个状态项——_groupsgroups。是的,它们基本上是同一件事。区别在于视图依赖于groups,而不是_groupsGroups存储能够根据_groups计算组状态。这意味着我们可以在不触及_groups状态的情况下安全地删除groups状态来更新视图渲染。现在,其他存储可以依赖这个存储,而不会存在任何数据消失的风险。现在让我们看看用户存储:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import groups from './groups';
import { SHOW_USERS } from '../actions/show';

class Users extends EventEmitter {
  constructor() {
    super();

    // The default state of the "_users" state is
    // an array of user objects with references to
    // groups from another store.
    this.state = {
      _users: [
        { name: 'User 1', group: 1 },
        { name: 'User 2', group: 0 },
        { name: 'User 3', group: 1 }
      ]
    };

    // Sets the "users" state array, the state
    // that's actually used by views. See
    // "mapUsers()" below.
    this.mapUsers();

    this.id = dispatcher.register((e) => {
      let { state } = this;

      switch(e.type) {

        // If we're showing users, we need to "waitFor()"
        // the "groups" store because we depend on it.
        // Then we can use "mapUsers()" again.
        case SHOW_USERS:
          dispatcher.waitFor([ groups.id ]);

          this.mapUsers();

          this.emit('change', state);
          break;

        // The default action is to empty out
        // the "users" state so that the view
        // will delete the UI elements. However, the
        // "_users" state remains, so that other stores
        // that depend on this one can still access
        // the data.
        default:
          state.users = [];
          this.emit('change', state);
          break;
      }
    });
  }

  // Maps the "_users" state to the "users" state.
  // The idea being that the "users" array can be
  // emptied to update view displays while the "_users"
  // array remains intact for other stores to use.
  mapUsers() {
    this.state.users = this.state._users.map(user =>
      Object.assign({
        groupName: groups.state._groups[user.group]
      }, user)
    );
  }
}

export default new Users();

你可以看到Users存储能够依赖于Groups存储中的_groups状态,以便构建用户列表视图所需的状态。这个存储遵循与Groups存储相同的模式,即它有一个_users状态和一个users状态。这允许其他视图在必要时依赖于_users,我们仍然可以清除users状态以清除 UI。然而,如果最终发现没有任何东西依赖于这个存储,我们可以恢复模式,使得在不再需要当前视图时,只有一个状态项被删除。最后,让我们看看main.js模块,看看这一切是如何结合在一起的:

import React from 'react';
import { render } from 'react-dom';

import radio from './stores/radio';
import users from './stores/users';
import groups from './stores/groups';

import Radio from './views/radio';
import Users from './views/users';
import Groups from './views/groups';

// The container DOM element...
var container = document.getElementById('app');

// Renders the React components. The state for the
// Flux stores are passed in as props.
function renderApp(
  radioState=radio.state,
  usersState=users.state,
  groupsState=groups.state
) {
  render(
    <div>
      <Radio
        users={radioState.users}
        groups={radioState.groups}/>
      <Users
        users={usersState.users}/>
      <Groups
        groups={groupsState.groups}/>
    </div>,
    container
  );
}

// Renders the app with the new "radio" state.
radio.on('change', (state) => {
  renderApp(state, users.state, groups.state);
});

// Renders the app with the new "users" state.
users.on('change', (state) => {
  renderApp(radio.state, state, groups.state);
});

// Renders the app with the new "groups" state.
groups.on('change', (state) => {
  renderApp(radio.state, users.state, state);
});

// Initial app rendering...
renderApp();

摘要

扩展 Flux 架构的重点在于存储产生的信息,而不是各种组件。本章从讨论其他涉及不断创建和销毁 JavaScript 组件的常见实践开始。这样做是为了释放资源,但代价是潜在的出错可能性。接下来,我们探讨了 Flux 架构相对静态的特性,其中组件具有较长的生命周期。它们不需要不断创建和销毁组件,这意味着潜在的问题更少。

接下来,我们介绍了扩展信息的概念。我们通过展示,在扩展架构时,我们的 JavaScript 组件及其之间的连接是最不需要担心的。真正的挑战出现在有大量数据需要处理时,进入系统的数据很可能会比我们拥有的 JavaScript 组件数量增长得更快。

我们以一些处理未使用存储数据的例子结束了本章。这最终是扩展 Flux 架构最重要的方面,因为它将未使用的资源归还给浏览器。在下一章中,我们将探讨不可变存储的话题。这是我们全书一直提到的,现在我们将给予它一些集中的关注。

第九章。不可变存储

在本章中,我们将探讨 Flux 存储中的不可变数据。不可变性是一个经常与函数式编程相关的术语。不可变数据是在创建后不会改变(突变)的数据。关键好处是你可以预测应用程序中数据变化的根本原因,因为数据不能被副作用意外更改。不可变性和 Flux 之间相处得很好,因为它们都关于明确性和可预测性。

我们将从讨论隐藏的更新或副作用开始。Flux 本身就阻止了这类事情的发生,不可变数据有助于强化这一理念。然后,我们将探讨这些副作用对我们 Flux 架构完整性的影响。由突变存储数据引起的副作用最严重的后果是破坏了 Flux 的单向数据流。接下来,我们将看看不可变性的隐藏成本——这些成本大多与所需的额外资源相关,可能导致性能明显下降。最后,我们将探讨 Immutable.js 库,以帮助对不可变数据进行转换。

摒弃隐藏的更新

Flux 的单向特性使其与其他现代前端架构区分开来。单向数据流之所以有效,是因为动作创建者是唯一能让新数据进入系统的方式。然而,Flux 并没有强制执行这一点,这意味着一些错误的代码片段有可能完全破坏我们的架构。

在本节中,我们将探讨在 Flux 中如何实现类似这种情况。然后我们将探讨视图通常如何从存储中获取数据,以及是否有更好的方法。最后,我们将思考我们 Flux 架构中的其他组件,看看是否除了存储数据外,还可以使其他内容不可变。

如何打破 Flux

打破 Flux 最简单的方法是在没有经过适当渠道的情况下突变存储状态。动作分发者是新数据进入系统的入口,它还协调我们存储的动作处理器。例如,一个动作可能会触发几个存储的处理程序,以不同的方式使用动作负载。如果直接突变存储的状态,这种情况就不会发生。我们可能会幸运地发现我们做的更改没有任何副作用。但这不正是我们明确动作以避免不可预测的复杂副作用的前提吗?

如果我们降低门槛,开始直接在这里和那里操作状态,我们还能阻止自己更频繁地这样做吗?最可能的情况是视图事件处理器突变存储数据。这是因为视图通常有直接引用存储,而其他 Flux 组件通常没有。所以当用户点击按钮,我们的处理器只是改变存储的状态而不是分发动作时,我们可能会陷入麻烦。

让我们通过一个例子来了解一下在 Flux 领域外操作是多么危险。我们首先检查按钮商店:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { TOGGLE } from '../actions/toggle';

class Button extends EventEmitter {
  constructor() {
    super();

    // The default state is to show the button
    // as enabled and to process click events.
    this.state = {
      text: 'Enabled',
      disabled: false
    };

    this.id = dispatcher.register((e) => {
      let { state } = this;

      switch(e.type) {

        // When the "TOGGLE" action is dispatched,
        // the next state of the button depends on
        // the current state of the "disabled"
        // property.
        case TOGGLE:
          state.text = state.disabled ?
            'Enabled' : 'Disabled';
          state.disabled = !state.disabled;

          this.emit('change', state);
          break;
      }
    });
  }
}

export default new Button();

看起来很简单——控制文本和按钮的disabled状态。这很简单,但前提是我们遵守 Flux 规则并派发动作来改变商店的状态。现在,让我们看看一个使用此商店来渲染自己的视图组件:

import React from 'react';
import button from '../stores/button';
import { toggle } from '../actions/toggle';

function onClick() {

  // Oh snap! This just totally broke Flux...
  button.state.disabled = !button.state.disabled;

  // Call the action creator as we should...
  toggle();
}

// Renders your typical HTML button, complete
// with properties and a callback handler for
// click events.
export default ({ text, disabled }) => (
  <button
    onClick={onClick}
    disabled={disabled}>{text}</button>
);

这里本应发生的事情是,当按钮被点击时,它应该变为禁用状态,因为当TOGGLE动作被派发时,按钮商店将相应地改变状态。这部分工作如预期那样。然而,由于调用toggle()之前的这一行,结果将永远不会如预期那样工作。在这里,我们直接操作商店的状态。这阻止了在派发TOGGLE动作时预期的行为发生,因为状态已经被改变,所以现在它会恢复原状。

这些小技巧如果不小心,可能会在将来造成大麻烦。当你查看这个视图模块时,有问题的代码会跳出来。想象一下,在一个真实的项目中,有更多这样的视图,每个都比这个大得多——你能在事情变得太晚之前发现这个问题吗?

获取商店数据

由于引用商店状态是一件危险的事情,也许我们可以完全避免它?这样会大大减少错误的可能性,正如我们在上一节中看到的。例如,当两个商店相互依赖时,它们使用调度器的waitFor()方法来确保我们依赖的商店首先更新。然后我们就可以直接访问商店,知道其状态已经更新。这种方法如图所示:

获取商店数据

依赖的商店直接引用了它所依赖的商店的状态,如果我们不小心,这可能会导致大问题。另一种方法是有依赖的商店监听它所依赖的商店的更改事件。回调函数可以使用作为参数传递给它的新状态。当然,我们仍然需要使用waitFor()或类似的方法来确保商店按正确的顺序更新。这种方法如下所示:

获取商店数据

这开始看起来像是一个视图组件——视图会监听存储的变化事件,以便它们可以渲染 UI 更新来反映状态的变化。视图还需要执行存储数据的初始渲染,这也是为什么它们通常会引用存储状态。这些想法的问题在于,它们实际上并没有阻止我们直接访问存储状态——谁知道这些回调参数中会传递哪种类型的引用。另一个问题是,在可能直接读取值的地方引入回调函数,从设计角度来看是一种过度复杂化。必须有一种更好的方法。使我们的存储状态数据不可变是朝着正确方向迈出的一步。

备注

在下一章,我们将实现自己的分发器组件。在这样做的时候,我们会考虑实现一些防止在更新轮次发生时从存储中访问状态数据的保障措施,但存储尚未更新。这将有助于更容易地处理依赖项的故障排除。

一切都是不可变的

在讨论如何强制执行不可变性之前,让我们谈谈 Flux 架构中一切事物都应该是不可变性的观点。从理论上讲,这不应该很难做到,因为 Flux 将状态限制在存储内部,不允许其存在于其他任何地方。所以,让我们从存储开始。

我们的所有存储都应该是不可变的,或者可能只是其中一些?只有一些不可变的存储不是一个好主意,因为它会促进不一致性。那么,在所有地方都实现不可变性,这是否是必要的?这是一个非常重要的问题,我们必须对我们的架构提出这个问题,因为这里没有一刀切的答案。当我们需要额外的保证,即以后不会有存储状态上的惊喜时,不可变性的论点就有效了。反论是,我们作为程序员足够自律,不可变性的机制只是增加了开销。

我们将在本章的剩余部分论证不可变数据的好处,因为几乎在所有情况下,其优点都超过了缺点。无论你对不可变性的看法如何,了解它在 Flux 架构中的优势都是有益的——即使你不会使用它。

那么视图组件呢——它们实际上可以是不可变的吗?好吧,实际上它们不能,因为 DOM API 不允许这样做。我们的视图组件实际上必须操纵页面上的元素状态。然而,如果我们使用像 React 这样的视图技术,那么我们会得到一层不可变的面纱,因为其理念是始终重新渲染组件。所以,当我们实际上在用 React 处理 DOM 操作的同时,我们似乎是在用新元素替换旧元素。这促进了这样一个观点,即状态在 Flux 视图中没有位置。

强制单向数据流

如果新数据只通过调度器派发的动作有效载荷进入系统,并且我们的存储数据是不可变的,那么我们就有一个单向数据流。这是我们的目标,所以问题是,我们如何强制执行这一点?我们能否简单地声明我们的存储数据是不可变的,然后就此结束?嗯,那绝对是一个值得追求的目标,但还有更多的事情要做。

在本节中,我们将讨论数据以非预期方向流动的概念,以及是什么导致了这种情况。然后,我们将考虑拥有太多存储和太少动作作为导致数据流功能障碍的贡献因素。最后,我们将检查一些我们可以利用的技术,以使我们的存储数据不可变。

反向、横向和泄漏的数据流

Flux 架构有一个单向数据流——数据从左侧进入,从右侧退出。这很容易想象成一个向前移动的流程。那么,这种流程可能会出错的地方有哪些呢?以反向流为例。如果一个视图实例持有存储实例的引用,并继续修改其状态,那么流程就是从视图流向存储。这与预期的流程方向完全相反,因此它是向后的。以下是这种外观的说明:

反向、横向和泄漏的数据流

这显然不是我们在使用 Flux 系统时预期的。但除非我们通过让存储的状态返回不可变数据结构给任何想要与之交互的其他组件来排除这种可能性,否则这是一个很可能会发生的情况。那么,存储——它们能否修改另一个存储的状态呢?它们不应该这样做,如果它们这样做,那看起来就像是一个横向数据流。

注意

ProTip: 任何横向移动的东西都是不好的。

下面是一个 Flux 存储之间横向数据流的例子:

反向、横向和泄漏的数据流

这和直接修改存储状态的观点组件一样糟糕,因为我们刚刚改变的状态可能会影响下一个计算出的状态。这正是我们在本章第一个代码示例中看到的情况。

那么,关于动作,它们能否直接操作存储的状态呢?这可能是最不可能的情况,因为动作创建函数应该在协调任何异步行为后仅仅派发动作。然而,动作创建函数可能在 AJAX 回调处理程序中错误地修改存储状态,例如。我们称之为泄漏流,因为它们绕过了调度器。因此,我们在没有任何可追踪的动作来显示它们来源的情况下泄漏了修改。以下是这个想法的说明:

反向、横向和泄漏的数据流

存储太多?

总是有可能我们的架构中存在太多的 Flux 商店。也许应用的功能已经超出了我们最初的设计。现在,仅仅将商店映射到功能上是不够的,因为已经有几十个商店了。

如果我们无法控制商店的数量,一个可能的结果是其他组件将直接进行状态突变。这仅仅是一个便利性的问题,如果有很多商店需要考虑,那就意味着每次我们想要做些什么时,我们都必须处理几个与调度器相关的开发活动。当有大量商店时,就有直接操作它们状态的冲动。移除商店可以减少这种冲动。

动作不足

我们的 Flux 架构可能没有足够的动作吗?例如,我们正在工作的视图需要改变商店的状态。没有动作来处理这个问题,所以我们不是构建一个新的动作创建器并更新商店以处理逻辑,而是直接突变商店。这听起来像是一项足够简单的任务——构建一个动作创建器函数并添加必要的商店更新逻辑。但如果我们必须不断实现这些一次性动作创建器函数,最终我们可能就不再关心了。解决这个问题有两种方法。第一种是实现更通用的动作,这些动作不仅适用于特定的情况,还可以接受参数。第二种是在你需要它们之前,构建一些与你在工作的功能相关的动作创建器函数。当你知道这些函数存在时,在潜意识里,你更有可能使用它们。

强制不可变性

让我们探索一些保持商店状态不可变的不同方法。目标是当某个外部实体引用商店的状态时,该实体对状态所做的任何更改实际上都不会影响商店,因为数据是不可变的。我们将首先实现一个不实际返回其状态引用的商店——它使用Object.assign()返回状态的副本:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// The state of this store is encapsulated
// within the module.
var state = {
  first: 1,
  second: 2,
};

class Copy extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        case MY_ACTION:

          // Mutates "state" with new properties...
          Object.assign(state, e.payload);
          this.emit('change', state);
          break;
      }
    });
  }

  // Returns a new copy of "state", not a reference
  // to the original.
  get state() {
    return Object.assign({}, state);
  }
}

export default new Copy();

在这里,你可以看到实际的商店状态位于模块级别的state变量中。这意味着它不能被外部世界直接访问,因为它没有被导出。我们希望状态以这种方式封装,这样其他组件就难以突变它。如果其他组件需要读取商店状态属性,它们可以读取商店的state属性。由于这是一个 getter 方法,它可以计算返回的值。在这种情况下,我们将动态创建一个新的对象。现在让我们看看一个将状态存储在常量中的商店:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// The state of this store is encapsulated
// within this module. It's also stored as
// a constant.
const state = {
  first: 1,
  second: 2,
};

class Constant extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        case MY_ACTION:
          // Mutates "state" with new properties...
          Object.assign(state, e.payload);
          this.emit('change', state);
          break;
      }
    });
  }

  // Returns a reference to the "state" constant...
  get state() {
    return state;
  }
}

export default new Constant();

这个存储结构与Copy存储相同,模式也一致。不同之处在于state不是一个变量——它是一个常量。这意味着我们不应该能够修改它,对吧?嗯,并不完全是这样——我们只是不能给它赋新值。因此,这种方法的价值有限,因为state()获取器返回的是常量的直接引用。我们将在其他组件使用存储时,稍后看到这种方法是如何工作的。让我们再看看另一种方法,它使用Object.frozen()使对象不可变:

import { EventEmitter } from 'events';
import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// The store state is encapsulated within
// this module...
var state;

// Merges new values with current values, freezes
// the new state, and assigns it to "state".
function change(newState) {
  let changedState = Object.assign({}, state, newState);
  state = Object.freeze(changedState);
}

// Sets the initial state and freezes it...
change({
  first: 1,
  second: 2,
});

class Frozen extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        case MY_ACTION:

          // Calls "change()" to update the "state"
          // value and re-freeze it.
          change(e.payload);
          this.emit('change', state);
          break;
      }
    });
  }

  // Returns a reference to the frozen "state"...
  get state() {
    return state;
  }
}

export default new Frozen();

state()获取器实际上返回的是冻结的state变量的引用。这个方法有趣的地方在于,我们不一定需要创建数据的新副本,因为我们的change()函数已经使其不可变。而当存储本身需要更新其状态时,状态就会被重新冻结。

让我们看看这些方法现在是如何比较的。首先,我们将导入存储并获取它们状态的引用:

import copy from './stores/copy';
import constant from './stores/constant';
import frozen from './stores/frozen';

var copyState = copy.state;
var constantState = constant.state;
var frozenState = frozen.state;

copyState.second++;
constantState.second++;

try {
  frozenState.second++;
} catch (err) {
  console.error(err);
  // →
  // TypeError: Cannot assign to read only property
  // 'second' of object
}

console.assert(
  copy.state.second !== copyState.second,
  'copy.second mutated'
);

console.assert(
  constant.state.second !== constantState.second,
  'constant.second mutated'
);
// → Assertion failed: constant.second mutated

看起来我们能够成功改变copyState的状态。这在某种程度上是正确的——我们改变了一个副本的状态,而这个副本实际上并不反映存储的状态。另一方面,constantState的改变确实有副作用,因为任何从常量存储读取状态的组件都会看到这个变化。

当我们尝试更改frozenState时,会抛出一个TypeError。这实际上可能是我们想要的结果,因为它明确指出我们尝试对fronzenState所做的事情是不允许的。当我们向存储状态添加新属性时,也会发生类似的事情——复制会静默失败,常量会失败,而冻结会明确失败:

copyState.third = 3;
constantState.third = 3;

try {
  frozenState.third = 3;
} catch (err) {
  console.error(err);
  // →
  // TypeError: Can't add property third, object is
  // not extensible
}

最后,让我们看看在更改事件发出时发送的状态数据:

copy.on('change', (state) => {
  console.assert(state !== copyState, 'copy same');
  console.assert(state.fourth, 'copy missing fourth');
});

constant.on('change', (state) => {
  console.assert(state !== constantState, 'constant same');
  // → Assertion failed: constant same

  console.assert(state.fourth, 'constant missing fourth');
});

frozen.on('change', (state) => {
  console.assert(state !== frozenState, 'frozen same');
  console.assert(state.fourth, 'frozen missing fourth');
});

myAction({ fourth: 4 });

myAction()函数将使用新数据扩展存储状态。正如我们再次看到的,常量方法失败了,因为它返回了被修改的相同引用。一般来说,这些方法在实践中都不太容易实现。这也是我们想要认真考虑使用像Immutable.js这样的库的另一个原因,在那里不可变性是默认模式,并且大部分隐藏在我们的代码中。

不可变数据的成本

到现在为止,你已经非常清楚不可变数据给 Flux 架构带来的优势——关于我们单向数据流的保证。这个安全网是有代价的。在本节中,我们将讨论不可变性可能带来的高昂成本以及我们可以采取的措施。

我们将从覆盖最大的不可变性问题——瞬态内存分配和垃圾回收开始。这些因素对我们 Flux 架构的性能构成了重大威胁。接下来,我们将考虑通过批量处理不可变数据上的转换来减少内存分配的数量。最后,我们将探讨不可变数据消除仅用于处理数据可变场景的代码的方式。

垃圾回收成本高昂

可变数据结构的一个好处是,一旦它们被分配,它们通常会持续一段时间。也就是说,我们不需要将现有结构的属性复制到一个新结构中,然后在需要更新时销毁旧结构。这是软件,所以我们将进行很多更新。

使用不可变数据时,我们面临着内存消耗的挑战。每次我们修改一个对象时,都必须为该对象分配一个新的副本。想象一下,我们处于一个循环中,在一个集合中对不可变对象进行修改——这会在短时间内产生大量的内存分配——可以说是峰值。此外,被新对象取代的旧对象并不会立即从内存中删除。它们必须等待垃圾回收器来清理。

当我们的应用程序使用的内存超过其需求时,性能会受到影响。当垃圾回收器因为我们的内存分配而频繁运行时,性能会受到影响。正是垃圾回收器,而不是其他任何东西,触发了卡顿的用户体验,因为我们的 JavaScript 代码在运行时无法响应任何挂起的事件。

可能有一种处理不可变数据的方法,比在只需要简单更新时替换大型对象更节省内存。

批量修改

幸运的是,存储会修改自己的状态。这意味着存储分发回调封装了状态转换期间发生的所有操作。所以如果我们的存储有不可变状态数据,那么外部世界就不需要知道存储在内部为了减少内存分配数量而采取的任何捷径。

假设一个存储接收一个动作,并且它必须在它的状态上执行三个单独的转换:进行一个转换以生成一个新对象,然后在这个新对象上执行另一个转换,依此类推。这是为中间数据进行的许多短暂的内存分配,而这些中间数据其他组件永远不会触及。以下是对正在发生的事情的说明:

批量修改

我们希望最终结果是新的状态引用,但中间创建的新状态是浪费的。让我们看看我们是否可以在返回最终不可变值之前将这些状态转换批量处理:

批量修改

现在,尽管我们进行了三次状态转换,但我们只分配了一个新的对象。我们在 Flux 存储中进行的修改对系统中的任何其他组件来说都是微不足道的,但我们仍然保持了任何其他想要访问和读取此状态的组件的不变性。

转移成本

可变数据的痛苦之处在于,使用这些数据的组件必须考虑副作用。它们并不一定知道这些数据何时以及如何会发生变化。因此,它们需要处理副作用的代码。虽然处理意外副作用的代码通常不会占用更多内存,但它也不是免费运行的。当我们的源代码中到处都有处理边缘情况的代码时,性能下降会累积。使用不可变数据,我们可以移除大部分,如果不是全部的话,这些检查状态的额外代码,因为我们能更好地预测它将会是什么。这有助于抵消额外内存分配和垃圾回收运行的代价。即使我们不在我们的商店中使用不可变数据,Flux 架构也使得副作用处理代码的需求几乎变得过时。单向数据流使得 Flux 非常可预测。

使用 Immutable.js

来自 Facebook 的 Immutable.js 库提供了不可变的 JavaScript 数据结构。这听起来可能很平凡,但幕后有很多事情发生以确保这一点,即尽可能高效地从转换中创建新实例。

在本节中,我们将探讨不可变列表和映射。这些可以在我们的 Flux 商店数据中分别作为数组和普通对象的可行替代品。然后,我们将看看 Immutable.js 如何在不需要中间表示的情况下组合复杂的转换。最后,我们将看到 Immutable.js 在经过转换后没有突变时返回相同的实例,从而允许高效的变更检测。

不可变列表和映射

我们将从查看列表和映射开始,因为这些都是我们将在商店中实现的一些常见结构。列表有点像数组,映射有点像普通的 JavaScript 对象。让我们实现一个使用列表的商店:

import { EventEmitter } from 'events';
import Immutable from 'Immutable';

import dispatcher from '../dispatcher';
import { LIST_PUSH, LIST_CAPS } from '../actions/list';

// The state of this store is an "Immutable.List"
// instance...
var state = Immutable.List();

class List extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // When the "LIST_PUSH" action is dispatched,
        // we create a new List instance by calling
        // "push()". The new list is assigned to "state".
        case LIST_PUSH:
          this.emit('change',
            (state = state.push(...e.payload)));
          break;

        // When the "LIST_CAPS" action is dispatched,
        // we created a new List instance by calling
        // "map()". The new list is assigned to "state".
        case LIST_CAPS:
          this.emit('change',
            (state = state.map(x => x.toUpperCase())));
          break;
      }
    });
  }

  get state() {
    return state;
  }
}

export default new List();

您可以看到,state 变量被初始化为一个空的 Immutable.List() 实例(new 关键字不是必需的,因为这些是返回新实例的函数)。每当我们在列表实例上调用一个方法时,就会返回一个新的实例。这就是为什么我们必须将调用 push()map() 的结果赋值给 state 的原因。

现在让我们实现一个映射商店:

import { EventEmitter } from 'events';
import Immutable from 'Immutable';

import dispatcher from '../dispatcher';
import { MAP_MERGE, MAP_INCR } from '../actions/map';

// The state of this store is an "Immutable.Map"
// instance...
var state = Immutable.Map();

class MapStore extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // When the "MAP_MERGE" action is dispatched,
        // we create a new Map instance by calling
        // "merge()". The new map is assigned to "state".
        case MAP_MERGE:
          this.emit('change',
            (state = state.merge(e.payload)));
          break;

        // When the "MAP_INCR" action is dispatched,
        // we create a new Map instance by calling
        // "map()". The new map is assigned to "state".
        case MAP_INCR:
          this.emit('change',
            (state = state.map(x => x + 1)));
      }
    });
  }

  get state() {
    return state;
  }
}

export default new MapStore();

如您所见,映射遵循与列表相同的不可变模式。主要区别在于它们是按键而不是按索引来组织的。现在让我们看看这两个存储是如何使用的:

import list from './stores/list';
import map from './stores/map';
import { listPush, listCaps } from './actions/list';
import { mapMerge, mapIncr } from './actions/map';

// Logs the items in the "list" store when
// it's state changes.
list.on('change', (state) => {
  for (let item of state) {
    console.log('  list item', item);
  }
});

// Logs the items in the "map" store when
// it's state changes.
map.on('change', (state) => {
  for (let [key, item] of state) {
    console.log(`  ${key}`, item);
  }
});

console.log('List push...');
listPush('First', 'Second', 'Third');
// → List push...
//     list item First
//     list item Second
//     list item Third

console.log('List caps...');
listCaps();
// → List caps...
//     list item FIRST
//     list item SECOND
//     list item THIRD

console.log('Map merge...');
mapMerge({ first: 1, second: 2 });
// → Map merge...
//     first 1
//     second 2

console.log('Map increment...');
mapIncr();
// → Map increment...
//     first 2
//     second 3

不可变转换

现在,是时候在 Flux 商店内部实现一个更复杂的转换了。这意味着将 Immutable.js 结构上的操作链式化以创建一个新的结构。但是,关于中间内存分配——我们肯定想关注这些,对吧?这里有一个尝试在转换商店状态时使用更少内存的商店:

import { EventEmitter } from 'events';
import Immutable from 'Immutable';

import dispatcher from '../dispatcher';
import { SORT_NAMES } from '../actions/sort-names';

// The state is an object with two immutable
// list instances. The first is a list of user
// maps. The second is a list of user names and
// is empty by default.
const state = {
  users: Immutable.List([
    Immutable.Map({ id: 33, name: 'tHiRd' }),
    Immutable.Map({ id: 22, name: 'sEcoNd' }),
    Immutable.Map({ id: 11, name: 'firsT' })
  ]),
  names: Immutable.List()
};

class Users extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // The "SORT_NAMES" action was dispatched...
        case SORT_NAMES:

          // Determines the "sort" multiplier that's passed
          // to "sortBy()" to sort in ascending or
          // descending direction.
          let sort = e.payload === 'desc' ? -1 : 1;

          // Assigns the sorted list to "users" after
          // performing a series of transforms. The
          // "toSeq()" and "toList()" calls aren't strictly
          // necessary. Any calls in between them, however,
          // don't result in new structures being created.
          state.names = state.users
            .sortBy(x => x.get('id') * sort)
            .toSeq()
            .map(x => x.get('name'))
            .map(x => `${x[0].toUpperCase()}${x.slice(1)}`)
            .map(x => `${x[0]}${x.slice(1).toLowerCase()}`)
            .toList();

          this.emit('change', state);
          break;
      }
    });
  }

  get state() {
    return state;
  }
}

export default new Users();

SORT_NAMES动作导致我们对不可变列表进行了一些有趣的转换。想法是将它映射到一个按用户id排序的大写用户名列表。这里使用的技术涉及在排序后使用toSeq()将列表转换为序列,这样做是为了防止map()调用分配新的结构,因为我们实际上不需要一个具体结构直到映射完成。为此,我们只需调用toList(),这将调用我们在序列上设置的所有映射并创建列表。这意味着我们在这里创建的唯一结构是来自sortBy()的新列表、来自toSeq()的新序列和来自toList()的新列表。

在这个特定的例子中,这可能是过度杀鸡用牛刀,仅仅是因为我们在一个包含三个元素的列表上执行了三个操作。所以,我们只需从我们的代码中移除toSeq()toList()来简化事情。然而,当我们扩展到更大的集合和更复杂的转换时,了解这种技术以减少我们架构的内存占用是有益的。现在让我们看看这个存储的实际应用效果:

import users from './stores/users';
import { sortNames } from './actions/sort-names';

// Logs the user names...
users.on('change', ({names}) => {
  for (let item of names) {
    console.log('  name', item);
  }
});

console.log('Ascending...');
sortNames();
// → Ascending...
//     name First
//     name Second
//     name Third

console.log('Descending...');
sortNames(true);
// → Descending...
//     name Third
//     name Second
//     name First

变化检测

在本章的最后一个例子中,我们将看到是否可以使用Immutable.js结构在我们的 Flux 存储中实现高效的变化检测。实际上,检测本身将在 React 视图中进行,但这依赖于存储状态使用Immutable.js对象。我们为什么要这样做——React 在计算 diff 时不是已经足够高效了吗?React 在这里确实很出色,但它仍然需要做相当多的工作来确定不需要重新渲染。我们可以通过提供存储状态实际上没有变化的提示来帮助我们的 React 组件。所以,不拖泥带水,这是我们将会使用的存储:

import { EventEmitter } from 'events';
import Immutable from 'Immutable';

import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// The store state is an Immutable.js Map instance.
var state = Immutable.Map({
  text: 'off'
});

class MyStore extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // When "MY_ACTION" is dispatched, we set
        // the "text" property of "state" as the
        // "payload". If the value has change, "state"
        // "set()" returns a new instance. If there's
        // no change, it returns the same instance.
        case MY_ACTION:
          this.emit('change',
            (state = state.set('text', e.payload)));
          break;
      }
    });
  }

  get state() {
    return state;
  }
}

export default new MyStore();

在我们这边没有做任何花哨的事情;我们只是使用Immutable.js Map作为我们的存储状态。当调用set()时,我们将新的Map实例分配给状态,因为返回的是一个新实例。这里是我们感兴趣的启发式方法——如果没有变化,返回相同的实例。让我们看看我们如何在我们视图中使用Immutable.js数据的这个属性:

import { default as React, Component } from 'react';

export default class MyView extendsComponent {

  render() {

    // Logs the fact that we're rendering because
    // "shouldComponentUpdate()" will prevent it
    // if the store state hasn't changed.
    console.log('Rendering...');

    let { state } = this.props;

    return (
      <p>{state.get('text')}</p>
    );
  }

  // Since we're using an Immutable.js Map as
  // the store state, we know that if the
  // instances are equal, nothing has changed
  // and there's no need to render.
  shouldComponentUpdate(nextProps) {
    return nextProps.state !== this.props.state;
  }
}

这个组件的关键部分是shouldComponentUpdate()方法,它通过进行严格的不等式比较来确定存储是否已更改。在这种情况下,如果这个组件被渲染很多次但不需要更改任何内容,这将避免大量的虚拟 DOM 树检查。现在,让我们看看我们如何使用这个视图:

import React from 'react';
import { render } from 'react-dom';

import myStore from './stores/my-store';
import MyView from './views/my-view';
import { myAction } from './actions/my-action';

// The container DOM element for our React component.
const container = document.getElementById('app');

// The payload that's sent to "myAction()"...
var payload = 'off';

// Renders the React components using the
// "myStore" state...
function renderApp(state=myStore.state) {
  render(
    <MyView state={myStore.state} />,
    container
  );
}

// Re-render the app when the store changes...
myStore.on('change', renderApp);

// Performs the initial rendering...
renderApp();

// Dispatches "MY_ACTION" every 0.5 seconds. This
// causes the store to change state and the app
// to re-render.
setInterval(() => {
  myAction(payload);
}, 500);

// After 5 seconds, change the payload that's
// dispatched with "MY_ACTION" so that the store
// state is actually different.
setTimeout(() => {
  payload = 'on';
}, 5000);

正如你所见,那些导致我们视图重新渲染的操作是持续不断地被派发的。然而,由于我们存储库中的set()调用在没有任何变化时返回相同的实例,视图本身实际上并没有做多少工作。然后,一旦我们在 5 秒后更改了有效载荷值,Immutable.js映射实例就会改变,视图也会更新。这个视图总共渲染了两次——初始渲染和存储数据实际发生变化时的渲染。

你可能已经注意到,这种实现本可以走向另一个方向,其中一个存储库不会在没有任何变化时发出更改。这完全取决于个人品味和权衡。我们选择的方法确实要求视图在优化渲染工作流程中扮演一个积极的角色。这对于 React 组件来说很容易做到,并且简化了我们的存储逻辑。另一方面,我们可能更喜欢保持我们的视图完全无逻辑,包括shouldComponentUpdate()检查。如果是这样,我们只需将这个逻辑移回存储库,并且如果两个Immutable.js实例相同,则不会发出更改事件。

摘要

本章向你介绍了不可变性——既从术语的一般意义上,也从事务流架构的角度来看。我们本章开始时讨论了可变数据如何破坏 Flux 的多种方式。特别是,这破坏了任何 Flux 架构的皇冠宝石——单向数据流。接下来,我们研究了当我们开始在存储之外修改数据时出现的不同类型的数据流,因为这些是在调试 Flux 架构时值得寻找的好东西。

我们有几种方法可以在我们的 Flux 存储中强制执行不可变数据,我们已经探索了许多方法。不可变数据是有代价的——因为垃圾收集器需要不断运行,阻止其他 JavaScript 代码运行,以收集所有这些额外的对象副本。我们探讨了如何最小化这些额外的内存分配,以及如何抵消使用不可变数据的总体成本。

我们通过实现几个使用Immutable.js数据结构的存储库来结束本章。这个库默认为我们提供了不可变性、附加功能和高效的中间内存分配。在下一章中,我们将实现我们自己的派发组件。

第十章:实现分发器

到目前为止,本书中我们一直依赖于 Flux 分发器的参考实现。这样做并没有什么问题——它是一块功能性的软件,分发器没有太多可移动的部分。另一方面,它只是更大想法的一个参考实现——动作需要被分发到存储中,存储依赖项需要被管理。

我们将从讨论 Flux 架构所需的抽象分发器接口开始。接下来,我们将讨论实现我们自己的分发器的动机。最后,我们将在本章的剩余部分致力于实现我们自己的分发器模块,并改进我们的存储组件,以便它们能够无缝地与新的分发器交互。

抽象分发器接口

任何参考实现的思路都是直接通过代码来展示某物应该如何工作。Facebook 对 Flux 分发器的参考实现正是如此——我们可以在实际的 Flux 架构中使用它并获得结果。我们还获得了对抽象分发器接口的理解。换句话说,参考实现有点像软件需求,只是以代码的形式表达。

在我们深入自己的分发器实现之前,本节将尝试更好地理解这些最小要求。分发器必须实现的第一项基本功能是存储注册,以便分发器可以向其分发有效载荷。然后,我们需要实际的分发机制,它遍历已注册的存储并交付有效载荷。最后,我们在分发有效载荷时需要考虑依赖语义。

存储注册

当我们实例化一个存储时,我们必须告诉分发器关于它的信息。否则,分发器不知道存储的存在。通常的模式看起来像这样:

存储注册

分发器维护一个内部回调集合,以便在分发动作时运行。它只需要遍历这个回调函数集合,依次调用每个函数。这听起来真的很简单,当 Flux 更新周期中的所有操作都是同步的时候。问题是,我们想改变存储注册过程的方式吗?

也许我们不是在存储构造函数内部注册回调函数,而是将存储实例本身的引用传递给分发器?那么,当需要通知存储有关已分发的动作时,分发器将遍历存储实例的集合并调用一些预定义的方法。这种方法的优点是,由于分发器有存储的引用,它可以访问存储的其他元数据,例如其依赖项。

当我们开始编写代码时,我们将在本章稍后进一步探讨这个想法。底线是——我们需要一种方法来告诉调度器,给定的存储实例希望接收动作通知。

分发有效载荷

实际上分发有效载荷相当简单。唯一复杂的部分是处理存储之间的依赖关系——我们将在下一节讨论这个问题。现在,只需想象一个没有存储间依赖关系的架构。它只是一个简单的集合,可以迭代,每个函数都使用动作有效载荷作为参数。以下是这个过程的一个说明:

分发有效载荷

除了依赖管理之外,这幅图景中是否还缺少其他内容?嗯,我们可能会遇到一种情况——嵌套调度。在 Flux 架构中,嵌套调度是严格禁止的,因为它们会破坏同步单向更新轮次。实际上,Facebook 对调度器的参考实现会跟踪任何给定更新轮次的状态,并在发生这种情况时捕获它。

这并不意味着我们实现的调度器组件必须检查这种条件。然而,当发生如此破坏架构本质的事情时,快速失败从不是个坏主意。

值得思考的另一件事是在给定更新轮次中调用每个已注册存储的必要性。当然,从一致性角度来看,这样做是有意义的——对待每个存储都一样,并通知他们关于所有事情。另一方面,我们可能有一个拥有数百个动作被分发的庞大应用。总是向那些从未对这些动作做出响应的存储分发动作有意义吗?当我们实现自己的调度器组件时,我们可以自由地思考如何实现这样的启发式方法,以使我们的应用受益,同时保持对 Flux 原则的忠诚。

处理依赖关系

分发动作最具挑战性的方面可能是确保存储依赖被正确处理。另一方面,调度器只需确保以正确的顺序调用存储动作处理器。考虑到依赖关系的动作分发在此处得到说明:

处理依赖关系

只要waitFor()调用右侧的存储在收到调度通知,那么一切就绪。所以从本质上讲,对于调度器来说,存储依赖是一个排序问题。以满足依赖图的方式排序回调,然后迭代并调用每个处理器。

问题是——我们真的想依赖waitFor()调度器方法来管理存储依赖吗?可能有一种更好的方法来处理这个问题,那就是声明一个我们依赖的存储数组。这样,在注册时就会将其传递给调度器,我们就不再需要waitFor()调用。

我们已经有了实现我们自己的分发器所需的基本蓝图。但在我们开始实施之前,让我们花更多的时间讨论一下面对 Facebook 分发器所遇到的挑战。

分发器的挑战

在上一节中,我们瞥见了 Facebook Flux 分发器参考实现的一些潜在挑战。在本节中,我们将详细阐述一些这种推理,试图提供实施我们自己的自定义分发器的动机。

在本节中,我们将重申 Flux NPM 包主要作为一个教育工具而存在的事实。依赖这样的包是可以的,特别是因为它完成了工作,但我们将讨论一些这种东西在生产环境中可能带来的风险。然后,我们将讨论分发器组件是单例实例的事实,它们可能并不需要是单例。

然后,我们将思考存储注册过程,以及它比必要的更手动的事实。最后,我们将再次讨论存储依赖管理问题,并讨论 waitFor() 和可能的声明式替代方案。

教育目的

如我们所知,Facebook Flux NPM 包提供了一个分发器的参考实现。了解这样一个组件应该如何工作的最佳方式是编写使用它的代码。换句话说,这是为了教育目的。这使我们能够快速起步,因为我们找到了编写 Flux 代码的最佳方式。Facebook 本可以省略分发器实现,让阅读 Flux 文档的程序员自己找出这一点。代码是非常有教育意义的,它也充当了一种文档形式。即使我们决定我们并不喜欢分发器的实现方式,我们至少可以阅读代码来了解分发器应该做什么。

如果我们在生产环境中使用这个包,会有任何风险吗?如果我们使用项目中默认的 Flux 分发器,并且针对它开发的一切都正常工作,那么我们没有理由不能在生产应用程序中使用它。如果它工作,它就工作。然而,这个参考实现是为了教育目的而设计的,这很可能意味着它没有进行严肃的开发。以 React 为反例,数百万人在生产环境中使用这个软件。这种技术向前推进并自我改进是有动力的。然而,对于参考分发器实现来说,情况并非如此。自己动手实现肯定值得考虑,尤其是如果还有改进的空间的话。

单例分发器

如果我们使用 Facebook 的 Flux 分发器,我们必须实例化它,因为它只是一个类。然而,由于在任何给定时间只有一个更新轮次发生,整个应用程序中不需要超过一个分发器实例。这是单例模式,而且并不总是最好的模式。一方面,它是无用的间接引用。

例如,每次我们想要派发一个动作时,我们需要访问分发器的dispatch()方法。这意味着我们必须导入分发器实例,并使用实例作为上下文调用方法,就像这样:dispatcher.dispatch()register()方法也是一样;当存储想要将自己注册到分发器时,它首先需要访问实例,然后再调用方法。

因此,似乎这个单例分发器实例除了妨碍和使代码更加冗长之外,没有真正的作用。如果我们不是使用单例类实例,而是将分发器仅仅作为一个导出相关函数的简单模块,会怎么样呢?这将大大简化需要分发器的地方的代码,如果我们的应用程序有很多存储和动作,那么这个地方可能相当多。

手动存储注册

Flux 架构的一个不变量是存储连接到分发器。除了通过派发动作之外,没有其他方式可以改变存储的状态。所以除非我们想要一个永远不会改变状态的静态存储,否则我们需要将其注册到分发器上。我们在这本书中迄今为止看到的所有示例存储都在构造函数中设置了它们的分发器处理器。这就是我们处理可能改变存储状态的动作的地方。

由于分发器注册是既定的,我们真的需要在每个存储创建时显式注册一个回调函数吗?另一种方法可能涉及一个基类存储,它会为我们处理这个注册;这并不一定是分发器特定的一个问题。

存储注册的另一个方面,大部分情况下感觉是不必要的,是管理分发器 ID。例如,如果我们实现一个依赖于另一个存储的存储,我们必须引用那个其他存储的派发 ID。使用 ID 的原因很简单——回调函数不能识别存储。因此,我们必须使用分发器将回调 ID 映射到存储。整个方法感觉非常混乱,所以当我们实现自己的分发器时,我们可以完全去掉这些派发 ID。

容易出错的依赖管理

我们还希望解决默认 Facebook Flux 分发器处理存储之间依赖关系的方式。waitFor()机制完成了它的任务,即在所有依赖都处理了动作之前,它会阻塞处理器的进一步执行。这样,我们知道我们依赖的存储总是最新的。问题是waitFor()感觉有点容易出错。

首先,它必须始终位于相同的位置——存储动作处理器的顶部。我们必须记住使用我们所依赖的存储的调度 ID,这样 waitFor() 才知道要处理哪些存储。一种更声明式的方法意味着我们可以将存储的依赖项设置为一个存储引用数组或类似的东西。这样,依赖关系就在实际的回调函数之外声明,并且更加明显。我们将找出在我们的调度器中实现这一方法的方式,我们现在就开始着手。

构建调度器模块

在本节中,我们将实现我们自己的调度器模块。这将成为我们迄今为止在这本书中依赖的 Facebook 参考实现的替代品。首先,我们将考虑调度器将如何跟踪存储模块的引用。然后,我们将讨论该模块需要公开的函数,接着将介绍 dispatch() 的实现过程。最后,我们将确定我们想要如何使用这个调度器模块来处理依赖关系管理。

封装存储引用

我们需要考虑的调度器模块的第一个方面是存储本身。在 Facebook 的参考实现中,没有存储的引用——只有回调函数的引用。也就是说,当我们向 Facebook 的调度器注册时,我们传递的是 register() 方法的一个函数,而不是存储实例本身。我们的调度器模块将保留存储引用,而不仅仅是回调函数。以下是一个说明参考实现采用的方法的图表:

封装存储引用

每次调用 register() 时,它都会将一个回调函数添加到回调函数列表中,该列表将由调度器在任何动作被调度时处理。然而,缺点是调度器可能需要访问存储以获取我们想要实现的高级功能,正如我们很快就会看到的。因此,我们将想要注册存储实例本身,而不仅仅是回调函数。这里展示了这种方法:

封装存储引用

回调函数列表现在是一个存储实例的列表,当动作被调度时,调度器现在可以访问存储数据,这对于像方法和依赖列表这样的功能非常有用。这里的权衡是回调函数更通用,它们只是被调度器调用。正如我们很快就会看到的,这种方法有一些优势,可以使存储代码更加简化。

处理依赖关系

在我们的调度器实现方面,我们首先考虑的是存储之间的依赖关系是如何管理的。标准的方法是实现一个waitFor()方法,该方法在存储处理函数中阻塞执行,直到它所依赖的存储被处理。如您现在所意识到的,这种方法由于它在处理函数中使用,可能会出现问题。我们正在追求的实现是一个更声明性的方法。

策略是,依赖于存储的列表被声明为存储的一个属性。这允许存储查询它所依赖的其他存储。它还把存储的依赖管理方面从应该专注于动作的处理代码中分离出来。以下是两种方法的视觉比较:

处理依赖关系

尝试访问在waitFor()中指定的依赖关系就像剥洋葱——它们是隐藏的。我们的目标是分离处理代码和依赖关系指定。那么我们到底该如何做呢?

而不是在调度过程中尝试处理依赖关系,我们可以在存储注册时解决我们的依赖关系。如果一个存储在其属性中列出了依赖项,那么调度器可以组织存储列表,以满足这些依赖项。以下是我们的调度器模块的register()函数的一个实现:

// This is used by stores so that they can register
// themselves with the dispatcher.
export function register(store) {

  // Don't let a store register itself twice...
  if (stores.includes(store)) {
    throw `Store ${store} already registered`;
  }

  // Adds the "store" reference to "stores".
  stores.push(store);

  // Sorts our stores based on dependencies. If a store
  // depends on another store, the other store is
  // considered "less than" the store. This means that
  // dependencies will be satisfied if the stores are
  // processed in this sort order.
  stores.sort((a, b) => {
    if (a.deps.includes(b)) {
      return 1;
    }

    if (b.deps.includes(a)) {
      return -1;
    }

    return 0;
  });
}

这是存储可以使用来注册自己的函数。这个函数首先做的事情是检查存储是否已经通过调度器进行了注册。这是一个简单的检查,因为引用存储在一个数组中;我们可以使用includes()方法。如果存储尚未注册,那么我们可以将存储推送到数组中。

接下来,我们处理存储依赖关系。每次存储注册时,我们都会重新排序stores数组。这个排序基于存储的deps属性。这就是存储的依赖关系被声明的地方。排序比较器很简单。它基于存储 A是否依赖于存储 B或反之。例如,假设这些存储按照以下顺序注册:

处理依赖关系

现在,让我们假设以下存储依赖关系已经被声明:

处理依赖关系

这意味着存储 A依赖于存储 B存储 D。在所有这些存储都注册后,我们的调度器模块中存储列表的顺序如下:

处理依赖关系

现在商店列表的顺序满足商店的依赖关系。当调度器遍历商店列表并调用每个商店处理程序时,它将按正确的顺序完成。由于商店 A依赖于商店 C商店 D,唯一重要的是这两个商店首先被处理。商店 A商店 C的顺序无关紧要,因为它们之间没有声明依赖关系。现在,让我们看看如何实现我们模块的分派逻辑。

分派操作

在 Facebook 对 Flux 调度器的参考实现中,分派机制是调度器实例的一个方法。由于实际上并不需要一个单例调度器实例,我们的调度器是一个简单的模块,公开了一些函数,包括一个dispatch()函数。多亏了我们在register()函数中实现的依赖关系排序逻辑,dispatch()的工作流程将非常直接。现在让我们看看这段代码:

// Used by action creator functions to dispatch an
// action payload.
export function dispatch(payload) {

  // The dispatcher is busy, meaning that we've
  // called "dispatch()" while an update round
  // was already taking place.
  if (busy) {
    throw 'Nested dispatch() call detected';
  }

  // Marks the dispatcher as "busy" before we
  // start calling any store handlers.
  busy = true;

  // The action "type" determines the method
  // that we'll call on a the store.
  let { type } = payload;

  // Iterates over each registered store, looking
  // for a method name that matches "type". If found,
  // then we call it, passing it the "payload" that
  // was dispatched.
  for (let store of stores) {
    if (typeof store[type] === 'function') {
      storetype;
    }
  }

  // The dispatcher isn't busy any more, so unmark it.
  busy = false;
}

您可以看到,在函数顶部有一个检查的busy变量。这正是在我们开始调用商店处理程序之前设置的。本质上,这是检查是否有任何调用dispatch()作为商店处理操作的后果。例如,我们可能不小心从商店或从监听商店的视图中调用dispatch()。这是不允许的,因为它破坏了我们 Flux 架构的单向数据流。当这种情况发生时,检测它并快速失败比让嵌套更新轮次运行要好。

除了忙状态处理逻辑之外,这个函数遍历商店集合,并检查是否有适当的方法可以调用。方法名称基于操作类型。例如,如果操作是MY_ACTION并且商店有相同名称的方法,那么该方法将使用有效载荷作为参数被调用。这个过程在这里被可视化:

分派操作

这与我们在本书中迄今为止使用的标准switch语句方法有很大的不同。相反,找到在商店中运行的适当代码的责任在于调度器。这意味着如果商店没有实现与已分发的操作相对应的方法,它将被商店忽略。这是我们商店分派处理程序中经常发生的事情,但现在它更高效,因为它绕过了switch情况检查。在下一节中,我们将看到我们的商店如何与这种新的调度器实现一起工作。但首先,这里是完整的调度器模块,这样您就可以看到所有内容是如何结合在一起的:

// References to registered stores...
const stores = [];

// This is true when the dispatcher is performing
// an update round. By default, it's not busy.
var busy = false;

// This is used by stores so that they can register
// themselves with the dispatcher.
export function register(store) {

  // Don't let a store register itself twice...
  if (stores.includes(store)) {
    throw `Store ${store} already registered`;
  }

  // Adds the "store" reference to "stores".
  stores.push(store);

  // Sorts our stores based on dependencies. If a store
  // depends on another store, the other store is
  // considered "less than" the store. This means that
  // dependencies will be satisfied if the stores are
  // processed in this sort order.
  stores.sort((a, b) => {
    if (a.deps.includes(b)) {
      return 1;
    }

    if (b.deps.includes(a)) {
      return -1;
    }

    return 0;
  });
}

// Used by action creator functions to dispatch an
// action payload.
export function dispatch(payload) {

  // The dispatcher is busy, meaning that we've
  // called "dispatch()" while an update round
  // was already taking place.
  if (busy) {
    throw 'Nested dispatch() call detected';
  }

  // Marks the dispatcher as "busy" before we
  // start calling any store handlers.
  busy = true;

  // The action "type" determines the method
  // that we'll call on a the store.
  let { type } = payload;

  // Iterates over each registered store, looking
  // for a method name that matches "type". If found,
  // then we call it, passing it the "payload" that
  // was dispatched.
  for (let store of stores) {
    if (typeof store[type] === 'function') {
      storetype;
    }
  }

  // The dispatcher isn't busy any more, so unmark it.
  busy = false;
}

改进商店注册

我们不能在不改进存储的工作流程的情况下改进分发器的工作流程。幸运的是,分发器已经实现了这项艰苦的工作。我们只需要以最佳方式实现我们的存储,以充分利用我们对分发器所做的改进。在本节中,我们将讨论实现基础存储类,然后是一些扩展它的存储的示例实现,这些存储实现了它们自己的动作方法。

基础存储类

我们刚刚实现的新分发器与 Facebook 的参考实现有一些重要的不同之处。两个关键的区别是,存储注册了一个自身的实例而不是回调函数,以及存储需要实现动作方法。基础存储类应该能够在创建时自动与分发器注册。这意味着扩展此基础类的存储不需要担心分发器——只需实现相应改变存储状态的动作方法。

分发器、基础存储以及扩展它的存储的布局在此图中展示:

基础存储类

让我们继续查看我们基础存储类的实现。然后,我们将实现一些扩展它的存储,这样我们就可以看到我们的新分发器模块的实际应用:

import { EventEmitter } from 'events';
import { register } from './dispatcher';

// Exports the base store for others to extend.
export default class Store extends EventEmitter {

  // The constructor sets the initial "state" of the
  // store, as well as any dependencies "deps" with
  // other stores.
  constructor(state = {}, deps = []) {
    super();

    // Stores the state and dependencies. The "deps"
    // property is actually required by the
    // dispatcher.
    this.state = state;
    this.deps = deps;

    // Registers the store with the dispatcher.
    register(this);
  }

  // This is a simple helper method that changes the
  // state of the store, by setting the "state"
  // property and then emitting the "change" event.
  change(state) {
    this.state = state;
    this.emit('change', state);
  }

}

就这样,很简单对吧?构造函数接受存储的初始状态和存储依赖项的数组。这两个参数都是可选的——它们有默认参数值。这对于 deps 属性尤为重要,因为我们的分发器模块期望它存在。然后,我们调用 register() 函数,以便分发器自动了解任何存储。记住,如果分发器无法处理分发的动作,那么 Flux 存储就没有任何用处。

我们还添加了一个方便的小 change() 方法,它会更新状态并为我们发出更改事件。现在我们有了基础存储类,我们可以自由地实现这样的小辅助方法,以减少重复的存储代码。

动作方法

让我们完成现在已经讨论了几段的示例。为了做到这一点,我们将实现几个扩展我们刚刚创建的基础存储的存储。这是第一个存储:

import Store from '../store';
import second from './second';
import third from './third';

// The initial state of the store, we'll
// pass this to "super()" in the constructor.
const initialState = {
  foo: false
};

// The dependencies this store has on other
// stores. In this case, it's "second" and
// "third". These too, are passed through
// "super()".
const deps = [ second, third ];

class First extends Store {

  // The call to "super()" takes care for setting up
  // the initial store state, and the dependencies
  // for us.
  constructor() {
    super(initialState, deps);
  }

  // Called in response to the "FOO" action
  // being dispatched...
  FOO(payload) {
    this.change({ foo: true });
  }

  // Called in response to the "BAR" action
  // being dispatched...
  BAR(payload) {
    this.change(Object.assign({
      bar: true
    }, this.state));
  }
}

export default new First();

这个存储拥有所有与我们的新基础存储类和新分发器模块一起工作的相关组件。您可以在构造函数中看到,我们正在将 initialStatedeps 值传递给 Store 构造函数。您还可以看到,在这个存储中实现了两个动作方法:FOO()BAR()。这意味着如果有任何类型为 FOOBAR 的动作被分发,这个存储将响应它们。现在让我们实现这个存储所依赖的两个存储:

注意

如果你实在无法忍受全大写的方法名外观,你可以随意更改要派发的动作类型的大小写。另一个选择是在派发器中实现不区分大小写的匹配。反对这种后者的权衡是我们将失去从动作类型到方法名的直接映射。小心你所期望的。

import Store from '../store';
import third from './third';

class Second extends Store {

  // The call to "super()" sets the initial
  // state for us.
  constructor() {
    super({
      foo: false
    });
  }

  // Called in response to the "FOO" action
  // being dispatched...
  FOO(payload) {
    this.change({ foo: true });
  }

  // Called in response to the "BAR" action
  // being dispatched. Note that we're
  // dependent on the "third" store, yet
  // we don't make this dependency explicit.
  // This could lead to trouble.
  BAR(payload) {
    this.change({
      foo: third.state.foo
    });
  }
}

export default new Second();

Second存储类似于First存储。它扩展了基Store类并设置了一个默认状态。它还响应两个动作,正如我们可以通过两个方法名看到的那样。然而,这个存储没有声明任何依赖关系,但它显然依赖于BAR()动作处理器中的第三个存储。这可能会或可能不会工作,这取决于third存储在派发器持有的存储集合中的位置。如果我们声明third为依赖项,那么我们可以确定它将始终在存储之前更新。现在让我们看看我们的最后一个存储:

import Store from '../store';

class Third extends Store {

  // The call to "super()" sets the initial
  // state for us...
  constructor() {
    super({
      foo: false
    });
  }

  // Called in response to the "FOO" action
  // being dispatched.
  FOO(payload) {
    this.change({ foo: 'updated' });
  }
}

export default new Third();

再次强调,这个存储遵循其两个后继者的相同模式。关键区别在于它没有BAR()动作处理器。这意味着当派发BAR动作时,这个存储中的任何内容都不会被调用。这与我们早期的处理器形成对比,其中每个动作都会通过一个switch语句进行过滤,然后被忽略。最后,让我们看看main.js来将这些内容串联起来:

import first from './stores/first';
import second from './stores/second';
import third from './stores/third';

import { foo } from './actions/foo';
import { bar } from './actions/bar';

// Logs the state of each store as it changes...
first.on('change', (state) => {
  console.log('first', state);
});

second.on('change', (state) => {
  console.log('second', state);
});

third.on('change', (state) => {
  console.log('third', state);
});

foo();
// →
// third {foo: "updated"}
// second {foo: true}
// first {foo: true}

bar();
// →
// second {foo: "updated"}
// first {bar: true, foo: true}

注意foo()的输出反映了正确的依赖顺序,而bar()的输出反映了Third中缺失的动作处理器。

摘要

在本章中,你了解了一些与 Facebook Flux 组件固有的局限性。首先,它并不是针对生产环境设计的,因为它是对 Flux 模式的参考实现。我们可以自由地以我们喜欢的方式实现这些派发模式。

派发器的本质方面是能够注册处理派发动作的存储代码,以及执行派发的能力。鉴于要求的简单性,实现另一个单例类是没有意义的。相反,派发器只需要公开一个register()dispatch()函数。

我们实现中的重大变化是关于依赖管理。不是每次派发动作时都确定依赖关系,而是register()函数以满足存储依赖关系的方式对stores集合进行排序。然后我们实现了一个基类存储,它通过自动为我们注册存储到派发器来简化我们的存储代码。

在下一章中,我们将探讨依赖于除 ReactJS 以外的技术来渲染自己的视图组件。

第十一章. 替代视图组件

Flux 文档并没有太多关于视图组件的说明。然而,视图是任何 Flux 架构中不可或缺的一部分。也许 Flux 的作者真正想要表达的是,Flux 并不真正关心我们渲染视图所使用的机制——只要它们以某种方式被渲染即可。

没有人会不知道 Flux 是为了与 React 配合而设计的。Facebook 已经为他们的视图组件构建了 React——Flux 是缺失的那一块,使得他们能够构建一个完整的、前端架构。我们将从讨论是什么让 React 如此适合 Flux 架构开始这一章。然后,我们将权衡这些好处与 React 的缺点。

接下来,我们将花一些时间使用 jQuery 和 Handlebars 模板引擎构建视图。这些是两个可能在任何开发者的职业生涯中某个时刻都曾接触过的流行技术。然后,我们将通过思考那些不需要特定渲染技术的视图来结束这一章,这样我们就可以灵活地处理视图,并在新热点到来时采用它。

ReactJS 非常适合 Flux

React 适合 Flux 架构并不令人惊讶。这两种技术都是由同一家公司创造的,并且它们都解决了互补的问题。在本节中,我们将深入了解 React 与 Flux 配合得如此之好的原因。我们将从查看 Flux 和 React 中都存在的单向流开始。接下来,我们将讨论重新渲染 DOM 结构比操作特定的 DOM 节点更容易,以及为什么这对存储更改事件处理器来说是一个很好的选择。最后,我们将讨论 React 组件相对较小的代码占用。

ReactJS 是单向的

在 Flux 架构中,数据流是单向的。它从动作开始,以视图更新结束——数据进入视图组件没有其他方式。React 本身也与 Flux 共享相同的单向哲学。数据流入根 React 组件,并逐渐流入用于组成根组件的任何组件。这个过程在组件层次结构中是递归的。

数据通过动作流入 Flux 存储,并以更改事件的形式流出。React 组件保持这种单向流。一旦 React 组件根据存储状态重新渲染自己,流程就结束了。唯一的选择是重新开始,通过派发一个新的动作。Flux 和 React 组件之间的流程在此处展示:

ReactJS 是单向的

我们数据流的前三项是 Flux 实体。每当一个动作被分发时,就会启动一个给定的数据流。然后,该动作本身进入分发器,并发送到每个存储。然后存储根据需要做出任何状态更改。从这里,数据流被传递给 React 组件。这是我们指定要渲染的标记结构的结构,使用 JSX。组件随后与虚拟 DOM 协商,以确定是否需要在实际 DOM 中进行任何更改。一旦这些更改完成,数据流就达到了终点。

我们为 React 组件概述的流程,即使它们不是 Flux 架构的一部分,看起来也不会有任何不同。Flux 组件只是以同步方式添加可预测的状态更改,然后在将数据传递给组件进行渲染之前。没有 Flux,React 仍然需要从顶部开始传递数据,以便重新渲染过程可以开始。这与 Flux 存储发出的更改事件非常契合。

与 React 不太匹配的是双向数据绑定的概念。有些人喜欢这个想法,并找到了使其与 React 一起工作的方法,但我在这里跑题了。为了使双向绑定有效,我们的视图组件需要与可变数据紧密相邻。然后,视图可以直接监听这些数据以重新渲染自身。我们并没有设置好使用 Flux 架构来处理这种情况,更不用说 React 了。我们可以直接修改某个状态,而不必首先进入一个管理应用程序全局状态同步更新的工作流程,这与 Flux 的每个想法都相悖。简单来说,Flux 架构倾向于具有可预测结果的单向数据流,而 React 有助于完成这一使命。

重新渲染新数据很容易

关于 ReactJS 的一个真正突出之处在于它重新渲染整个 DOM 树的能力。嗯,任何 JavaScript 代码都可以通过重新构建来替换现有的 DOM 树。React 使用所谓的虚拟 DOM 来比较用户当前正在查看的现有元素,以及我们刚刚渲染的新元素。React 不会替换整个树,而只会触及两个树不同的 DOM 位置。除了 React 内置的启发式方法外,基本性能优势来自于虚拟 DOM 位于 JavaScript 内存中——我们不需要查询真实 DOM 中的元素。查询 DOM 可能会产生负面的性能影响。

为了解决这些性能问题,我们的视图代码可以发出特定的查询,这些查询运行效率高,并且只获取我们需要的精确元素。我们的视图代码还可以缓存它需要的特定元素。这种方法的缺点是,一旦我们有了超过几个视图组件,就会感觉零散。当组件都针对它们自己的特定性能要求定制时,它们很难共享代码,而且这高度依赖于组件的 DOM 结构。

对于程序员来说,能够说出“这里是一个快照,展示了这些视图元素在这个时间点应该看起来是什么样子”更为自然。我们不应该需要拆解 DOM 结构,并说这个div应该看起来像这样,而那个span应该隐藏,等等。这就是为什么 JSX 起作用的原因;我们可以更容易地可视化我们组件的输出将是什么样子,因为它结构化得就像元素的结构一样。

小型代码占用

与具有大量命令式 DOM 操作代码的视图组件相比,React 组件通常包含更少的代码。React 没有这种类型的代码,因为它只需要通过 JSX 表达 DOM 的结构。然而,如果没有 Flux 作为架构,使用 React 的应用程序可能会发现 React 组件包含更多的数据转换代码。

例如,当 React 组件挂载到 DOM 中时,我们可能需要对来自某些来源的数据进行某种转换,可能是 AJAX 响应。有了 Flux,来源始终是存储的状态,因此我们知道数据转换已经在它们传递给 React 视图之前发生了。记住,是视图驱动我们的存储状态的结构,而不是存储驱动视图的结构。

事件处理代码是另一个 React 组件可以具有小型代码占用的领域。好吧,这里实际上有两个维度。首先,React 中的事件处理程序直接在 JSX 中声明,因此它们就像 DOM 树结构中的任何其他元素属性一样——不需要将元素插入 DOM,然后在稍后查找它们,以便我们可以将事件处理函数附加到它们上。第二个维度实际上并不特定于 React,而更多的是 Flux 现象。事件处理程序本身通常是动作创建函数。我们视图中的所有逻辑现在都是我们存储的一部分。

ReactJS 的缺点

现在你已经很好地掌握了在 Flux 架构中使用 ReactJS 作为视图层的优点,是时候看看一些缺点了。任何事物都有其负面权衡——没有完美技术的存在。因此,在应用 Flux 架构的背景下,这些因素是值得考虑的。

首先,我们将考虑内存消耗。React 是一个相当大的库,对应用程序加载时间有明显的影響。然而,与虚拟 DOM 消耗的内存量相比,这只是一个次要问题。接下来,我们将探讨将 JSX 语法引入我们的 JavaScript 模块,以及这可能会给那些不习惯将其他语言混合到他们的 JavaScript 模块中的人带来的问题。

虚拟 DOM 和内存

JavaScript 应用程序应尽可能追求内存效率。那些感觉臃肿且对用户不响应的应用程序。使用大量内存的应用程序本质上比使用较少内存的应用程序慢,因为它们需要执行更多的工作。例如,如果我们需要在集合中查找某个东西,如果集合中有大量对象,那么显然需要更多的计算资源,而不是一个对象数量小得多的集合。这种做法还会在垃圾回收期间损害应用程序性能。如果我们有大量分配后从未释放(可能由于其他问题如泄漏)的集合,那么这就不成问题了。但更常见的行为是,在用户操作时分配大量内存,然后在用户继续操作时释放该内存。这种行为将触发频繁的垃圾回收运行,这会导致响应性暂停。

React 的架构比其他内存处理方法需要更多的内存。这是由于 React 维护的虚拟 DOM。这个内存结构旨在反映真实 DOM 的结构。它不跟踪真实 DOM 中每个元素的每一份数据。它只跟踪计算 diff 所必需的数据。以下是我们组件、虚拟 DOM 和真实 DOM 之间映射的示意图:

虚拟 DOM 和内存

我们 React 组件中的元素不一定占用很多内存,因为它们只是组件的声明部分,指定了要使用哪些元素以及它们应该具有哪些属性值。虚拟 DOM 反映了我们在 JSX 中指定的结构和属性;这些元素实际上确实占用了内存。最后,我们有用户看到并与之交互的真实 DOM 元素。这些也占用了相当大的内存。

这种方法的主要挑战是我们对 DOM 中渲染的任何内容都进行了重复。换句话说,虚拟 DOM 出于必要增加了我们的 DOM 元素消耗的总内存。没有虚拟 DOM,React 和 JSX 只是另一个模板引擎。虚拟 DOM 解决了其他地方的性能问题。React 在性能方面表现优异的主要领域是高效的 DOM API 交互,因为虚拟 DOM 消除了许多这些调用的需要。

你的典型 React 应用程序消耗的内存是否是一个致命的问题?绝对不是。内存正迅速成为一种商品,甚至在移动空间也是如此。所以如果我们能分配更多的内存来解决真正的性能问题,我们应该尽一切可能这样做。然而,在某些情况下,过度的内存分配可能会成为 React 应用程序的问题。例如,如果我们只是需要渲染大量的元素怎么办?当且仅当这成为一个性能问题时,你最好的选择可能是设计更少的元素。

JSX 和标记

JSX 实质上是 HTML(好吧,技术上来说是 XML)与 JavaScript 代码混合。如果你的初始反应对这个方法并不满意,你并不孤单。在漫长的岁月里,实际上几十年,我们都习惯了将关注点分开。像 HTML 这样的东西永远不应该和那些控制何时以及如何显示该标记的 JavaScript 逻辑放在同一个模块中。如果我们已经习惯了多年的关注点分离原则,那么对将两个关注点合并到一个模块中的想法感到抵触是很自然的。

很可能你最近工作的最后一个项目涉及到在模板文件中指定标记。然后这些模板被输入到视图层进行渲染。从这种设置转向 Flux 可能是一次性难以全部接受的事情。一方面,我们有一个全新的单向数据流需要考虑。另一方面,我们正在谈论放弃我们辛苦构建到单独层中的所有东西。

我们不要忘记关注点分离原则确实有其作用。如果两个关注点在不同的地方实现,那么一个关注点的变化影响另一个关注点的可能性就小得多。想想看,将模板视为将任何给定组件的视觉方面隔离开来的方式。至少在理论上,我们可以让设计团队自由地处理模板,而不用担心它们会破坏组件的 JavaScript 实现。

如果你在这本书中学到了什么,那可能就是 UI 组件的复杂性远不止其各部分的总和。Flux 通过在存储中显式地建模这些复杂性来试图承认这些复杂性。在 Flux 中更新 UI 的严格顺序和同步性是有原因的:尽管涉及所有这些复杂性,但仍然具有可预测性。这和 JSX 有什么关系呢?好吧,在将其视为违反关注点分离原则的东西之前,想想它如何与 Flux 存储很好地匹配。还要考虑这样一个想法:标记和渲染它的逻辑最终可能是同一个关注点。

供应商锁定

你是否曾听到有人这么说:“我正在使用库 x,因为我不想被锁定在库 y 中?”供应商锁定是一个棘手的问题领域。尽管如今,大多数项目都依赖于开源项目,这更像是一种技术方法锁定。如果我不在这里至少提及与 Flux 和 React 相关的话题,那将是一个疏忽。

一旦我们开始使用 React 和 JSX,我们基本上就下了赌注。这不仅仅是因为它是一个安全的赌注。尽管如此,我们已经走上了一条非常难以摆脱的道路,这正是过去三个部分的核心观点。即使你的心中 95%已经决定选择 React,知道你已经权衡了利弊,你也会睡得更香。

使用 jQuery 和 Handlebars

jQuery 和 Handlebars 是现代 Web 应用中普遍使用的两种技术。对于新接触 Flux 的人来说,有很大概率他们已经使用过其中一种或两种技术,因此我们将在这个部分实现一些使用 jQuery 和 Handlebars 的视图。

我们将从讨论 jQuery 和 Handlebars 为何适合实现视图组件开始。然后,我们将实现一个使用这些技术来渲染 Flux 存储状态的视图。之后,我们将思考如何从更小的部分组合更大的视图,以及如何最好地处理用户事件。

为什么是 jQuery 和 Handlebars?

在 JavaScript 框架出现之前,jQuery 就已经存在。这个小库旨在解决前端开发中普遍存在的跨浏览器问题,总的来说是为了让开发更加愉快。如今,jQuery 在 JavaScript 库领域仍然是一个主导者。许多大型框架都依赖于 jQuery,因为它非常有效,而且学习它的工作原理的门槛很低。

jQuery 不太擅长的一件事是使用 HTML 指定 UI 组件的布局。例如,我们可以使用 jQuery 动态构建新元素并将它们插入 DOM。然而,这种方法的某些方面感觉笨拙且不自然。通常,能够使用与页面显示相同的结构来编写 HTML 会更清晰。这消除了间接层,使我们更容易将标记映射到渲染输出。

进入 Handlebars。这个库为我们前端添加了一个复杂的模板引擎。编写 Handlebars 模板意味着我们可以编写 HTML,以及一些特定的 Handlebars 语法用于动态部分,并避免使用 jQuery 尝试组装元素的混乱。这两个库是互补的。我们有 Handlebars 模板来声明我们应用程序的结构,我们使用 Handlebars 渲染引擎来渲染这个结构。然后,jQuery 可以处理视图组件的各个方面,例如选择 DOM 元素和处理事件。让我们看看在 Flux 架构的上下文中这看起来是什么样子,通过实现一个渲染 Handlebars 模板的视图:

渲染模板

让我们从最基本的使用场景开始——使用 jQuery 将 Handlebars 模板渲染到 DOM 元素中。首先,让我们看看 Handlebars 模板文件本身:

<p><strong>First: </strong>{{first}}</p>
<p><strong>Last: </strong>{{last}}</p>

如您所见,这基本上是基本的 HTML,其中混合了一些用于动态部分的特定 Handlebars 语法。这个模板存储在一个 .hbs 文件中(代表 handlebars——有些人使用完整的 .handlebars 扩展名)。我们可以更新我们的 Webpack 配置以添加 Handlebars 加载器。这个加载器会解析和编译 .hbs 模板,这意味着使用这些模板的代码可以像导入常规 JavaScript 模块一样导入它们。让我们看看在我们的视图组件中这看起来是什么样子:

// Imports the compiled Handlebars "template"
// function just like a regular JavaScript module.
import template from './my-view.hbs';
import myStore from '../stores/my-store';

export default class MyView {
  constructor(element) {

    // Sets the container element that
    // we'll use to place the rendered template
    // content. Expected to be a jQuery object.
    this.element = element;

    // When the store state changes, we can
    // re-render the view.
    myStore.on('change', (state) => {
      this.render(state);
    });
  }

  // Renders the view. The default state is
  // the initial "myStore.state". We use the
  // "element" property of the view to set the
  // HTML to the rendered output of the Handlebars
  // "template()".
  render(state = myStore.state) {
    this.element.html(template(state));
    return this;
  }
}

这个视图模块导入的 template() 函数是作为 Webpack 插件编译模板为我们创建的函数的结果。Handlebars 的运行时作为 Webpack 创建的包的一部分包含在内。我们的视图组件的 render() 方法调用 template() 函数,传递一个上下文,并使用返回值作为视图元素的新的内容。上下文只是存储的状态,每次存储状态改变时,html() jQuery 函数都会用来替换现有的元素内容。

注意

ReactJS 与像 Handlebars 模板引擎这样的方法之间的基本区别在于,React 尝试进行小范围的更新。使用 Handlebars,我们可能会替换大量的 DOM 内容,性能问题可能会对用户变得明显。为了解决这类问题,我们必须改变我们应用程序的构建方式。这本身可能会让我们在使用像 React 这样的工具时处于不利地位,因为我们可以重新渲染大量的 DOM 结构,同时仍然保持效率。

现在,让我们看看驱动这个视图内容的存储:

import { EventEmitter } from 'events';

import dispatcher from '../dispatcher';
import { MY_ACTION } from '../actions/my-action';

// The initial state of the store. Instead of
// empty strings, this state uses labels that
// indicate that there's still data to come.
var state = {
  first: 'loading...',
  last: 'loading...'
};

class MyStore extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // When the "MY_ACTION" action is
        // dispatched, we extend the state
        // with the value of "payload",
        // overriding any existing property values.
        case MY_ACTION:
          this.emit('change',
            (state = Object.assign(
              {},
              state,
              e.payload
            ))
          );
          break;
      }
    });
  }

  get state() {
    return state;
  }
}

export default new MyStore();

这是一个相当典型的存储——与我们在本书中看到的大多数存储没有太大区别。作为 MY_ACTION 动作一部分发送的有效负载用于扩展存储的状态,如果有的话,将覆盖现有的属性名称。现在让我们看看主程序:

import $ from 'jquery';

import { myAction } from './actions/my-action';
import MyView from './views/my-view';

// Constructs the new view and performs the
// initial render by calling "render()". Note
// that there's now stored reference to this view,
// because we don't actually need to. If we
// did, "render()" returns the view instance.
new MyView($('#app')).render();

// After 1 second, dispatch "MY_ACTION", which
// will replace the "loading..." labels.
setTimeout(() => {
  myAction({
    first: 'Face',
    last: 'Book'
  });
}, 1000);

这是我们初始化视图组件实例的地方,传递给它一个 jQuery 实例。这个 jQuery 对象代表 #app 元素,并由视图用来持有渲染的 Handlebars 模板内容。在一秒延迟后,我们调用 myAction(),这会导致 myStore 状态改变,并且 Handlebars 模板重新渲染。

注意

通常,当我们的 Handlebars 模板开始变大时,我们会开始添加专门的处理程序,它们只对特定的存储属性做出响应。原因是属性变化得太频繁,它们只影响可见 UI 的一小部分。然后这些微处理程序就会扩散,我们开始失去可预测性,因为我们正在向渲染代码中引入更多的路径。在 ReactJS 中,这种情况不太可能发生,因为我们很少需要像这样分解视图更新。

组成视图

如果我们将 Handlebars 模板作为视图组件的主要成分,我们可能需要能够将我们的模板分解成更小的块。想想我们分解 React 组件的方式——我们最终得到更小的组件,这些组件通常可以在功能之间共享。使用 Handlebars 模板,我们可以通过使用部分模板实现类似的效果。部分是一个较小的部分,它适合于更大的整体,以形成由视图组件渲染的模板。

让我们从查看一个作为具有用户数据数组的存储库的列表视图的 Handlebars 模板开始。

<ul>
  {{#each users}}
  <li>{{> item-view}}</li>
  {{/each}}
</ul>

这个模板正在遍历我们的存储库的 users 属性,它是一个数组。然而,它并没有直接渲染每个项目,而是简单地使用特殊语法引用一个部分模板。现在让我们看看这个部分模板,这样我们就可以了解传递给它的是什么:

<span style="text-transform: capitalize">{{first}}</span>
<span style="text-transform: capitalize">{{last}}</span>

在这个模板中,我们不需要对在这种情况下使用的属性进行限定:firstlast。父模板中的上下文传递给部分模板,在这个例子中是用户对象。所以这有点像从父组件向子 React 组件传递 props。然而,再次强调,区别在于我们使用的每个 Handlebars 组件来组合 DOM 元素的结构的组件都会重新渲染,因为没有虚拟 DOM。让我们看看用来填充这个视图数据的存储库:

import { EventEmitter } from 'events';

import dispatcher from '../dispatcher';
import { REVERSE } from '../actions/reverse';

// The initial state is a list of
// user objects.
var state = {
  users: [
    { first: 'first 1', last: 'last 1' },
    { first: 'first 2', last: 'last 2' },
    { first: 'first 3', last: 'last 3' }
  ]
};

class MyStore extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // When the "REVERSE" action is dispatched,
        // the "state.users" array is reversed by
        // calling "reverse()".
        case REVERSE:
          this.emit('change',
            (state = Object.assign(
              {},
              state,
              { users: state.users.reverse() }
            ))
          );
          break;
      }
    });
  }

  get state() {
    return state;
  }
}

export default new MyStore();

最后,主程序。在这里,我们将设置一个间隔计时器,不断分发 REVERSE 动作。这会导致整个 UI 在每次分发时重新渲染:

import $ from 'jquery';

import { reverse } from './actions/reverse';
import ListView from './views/list-view';

// Performs the initial rendering of
// the list view, after initializing
// the view using the "#app" element.
new ListView($('#app')).render();

// Every second, toggle the sort
// order of the list by re-rendering
// the main template and it's partial
// templates.
setInterval(reverse, 1000);

注意

一般而言,Flux 架构应该尽可能少地使用存储库。然而,如果我们使用 Handlebars 在视图层,我们可能会受到影响而以不同的方式设计我们的存储库。例如,我们可能想要以这种方式分割整个应用程序状态,从而减少需要重新插入文档的 DOM 结构。

处理事件

在任何现代 Web 框架出现之前,jQuery 就已经解决了跨浏览器事件处理问题。尽管 API 在多年中有所变化,但 jQuery 事件处理功能仍然强大。如果我们正在构建由 jQuery 和 Handlebars 驱动的视图,这显然是相关的。

在这个上下文中处理事件的最紧迫的挑战是我们每次 Handlebars 模板需要更新时都会重新渲染元素。我们不希望每次将元素插入 DOM 时都要重新附加事件处理程序。ReactJS 利用了一种策略,实际上并没有直接将事件处理程序绑定到我们想要监听的元素上。相反,处理程序绑定到body元素上,当事件冒泡时,调用适当的处理程序。事实证明,这种方法具有性能优势,因为它避免了需要反复将同一个处理程序函数绑定到同一个元素上。以下是这个想法的说明:

处理事件

我们可以使用 jQuery 实现类似的效果。首先让我们看看 Handlebars 模板文件,这样我们就可以了解我们正在处理的 UI 类型。我们将通过添加反向按钮和选择功能来扩展前面的示例。以下是新的项目视图模板:

<a href="#{{@index}}" style="font-weight: {{fontWeight}}"
  <span style="text-transform: capitalize">{{first}}</span>
  <span style="text-transform: capitalize">{{last}}</span>
</a>

该项目现在是一个链接。请注意,我们能够使用@index Handlebars 语法,它允许访问我们正在迭代的集合中当前项的索引。即使迭代发生在另一个模板中,这个特殊值仍然可以访问。现在让我们看看主列表视图 Handlebars 模板中有什么:

<button>Reverse</button>
<ul>
  {{#each users}}
  <li>{{> item-view}}</li>
  {{/each}}
</ul>

构建列表的ul与之前相同。现在我们有一个新的按钮来反转列表的排序顺序,而不是一个间隔计时器。现在让我们看看视图组件的事件处理能力:

import template from './list-view.hbs';
import { reverse } from '../actions/reverse';
import { select } from '../actions/select';
import myStore from '../stores/my-store';

export default class ListView {
  constructor(element) {

    this.element = element;

    // When the store state changes, re-render
    // the view.
    myStore.on('change', (state) => {
      this.render(state);
    });

    this.element

      // Binds the click event to "#app", but
      // is only handled if a "button" element
      // generated the event. The "reverse()"
      // action creator is used as the handler.
      .on('click', 'button', reverse)

      // Binds the click event to "#app", but
      // is only handled if an "a" element
      // generated the event. The index is parsed
      // from the "href" attribute, and this is
      // passed as the payload to the "select()"
      // action creator.
      .on('click', 'a', (e) => {
        e.preventDefault();

        let index = +(/(\d+)$/)
          .exec(e.currentTarget.href)[1];

        select(index);
      });
  }

  // Sets the HTML of "element" to the rendered
  // Handlebars "template()". The context of
  // the template is always the Flux store state.
  render(state = myStore.state) {
    this.element.html(template(state));
    return this;
  }
}

我们遵循 React 的模式,其中处理程序永远不会直接绑定到经常需要重新渲染的东西上。实际上,你可以看到事件处理程序是在视图组件的构造函数中设置的,在视图渲染任何内容之前。这是因为#app元素已经就位,这是我们感兴趣的元素。

第一个处理程序是为反向按钮,它使用reverse()动作创建函数。这是on()的第二个参数,它提供了元素上下文,因此我们知道这个处理程序是为button元素。同样的原则也应用于我们的第二个处理程序,当用户点击链接时被调用。在这里,我们只是阻止默认的浏览器行为并派发select事件。现在,让我们看看我们为了支持这种新的事件行为对存储所做的某些更改:

import { EventEmitter } from 'events';

import dispatcher from '../dispatcher';
import { REVERSE } from '../actions/reverse';
import { SELECT } from '../actions/select';

// The initial state is a list of
// user objects. They each have a
// "fontWeight" property which is
// translated to a CSS value when
// rendered.
var state = {
  users: [
    {
      first: 'first 1',
      last: 'last 1',
      fontWeight: 'normal'
    },
    {
      first: 'first 2',
      last: 'last 2',
      fontWeight: 'normal'
    },
    {
      first: 'first 3',
      last: 'last 3',
      fontWeight: 'normal'
    }
  ]
};

class MyStore extends EventEmitter {
  constructor() {
    super();

    this.id = dispatcher.register((e) => {
      switch(e.type) {

        // When the "REVERSE" action is dispatched,
        // the "state.users" array is reversed by
        // calling "reverse()".
        case REVERSE:
          this.emit('change',
            (state = Object.assign(
              {},
              state,
              { users: state.users.reverse() }
            ))
          );
          break;

        // When the "SELECT" action is dispatched, we
        // need to find the appropriate item based on
        // the "payload" index and mark it as selected.
        case SELECT:
          this.emit('change',
            (state = Object.assign(
              {},
              state,
              { users: state.users.map((v, i) => {

                // If the current index is the selected
                // item, change the "fontWeight" property.
                if (i === e.payload) {
                  return Object.assign({}, v,
                    { fontWeight: 'bold' });

                // Otherwise, set the "fontWeight" back
                // to "normal" so that any previously
                // selected items are reset.
                } else {
                  return Object.assign({}, v,
                    { fontWeight: 'normal' });
                }
              })}
            ))
          );
          break;
      }
    });
  }

  get state() {
    return state;
  }
}

export default new MyStore();

这里有两个重要的更改值得指出。第一个更改是我们现在的users数组中的每个项目都有一个新的fontWeight属性。这是必要的,因为它控制着我们的链接的显示,以表明某些内容已被选中。由于尚未选择任何内容,所以一切默认为normal

注意

我们可以在视图组件中放入一些代码,用于查找fontWeight属性,如果找不到,则默认为正常。这种策略的问题在于它向视图组件中引入了不必要的逻辑。我们试图将所有东西都保留在存储中,即使是看似微不足道的事情,比如这个。即使这意味着在存储中添加默认值,这些默认值在浏览器中也是默认的。

对存储的第二次更改是添加了SELECT处理逻辑。当这个动作被分发时,我们将项目索引与有效负载索引匹配,并更改字体粗细。所有不匹配的其他内容都会被重置为正常的font-weight

使用纯 JavaScript

在前端 JavaScript 渲染库的生态系统中的多样性不足不是一个问题。事实上,对我们来说,问题正好相反——可供选择的库和框架太多了。虽然 JavaScript 社区中的一些人认为这种多样化的选择是一个问题,但这并不一定如此。有太多的技术可供选择,总比选择不足要好。

在本节中,我们将讨论使用纯 JavaScript 作为我们的视图技术——不使用任何库或框架。这个想法并不是要完全避免使用框架,而是要保持我们的选择多样化,随着我们应用程序架构的展开。最终,我们可能会将视图组件移动到使用 React,或者也许有一些其他新潮的技术我们一直在关注。

保持选择多样化

在某个时刻,我们必须选择一种与我们的视图组件一起使用的技术。这取决于我们目前的项目处于哪个阶段。如果游戏还处于早期,并且我们已经决定使用一个视图库,我们可能会长时间限制自己使用这项技术。鉴于 JavaScript 及其周围生态系统的发展速度如此之快,长时间停留在任何技术上通常并不是一件好事。我们必须接受这样一个事实:变化不断使曾经的新潮事物变得过时。

另一方面,我们不想等待太久才为我们的视图做出技术决策,因为使用纯 JavaScript 构建的东西越多,将这些视图迁移到更具意见导向的方法就越困难。那么,最佳平衡点在哪里呢?

最好的策略是在可能的情况下避免锁定。这涉及到保持事物松散耦合,以便它们可以互换。幸运的是,Flux 架构使这变得容易,因为视图层的责任相当有限。它们需要监听存储更改事件并渲染存储状态。也许我们应该尝试构建两套视图组件。第一套使用像 React 这样的技术,另一套使用像 jQuery 和 Handlebars 这样的其他技术。这不仅允许我们选择最适合我们产品的视图技术,还让我们测试我们采用新技术的准备情况,这是我们不可避免地想要做的。

转向 React

正如你在本章中看到的,我们可以在 Flux 架构的视图组件中使用像 jQuery 和 Handlebars 这样的技术。更重要的是,它们不会干扰 Flux 架构中发现的单向数据流。话虽如此,React 可能是作为 Flux 架构一部分使用的最佳视图技术。从单向数据流的角度来看,React 自然地吸收了这一点。即使没有 Flux,无状态的函数式 React 组件也会表现得像我们在 Flux 架构中期望视图那样。当新的属性到来时,新的 HTML 就会被渲染。

除了 React 对单向数据流的自然倾向之外,重新渲染大型 DOM 结构的感觉也不那么令人畏惧。多亏了 React 用来修补渲染输出的虚拟 DOM——而不是替换整个内容——我们可以高效地将存储状态传递给顶层视图以进行重新渲染。React 还为我们处理其他边缘情况,例如在重新渲染期间保持表单控件的关注点。

真正的问题是双重的:转向 React 的必然性有多强,以及我们现有的代码有多容易挽救?好吧,第一个问题通常很容易回答——你很可能会在你的 Flux 架构中使用 React。它只是 Flux 架构的一个很好的匹配。然而,认为没有负面权衡是天真了,比如更高的内存消耗。所以,如果我们决定在已经开发了一些视图组件之后转向 React,我们需要把所有东西都扔掉吗?不太可能。视图在 Flux 架构中扮演着相对较小的角色,正如我在整本书中强调的那样。所以,如果转向 React 解决了你的 Flux 视图组件中的问题,那就这么做吧——这是一个好的发展方向。目前是这样的。

新的热门技术

几年前,React 还是一种全新的热门技术。就像任何新热门技术一样,开发者应该以一定程度的怀疑态度来接近这项技术。结果证明,React 对许多早期采用者来说是一个不错的选择。另一方面,并非所有新潮的技术都能成功。这就是进步的途径,也是为什么 JavaScript 生态系统取得了如此多的进步。这个故事的要点是什么?总会出现一些比你已经下注的更好的新热门技术。准备好去采纳和再次采纳。

例如,谷歌目前正在实施一种名为 Incremental DOM 的视图技术(google.github.io/incremental-dom/),它采用了一种不同的渲染方法,使用更少的内存。还有 Veu.jsvuejs.org/)。未来还有无数其他可能性。只需确保你的视图可以转向并拥抱最新的最佳视图技术——它很快就会到来。

摘要

本章的重点是 Flux 架构中的视图组件以及它们如何松散耦合到可以替换渲染技术的程度。我们从一个关于 React 本身的讨论开始,讨论了它为什么适合 Flux 架构。然后,我们转换了话题,讨论了使用 ReactJS 的潜在缺点。

我们花了一些时间实现了一些利用 jQuery 和 Handlebars 的视图。这些是许多开发者熟悉的成熟技术,可以作为实现 Flux 架构的良好起点。然而,对于任何实现 Flux 的人来说,将 React 视为首选的视图技术有着强烈的动机。

我们以讨论使用 VanillaJS 渲染视图组件结束本章。在我们理解了选择该技术的后果之前,没有必要急于采用特定的技术。总会出现更新、更好的视图库,而 Flux 架构使得转向和拥抱新热门技术变得容易。

第十二章。利用 Flux 库

首先,Flux 是一套架构指南,指定为我们遵循的模式。虽然这提供了最大的灵活性,但在决定如何实现特定的 Flux 组件时,有时可能会让人感到无所适从。幸运的是,有一些非常好的 Flux 库提供了对 Flux 组件有见地的实现,这减少了我们需要编写的许多样板代码。本章的目的是查看这些库中的两个,以展示 Flux 实现可以有多么不同。目标不是合规,而是一个稳固的架构,有助于我们的应用程序完成任务。

实现核心 Flux 组件

在本节中,我们将重申我们可以改变架构中各种 Flux 组件的具体实现的想法。我们将从讨论分发器本身开始,并考虑我们可能做出的各种更改。然后,我们将考虑存储以及我们可能想要在那里做出的增强。最后,我们将讨论动作和动作创建函数。

自定义分发器

在第十章,实现分发器,我们实现了自己的分发器组件。Facebook 提供的参考实现完全可以使用,但它并不是每个生产 Flux 架构中默认的组件。相反,它是一个起点,这样我们可以看到 Flux 分发器规范应该如何工作。

我们的解决方案是公开分发器模块中的dispatch()register()函数。通过这样做,我们在代码的其他部分使用分发器变得更加直接。不再需要考虑分发器实例——一切都被封装在分发器模块中。

一个通用的 Flux 库可能希望更进一步,完全取消分发器,这听起来可能有些疯狂——它是一个基本的 Flux 组件。然而,我们仍然可以在不明确实现这个抽象的情况下实现分发器的相同架构原则。这就是将 Flux 作为一套规范而不是具体实现发布出来的全部意义。我们在概念上知道 Flux 架构应该做什么和不应该做什么——我们有机会选择如何通过我们的实现来强制执行这些规则。

实现基础存储

在第十章实现分发器中,我们做出的另一个改进是对存储层次结构的改进。我们的每个存储都继承自一个基类。我们实现这个功能的主要原因是为了自动化存储与分发器的注册,这是有益的,因为一个没有监听分发器发出的事件的 Flux 存储是没有意义的。也许 Flux 库应该为我们处理这种基础功能。

我们还实现了方法动作处理器。实际上,在我们的实现中,这是派发器本身的一个功能,而且相当受限。也许基础存储是这种功能合适的地点。库应该包含这种类型的通用复杂性,而不是我们的应用程序。

使用 Flux 存储继承基本功能的好处在于,这是我们应用程序的大脑所在之处。如果我们发现一些通用的状态转换行为适用于多个存储,那么有一个基础存储在位,就使我们能够轻松地将通用代码提取出来。也许 Flux 库可以在其基础存储中提供一些基本的转换,这样我们就可以从中继承。

创建动作

常量是 Flux 架构中明确动作的绝佳方式。动作模块定义了常量,动作创建函数将常量传递给派发器。存储在确定如何处理派发的动作时也会使用这些常量。这就在动作创建器和响应此动作的存储中的代码之间建立了一个明确的联系。

在第十章《实现派发器》中,我们采用了不同的方法。动作创建函数仍然定义了常量,并在派发动作时使用它们。然而,我们进行了更改,允许我们的存储定义方法处理器。因此,而不是一个监听派发器的函数,存储定义了与动作定义的常量相匹配的方法。从存储的角度来看,这很方便,但如果常量只被动作创建函数使用,那么它们的价值就会降低。

Flux 库可以帮助使派发和处理动作变得更加直接。使用常量和switch语句是好的,因为它们使发生的事情变得明确。我们喜欢在 Flux 架构中保持明确性。挑战在于,这种方法要求程序员在实现系统时保持警惕。换句话说,有大量机会出现人为错误。Flux 库可以消除处理常量时可能出现的错误。

另一个 Flux 库可以帮助的领域是与异步动作创建函数相关。我们应用程序的异步行为可能遵循类似的模式:

  • 在异步代码运行之前派发一个改变存储状态的动作

  • 当响应到达时派发一个动作

  • 如果异步行为失败,派发不同的动作。

这几乎就像异步动作有一个生命周期,可以通过 Flux 库将其抽象成一个通用函数。

实现痛点

在上一节中,我们讨论了 Flux 中可能从自定义实现中受益的领域。在我们深入探讨Alt.js和 Redux 之前,我们将简要讨论一些实现 Flux 架构的痛点。在任意架构中,异步动作都是难以做对的,更不用说 Flux 了。我们将应用程序状态划分到存储中的方式可能是一个棘手的设计问题。如果我们做错了,可能很难恢复。最后,我们还有数据依赖问题需要考虑。

分发异步动作

正如我们在上一节中讨论的,异步动作创建者难以实现。这很具挑战性,因为我们通常必须让存储知道这个异步动作即将发生,以便 UI 可以更新以反映这一点。例如,当点击一个发送一个或多个 AJAX 请求的按钮时,我们可能希望在实际上发送请求之前禁用该按钮,以防止重复请求。在 Flux 中,唯一的方法是分发一个动作,因为一切都是单向的。

库可以在一定程度上帮助解决这个问题。例如,预请求动作和成功/错误响应动作可以抽象成更易于使用的东西,因为这是一个常见的模式。然而,即使这样做,也留下了组装请求以获取给定动作所需的所有数据、同步响应并将它们每个传递给存储以便将其转换为视图所需的内容的问题。

也许最好的办法是将这个异步问题排除在 Flux 的作用范围之外。例如,Facebook 已经引入了 GraphQL,这是一种简化从后端服务构建复杂数据并只响应存储实际需要的内容的语言。所有这些都在一个响应中完成,因此我们节省了带宽和延迟。这种方法并不适合每个人,因此 Flux 的实现者需要选择他们想要如何处理异步性,只要客户端的单向数据流保持完整即可。

划分存储

在我们的 Flux 架构中错误地划分存储可能是我们面临的最大设计风险之一。通常发生的情况是存储大致平衡;然后,随着系统的演变,所有的新功能最终都进入了一个存储,而其他存储的责任并不明确。换句话说,存储变得不平衡。持有大部分应用程序状态的存储变得过于复杂,难以维护。

我们存储分区可能存在的另一个潜在问题是它们变得过于细粒度。我们也不想看到这种情况发生。尽管由单个存储管理的状态足够简单,但复杂性在于所有这些存储之间的依赖关系。即使没有太多的依赖关系,当需要考虑更多的存储时,在试图推理某事时,我们的大脑中很难保持足够的状态。当相关状态都在一个地方时,预测会发生什么要容易得多。

如果一个 Flux 库,比如 Redux,采取激进的措施,只允许一个存储,从而消除所有混淆的来源,会怎么样?这确实防止了设计问题,如存储分区。相反,正如我们将在本章后面看到的那样,Redux 使用 reducer 函数来转换单个存储的状态。

使用 Alt

Alt.js是一个为我们实现大量模板代码的 Flux 库。它完全遵循 Flux 概念和模式,但让我们从应用架构的角度关注我们的应用,而不是担心动作常量和switch语句。

在本节中,我们将在深入研究简单的待办事项列表示例之前,简要介绍 Alt 的核心概念。示例故意很简单——你将能够将代码映射到你在本书中迄今为止学到的 Flux 概念。

核心思想

Facebook Flux 包的主要目标是提供一个基本派发组件的参考实现。这很好地作为 Flux 概念的辅助——动作以同步、单向的方式派发到存储中。正如我们在书中所看到的,派发概念甚至不一定需要暴露给实现 Flux 的人。我们可以简化 Flux 抽象,同时仍然符合 Flux 架构的约束。

Alt 是一个旨在用于生产应用的 Flux 库——它不是一个参考实现。在我们深入代码之前,让我们先了解一下它作为 Flux 库的一些目标。

  • 合规性:Alt 不借鉴 Flux 中的想法——它真正是为 Flux 系统设计的。例如,存储、动作和视图的概念都相关。同样,Alt 紧密遵循 Flux 架构的原则。像同步更新轮次和单向数据流这样的原则都得到了强制执行。

  • 自动化模板代码:与实现 Flux 相关的一些繁琐编程任务,Alt 处理得很好。这包括自动创建动作创建函数和动作常量。Alt 还会为我们处理存储动作处理方法——减少了对长switch语句的需求。

  • 没有分发器:我们的代码没有分发器与之交互。将动作分发到所有存储库是在我们调用动作创建者函数时在幕后处理的。存储库之间的依赖管理是在存储库内部直接处理的。

创建存储库

我们将要创建的简单应用将为用户显示两个列表。一个列表用于待办事项,另一个列表用于已完成的事项。我们将使用两个存储库——每个列表一个。让我们看看如何使用 Alt.js 创建存储库。首先,我们有 Todo 存储库:

import alt from '../alt';
import actions from '../actions';

class Todo {
  constructor() {

    // This is the state of the input element
    // used to create a new Todo item.
    this.inputValue = '';

    // The initial list of todo items...
    this.todos = [
      { title: 'Build this thing' },
      { title: 'Build that thing' },
      { title: 'Build all the things' }
    ];

    // Sets up the handler methods to be called
    // when the corresponding action is dispatched.
    this.bindListeners({
      createTodo: actions.CREATE_TODO,
      removeTodo: actions.REMOVE_TODO,
      updateInputValue: actions.UPDATE_INPUT_VALUE
    });
  }

  // Creates a new Todo using the action "payload"
  // as the title.
  createTodo(payload) {
    this.todos.push({ title: payload });
  }

  // Removes the Todo based on the index, which is
  // passed in as the action payload.
  removeTodo(payload) {
    this.todos.splice(payload, 1);
  }

  // Updates the Todo value that the user is currently
  // entering in the Todo input box.
  updateInputValue(payload) {
    this.inputValue = payload;
  }
}

// The "createStore()" function hooks our store class
// up with all the relevant action dispatching machinery,
// returning an instance of the store.
export default alt.createStore(Todo, 'Todo');

与我们在本书中迄今为止看到的内容相比,这可能看起来不太熟悉。不用担心;我们现在会逐步解释这些组件。你可能首先会问——状态在哪里?从代码中看不出来,但状态是类的任何实例变量。在这种情况下,是 inputValue 字符串和 todos 数组。

接下来,我们有一个调用 bindListeners() 并传递一个配置对象给它。这就是 Alt 存储库如何将动作映射到方法的方式。你可以看到我们定义了与传递给 bindListeners() 的内容相对应的方法。最后,我们有调用 createStore() 的操作。这个函数为我们实例化了 Todo 存储库类,同时也连接了分发机制。

存储库的定义就到这里——它已经准备好供需要渲染其状态的视图使用。现在让我们看看 Done 存储库,它遵循相同的做法,只是组件更少:

import alt from '../alt';
import actions from '../actions';
import todo from './todo';

class Done {
  constructor() {

    // The "done" state holds an array of
    // completed items.
    this.done = [];

    // Binds the only listener of this store.
    this.bindListeners({
      createDone: actions.CREATE_DONE
    });
  }

  // This action payload is the index of an item
  // from the "todo" store. This is called when
  // the item is clicked, and the item is added
  // to the "done" array.
  //
  // Note that this action handler does not mutate
  // the "todo" state as that is not allowed.
  createDone(payload) {
    const { todos } = todo.getState();
    this.done.splice(0, 0, todos[payload]);
  }
}

// Creates the store instance, and hooks it
// up with the Alt dispatching machinery.
export default alt.createStore(Done, 'Done');

你可以看到,这个存储库实际上使用 Todo 存储库在项目标记为已完成时复制项目数据。然而,这个存储库不会修改 Todo 存储库,因为这会违反单向数据流。

注意

这些存储库类不是事件发射器,因此它们在状态改变时不会显式地发射任何内容。例如,当待办事项被添加时,视图如何知道有任何变化?由于 createTodo() 方法会自动为我们调用,一旦我们的方法执行完毕,通知机制也会自动发生。我们稍后会看到更多关于状态改变通知语义的内容。

声明动作创建者

我们已经看到了存储库如何响应分发的动作。现在我们需要一种实际分发这些动作的手段。这可能是我们 Alt 应用程序中最容易的部分。Alt 可以生成我们需要的函数,以及在我们存储库中由 bindListeners() 调用使用的常量。让我们看看动作模块,看看它是如何与 Alt 一起工作的:

import alt from './alt';

// Exports an object with functions that accept
// a payload argument. These are the action
// creators. Also creates action constants
// based on the names passed to "generateActions()"
export default alt.generateActions(
  'createTodo',
  'createDone',
  'removeTodo',
  'updateInputValue'
);

这将导出一个包含具有与传递给 generateActions() 的字符串相同名称的动作创建函数的对象。它还将生成存储使用的动作常量。由于我们的动作创建函数都非常相似,generateActions() 具有很高的实用性。我们不再需要维护大量的样板代码。另一方面,还有一些更复杂的案例,涉及需要更多代码的异步动作。如果您对在项目中使用此库感兴趣,请查看 Alt 文档中的异步动作。

监听状态变化

在整本书中,我们都在我们的存储发出的更改事件上添加了事件处理函数。使用像 Alt 这样的库,这已经为我们管理了一部分。让我们看看我们应用程序的主要模块,它使用 AltContainer React 组件将存储数据馈送到我们的其他 React 组件:

// The React and Alt components we need...
import React from 'react';
import { render } from 'react-dom';
import AltContainer from 'alt-container';

// The stores and React components from
// this application...
import todo from './stores/todo';
import done from './stores/done';
import TodoList from './views/todo-list';
import DoneList from './views/done-list';

// Renders the "AltContainer" component. This
// is where the stores are tied to the views.
// The "TodoList" and "DoneList" components
// are children of the "AltContainer", so
// they get the "todo" and the "done" stores
// as props.
render(
  <AltContainer stores={{ todo, done }}>
    <TodoList/>
    <DoneList/>
  </AltContainer>,
  document.getElementById('app')
);

AltContainer 组件接受一个 stores 属性。容器将监听这些存储中的每一个,并在任何存储的状态发生变化时重新渲染其子组件。这是获取我们的视图监听存储的唯一设置——无需在各个地方手动调用 on()listen()。在下一节中,我们将查看 TodoListDoneList 组件,看看它们是如何与 AltContainer 一起工作的。

渲染视图和分发动作

TodoList 组件的职责是渲染来自 Todo 存储的项目。这个视图还需要处理两件事。首先,有一个用户用来输入新待办事项的 input 元素。其次,我们需要在点击项目时将其标记为完成,通过将其移动到完成列表中。后两个职责涉及事件处理和分发动作。让我们看看待办事项视图的实现:

import React from 'react';
import { Component } from 'react';

import actions from '../actions';

export default class TodoList extends Component {
    render() {

      // The relevant state from the "todo" store
      // that we're rendering here.
      const { todos, inputValue } = this.props.todo;

      // Renders an input for new todos, and the list
      // of current todos. When the user types
      // and then hits enter, the new todo is created.
      // When the user clicks a todo, it's moved to the
      // "done" store.
      return (
        <div>
          <h3>TODO</h3>
          <div>
            <input
              value={inputValue}
              placeholder="TODO..."
              onKeyUp={this.onKeyUp}
              onChange={this.onChange}
              autoFocus
            />
          </div>
          <ul>
            {todos.map(({ title }, i) =>
              <li key={i}>
                <a
                  href="#"
                  onClick={this.onClick.bind(null, i)}
                >{title}</a>
              </li>
            )}
          </ul>
        </div>
      );
    }

    // An active Todo was clicked. The "key" is the
    // index of the Todo within the store. This is
    // passed as the payload to the "createDone()"
    // action, and next to the "removeTodo()" action.
    onClick(key) {
      actions.createDone(key);
      actions.removeTodo(key);
    }

    // If the user has entered some text and the
    // "enter" key is pressed, we use the
    // "createTodo()" action to create a new
    // item using the entered text. Then we clear
    // the input using the "updateInputValue()"
    // action, passing it an empty string.
    onKeyUp(e) {
      const { value } = e.target;

      if (e.which === 13 && value) {
        actions.createTodo(value);
        actions.updateInputValue('');
      }
    }

    // The text input value changed - update the store.
    onChange(e) {
      actions.updateInputValue(e.target.value);
    }
}

注意

您可能想知道为什么我们不能在按下 Enter 键时直接清除 e.target.value。确实,我们可以这样做,但这会违反 Flux 的本质,其中状态被保存在存储中。这包括用户输入时的临时值。如果应用程序的另一个部分想要了解文本输入值,它只需要依赖于 Todo 存储。如果状态不存在,那么我们的代码将不得不查询 DOM,而我们不想这样做。

最后,让我们看看完成列表组件。这个组件比待办事项列表更简单,因为没有事件处理:

import React from 'react';
import { Component } from 'react';

export default class DoneList extends Component {
  render() {

    // The "done" array is the only state we need
    // from the "done" store.
    const { done } = this.props.done;

    // We want to display these items
    // as strikethrough text.
    const itemStyle = {
      textDecoration: 'line-through'
    }

    // Renders the list of done items, with
    // the "itemStyle" applied to each item.
    return (
      <div>
        <h3>DONE</h3>
        <ul>
          {done.map(({ title }) =>
            <li style={itemStyle}>{title}</li>
          )}
        </ul>
      </div>
    );
  }
}

使用 Redux

在本节中,我们将探讨 Redux 库来实现 Flux 架构。与 Alt.js 不同,Redux 并不旨在实现 Flux 兼容。Redux 的目标是借鉴 Flux 的重要思想,而将繁琐的部分留在了后面。尽管 Redux 没有按照官方文档中指定的方式实现 Flux 组件,但现在 Redux 是 React 架构的首选解决方案。Redux 证明了简洁性总是胜过高级功能。

核心思想

在实现一些 Redux 代码之前,让我们花一点时间来了解一下 Redux 的核心思想:

  • 没有分发器:这就像Alt.js,它也从其 API 中移除了分发器的概念。这些 Flux 库没有暴露分发器组件的事实有助于说明 Flux 只是一套思想和模式,而不是一个实现。Alt 和 Redux 都分发动作,只是它们不需要分发器来完成。

  • 一个存储器统治一切:Redux 摒弃了 Flux 架构需要多个存储器的观点。相反,使用一个存储器来保存整个应用程序状态。乍一看,这可能会让人觉得存储器会变得太大,难以理解。这种情况在多个存储器中同样可能发生,唯一的区别是应用程序状态被分割成不同的模块。

  • 向存储器分发:当只有一个存储器需要担心时,我们可以做出一些设计上的让步,比如将存储器和分发器视为同一概念。这正是 Redux 所做的事情——它直接向存储器分发动作。

  • 纯减法器:多个 Flux 存储背后的想法是将应用程序状态分割成几个逻辑上分离的域。我们仍然可以使用 Redux 来实现这一点,区别在于我们使用减法函数将状态分割成域。这些函数负责在分发动作时转换存储的状态。它们是纯的,因为它们返回新的数据并避免引入任何副作用。

减法器和存储器

我们现在将实现与使用 Alt 所制作的相同简单的待办事项应用程序——这次使用 Redux。这两个库之间有很多重叠,尤其是与 React 组件本身;那里不需要做太多改变。Redux 与 Alt 和 Flux 的一般区别在于它的单个存储器以及改变其状态的减法函数。话虽如此,我们将首先查看存储器和它的减法函数。

我们将为 Redux 存储的初始状态创建一个模块。这是一个重要的第一步,因为它为转换存储状态的减法函数提供了初始结构。让我们看看初始状态模块:

import Immutable from 'immutable';

// The initial state of the Redux store. The
// "shape" of the application state includes
// two domains - "Todo" and "Done". Each domain
// is an Immutable.js structure.
const initialState = {
  Todo: Immutable.fromJS({
    inputValue: '',
    todos: [
      { title: 'Build this thing' },
      { title: 'Build that thing' },
      { title: 'Build all the things' }
    ]
  }),
  Done: Immutable.fromJS({
    done: []
  })
};

export default initialState;

状态是一个简单的 JavaScript 对象。你可以看到单个存储器并不是一个杂乱无章的属性集合,而是由两个主要属性——TodoDone——组织起来的。这就像拥有多个存储器,只是它们在一个对象中。你还会注意到每个存储属性都是一个Immutable.js数据结构。之所以这样做,是因为我们需要将传递给我们的减法函数的状态视为不可变的。这个库使得强制不可变性变得容易。

存储器状态发生的状态转换将被分为两个减法函数。实际上,这两个函数映射到存储器的两个初始属性:TodoDone。让我们首先看看Todo减法函数:

import Immutable from 'immutable';

import initialState from '../initial-state';
import {
  UPDATE_INPUT_VALUE,
  CREATE_TODO,
  REMOVE_TODO
} from '../constants';

export default function Todo(state = initialState, action) {
  switch (action.type) {

    // When the "UPDATE_INPUT_VALUE" action is dispatched,
    // we set the "inputValue" key of the Immutable.Map.
    case UPDATE_INPUT_VALUE:
      return state.set('inputValue', action.payload);

    // When the "CREATE_TODO" action is dispatched,
    // we push the new item to the end of the
    // Immutable.List
    case CREATE_TODO:
      return state.set('todos',
        state.get('todos').push(Immutable.Map({
          title: action.payload
        }))
      );

    // When the "REMOVE_TODO" action is dispatched,
    // we delete the item at the given index from
    // the Immutable.List.
    case REMOVE_TODO:
      return state.set('todos',
        state.get('todos').delete(action.payload));
    default:
      return state;
  }
}

这里使用的 switch 语句应该看起来很熟悉——这是我们一直在本书中实现存储的模式。实际上,这个函数就像一个存储,有两个主要区别。第一个区别是它是一个函数而不是一个类。这意味着我们不是设置状态属性值,而是返回新状态。第二个区别是 Redux 处理监听存储和调用此 reducer 函数的机制。使用类时,我们必须自己编写大量这样的代码。

注意

确保这些 reducer 函数不修改状态参数非常重要。这就是为什么我们使用 Immutable.js 库——使通过创建新数据来转换现有状态变得更容易。对于转换 Redux 存储状态来说,使用 Immutable.js 不是必需的,但它确实有助于代码简洁性。

现在让我们看看 Done reducer 函数:

import Immutable from 'immutable';

import initialState from '../initial-state';
import { CREATE_DONE } from '../constants';

export default function Done(state = initialState, action) {
  switch (action.type) {

    // When the "CREATE_DONE" action is dispatched,
    // we insert the new item into the beginning
    // of the Immutable.List.
    case CREATE_DONE:
      return state.set('done',
        state.get('done')
          .insert(0, Immutable.Map(action.payload))
      );

    // Nothing to do, return the state "as-is".
    default:
      return state;
  }
}

我们几乎完成了我们的 Redux 存储。到目前为止,我们有两个 reducer 函数,每个函数都在它们自己的模块中。我们需要使用 combineReducers()createStore() 将它们结合起来。现在让我们看看我们的存储模块:

import { combineReducers, createStore } from 'redux';

import initialState from './initial-state';
import Todo from './reducers/todo.js';
import Done from './reducers/done.js';

export default createStore(combineReducers({
  Todo,
  Done
}), initialState);

如你所见,combineReducers() 函数创建了一个新的函数。这是维护应用程序状态的主要 reducer 函数。所以,与需要处理将动作发送到多个存储的典型 Flux 分发器不同,Redux 动作被发送到这个单一存储,并且我们的 reducer 函数会相应地被调用。

Redux 动作

如你所知,动作和动作创建者之间有一个区别。动作是将要发送到各个 Flux 存储的负载,而动作创建者负责创建动作负载,然后将它们发送到分发器。在 Redux 中,动作创建者函数略有不同,因为它们只创建动作负载,并不直接与分发器通信。

在我们实现视图组件时,将在下一节中看到动作创建者是如何被调用的。但就目前而言,这是我们的动作模块的样子:

import {
  CREATE_TODO,
  CREATE_DONE,
  REMOVE_TODO,
  UPDATE_INPUT_VALUE
} from './constants';

// Creates a new Todo item. The "payload" should
// be an object with a "title" property.
export function createTodo(payload) {
  return {
    type: CREATE_TODO,
    payload
  };
}

// Creates a new Done item. The "payload" should
// be an object with a "title" property.
export function createDone(payload) {
  return {
    type: CREATE_DONE,
    payload
  };
}

// Removes the todo and the given "payload" index.
export function removeTodo(payload) {
  return {
    type: REMOVE_TODO,
    payload
  };
}

// Updates the "inputValue" state with the given
// "payload" string value.
export function updateInputValue(payload) {
  return {
    type: UPDATE_INPUT_VALUE,
    payload
  };
}

这些函数只是返回存储将要分发的数据——它们实际上并不分发数据。例外情况是当涉及到异步操作时。在这种情况下,我们需要在异步值解决后实际分发动作。请参阅官方 Redux 文档,其中包含大量的异步动作示例。

渲染组件和分发动作

到目前为止,我们有了 Redux 存储和动作创建者函数。剩下要做的就是实现我们的 React 组件并将它们连接到存储。我们将从 TodoList 视图开始:

import React from 'react';
import { Component } from 'react';
import { connect } from 'react-redux';

import {
  updateInputValue,
  createTodo,
  createDone,
  removeTodo
} from '../actions';

class TodoList extends Component {
  constructor(...args) {
    super(...args);
    this.onClick = this.onClick.bind(this);
    this.onKeyUp = this.onKeyUp.bind(this);
    this.onChange = this.onChange.bind(this);
  }

  render() {

    // The relevant state from the "todo" store
    // that we're rendering here.
    const { todos, inputValue } = this.props;

    // Renders an input for new todos, and the list
    // of current todos. When the user types
    // and then hits enter, the new todo is created.
    // When the user clicks a todo, it's moved to the
    // "done" array.
    return (
      <div>
        <h3>TODO</h3>
        <div>
          <input
            value={inputValue}
            placeholder="TODO..."
            onKeyUp={this.onKeyUp}
            onChange={this.onChange}
            autoFocus
          />
        </div>
        <ul>
          {todos.map(({ title }, i) =>
            <li key={i}>
              <a
                href="#"
                onClick={this.onClick.bind(null, i)}
              >{title}</a>
            </li>
          )}
        </ul>
      </div>
    );
  }

  // An active Todo was clicked. The "key" is the
  // index of the Todo within the store. This is
  // passed as the payload to the "createDone()"
  // action, and next to the "removeTodo()" action.
  onClick(key) {
    const { dispatch, todos } = this.props;

    dispatch(createDone(todos[key]));
    dispatch(removeTodo(key));
  }

  // If the user has entered some text and the
  // "enter" key is pressed, we use the
  // "createTodo()" action to create a new
  // item using the entered text. Then we clear
  // the input using the "updateInputValue()"
  // action, passing it an empty string.
  onKeyUp(e) {
    const { dispatch } = this.props;
    const { value } = e.target;

    if (e.which === 13 && value) {
      dispatch(createTodo(e.target.value));
      dispatch(updateInputValue(''));
    }
  }

  // The text input value changed - update the store.
  onChange(e) {
    this.props.dispatch(
    updateInputValue(e.target.value));
  }
}

// The props that get passed to this component
// from the store. We just need to convert the
// "Todo" Immutable.js structure to plain JS.
function mapStateToProps(state) {
  return state.Todo.toJS();
}

// Exports the "connected" version of the
// component that's connect to the Redux store.
export default connect(mapStateToProps)(TodoList);

关于这个模块的关键点是它不是导出的组件类。相反,我们使用来自react-redux包的connect()函数。这个函数将 Redux 存储与这个视图连接起来。存储中的状态通过mapStateToProps()函数传递,该函数决定了 React 组件属性是如何分配的。在这种情况下,我们只需要将Immutable.js结构转换成普通的 JavaScript 对象。

事件处理器的缺点是我们需要在构造函数中绑定它们的上下文,因为 React 不会自动绑定 ES2015 风格组件的上下文。处理器需要访问this.props,因为它包含用于将我们的动作数据派发到存储的dispatch()函数,以及用于构建动作负载的存储数据。现在让我们看看DoneList组件:

import React, { Component } from 'react';
import { connect } from 'react-redux';

class DoneList extends Component {
  render() {

    // The "done" array is the only state we need
    // from the "done" store.
    const { done } = this.props;

    // We want to display these items
    // as strikethrough text.
    constitemStyle = {
      textDecoration: 'line-through'
    }

    // Renders the list of done items, with
    // the "itemStyle" applied to each item.
    return (
      <div>
        <h3>DONE</h3>
        <ul>
          {done.map(({ title }, i) =>
            <li key={i} style={itemStyle}>{title}</li>
          )}
        </ul>
      </div>
    );
  }
}

// The props that get passed to this component
// from the store. We just need to convert the
// "Done" Immutable.js structure to plain JS.
function mapStateToProps(state) {
  return state.Done.toJS();
}

// Exports the "connected" version of the
// component that's connect to the Redux store.
export default connect(mapStateToProps)(DoneList);

正如你所见,这与TodoList组件的工作方式非常相似。实际上,这些组件与同一应用的 Alt 实现相比变化不大。最后一步是将这两个组件与 Redux 存储连接起来,这可以通过使用Provider组件来完成:

import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';

import store from './store';
import TodoList from './views/todo-list';
import DoneList from './views/done-list';

// Renders the "TodoList" and the "DoneList"
// components. The "Provider" component is
// used to connect the store to the components.
// When the store changes state, the children
// of "Provider" are re-rendered.
render(
  <Provider store={store}>
    <div>
      <TodoList/>
      <DoneList/>
    </div>
  </Provider>,
  document.getElementById('app')
);

摘要

在本章中,你学习了如何利用 Flux 库。特别是,我们查看了两款流行的库,这些库可以用来实现 Flux 架构。

我们在本章的开头进行了一次讨论,主要内容是对 Flux 的基本原则的回顾,以及我们在本书的前几章中如何实现这些原则。然后我们讨论了实现 Flux 的一些痛点——如单例派发器、重复的动作代码和存储模块的分区。这些都是Alt.js或 Redux 等库可以为我们解决的问题。

然后,我们继续使用Alt.js Flux 库实现了一个简单的待办事项应用。Flux 背后的想法是在后台自动化所有相关的 Flux 组件的实现,同时自动化一些典型的繁琐实现工作。之后,我们将注意力转向 Redux 库。Redux 不太关心严格遵循 Flux 模式。相反,Redux 追求简洁,同时借鉴了一些重要的 Flux 思想,如单向数据流。

在下一章中,我们将介绍任何 Flux 架构的两个非常重要的方面——功能测试和性能测试。

第十三章:测试与性能

我们希望我们应用程序的架构尽可能优秀。虽然这样说可能显得有些荒谬,但确实有必要时不时地重复这一点,以提醒我们使用 Flux 所做的工作有可能决定应用程序的成功与否。我们拥有的最佳工具是单元测试和性能测试。这两个活动同等重要。功能正确但速度极慢是失败。速度极快但充满错误也是失败。

实施成功的测试的一个巨大贡献因素是关注相关的内容。在本章中,我们将花时间思考对于 Flux 架构来说,哪些是重要的测试——从功能和性能两个角度来看。鉴于 Flux 对于社区来说相对较新,这一点尤为重要。我们将关注特定的 Flux 组件,并为它们设计一些单元测试。然后,我们将思考基准测试底层代码与性能测试端到端场景之间的区别。

Hello Jest

当涉及到为 JavaScript 代码编写有效的单元测试时,Jasmine 是广泛接受的选择工具。对于 Jasmine 的附加工具并不缺乏,这使得测试几乎任何东西和使用任何工具运行测试成为可能。例如,使用任务运行器(如 Grunt 或 Gulp)来运行测试,以及与项目相关的其他各种构建任务是常见的做法。

Jest 是由 Facebook 开发的一个单元测试工具,它借鉴了 Jasmine 的优点,并增加了新的功能。在项目中运行 Jest 也同样简单。例如,依赖于 Webpack 的项目通常依赖于 NPM 脚本来执行各种任务,而不是使用任务运行器。使用 Jest 就可以轻松做到这一点,正如我们接下来将要看到的。

Jest 有三个关键方面可以帮助我们测试 Flux 架构:

  • Jest 提供了一个虚拟化的 JavaScript 环境,包括 DOM 接口

  • Jest 会启动多个工作进程来运行我们的测试,这导致等待测试完成的时间更少,并且整体上加快了开发周期

  • Jest 可以为我们模拟 JavaScript 模块,这使得隔离代码单元以进行测试变得更加容易

让我们通过一个快速示例来开始。假设我们有一个我们想要测试的以下函数:

// Builds and returns a string based
// on the "name" argument.
export default function sayHello(name = 'World') {
  return `Hello ${name}!`;
}

这应该很容易,我们只需要编写一个单元测试来检查预期的输出。让我们看看这个测试在 Jest 中的样子:

// Tells Jest that we want the real "hello"
// module, not the mocked version.
jest.unmock('../hello');

// Imports the function we want to test.
import sayHello from '../hello';

// Your typical Jasmine test suite, test cases,
// and test assertions.
describe('sayHello()', () => {
  it('says hello world', () => {
    expect(sayHello()).toBe('Hello World!');
  });

  it('says hello flux', () => {
    expect(sayHello('Flux')).toBe('Hello Flux!');
  });
});

如果这看起来很像 Jasmine,那是因为它确实如此。实际上,Jasmine 被用于底层执行所有的测试断言。然而,在测试模块的顶部,你可以看到有一个 Jest 函数调用 unmock()。这告诉 Jest 我们不想要 sayHello() 函数的模拟版本。我们想要测试真实的东西。

注意

在将 Jest 设置成与 ES2015 模块导入一起工作方面,实际上涉及了很多调整。但在这里尝试解释它可能不太合适,我建议查看这本书附带源代码。现在,回到重要的事情上。

让我们创建一个main.js模块,它导入sayHello()函数并调用它:

import sayHello from './hello';
sayHello();

我们为sayHello()函数创建的 Jest 单元测试将sayHello()函数进行了隔离。也就是说,我们不需要测试任何其他代码来测试这个函数。如果我们将这种逻辑应用到主模块,我们就不必依赖于实现sayHello()的代码。这正是 Jest 的模拟功能派上用场的地方。我们上次的测试关闭了 hello 模块的模拟功能,其中定义了sayHello()。这次,我们实际上想要模拟这个函数。让我们看看主测试看起来像什么:

jest.unmock('../main');

// The "main" module is the real deal. The
// "sayHello()" function is a mock.
import '../main';
import sayHello from '../hello';

describe('main', () => {

  // We're expecting the "main" module to call
  // "sayHello()" exactly once. Since the "sayHello()"
  // function we've imported here is the same mock
  // called by main, we can verify this is indeed
  // what main is actually doing.
  it('calls sayHello()', () => {
    expect(sayHello.mock.calls.length).toBe(1);
  });
});

这次,我们确保main.js模块不会被 Jest 模拟。这意味着我们导入的sayHello()函数实际上是模拟版本。为了验证主模块是否按预期工作,尽管模块很简单,我们只需要验证sayHello()函数被调用了一次。

测试动作创建者

现在我们对 Jest 的工作原理有了大致的了解,是时候开始测试我们 Flux 架构的各个组件了。我们将从动作创建者函数开始,因为它们决定了进入系统的数据,并且是单向数据流的起点。我们想要测试两种类型的动作创建者。首先,我们有基本的同步函数,然后是异步函数。这两种类型的动作会导致非常不同的单元测试。

同步函数

动作创建者的任务是创建必要的有效负载数据并将其分发给存储。因此,为了测试这个功能,我们需要真实的动作创建者函数和一个模拟的分发器组件。记住,我们的想法是将组件作为单独的测试单元进行隔离——我们不想任何来自分发器代码的副作用影响测试结果。有了这些话,让我们看看动作创建者:

import dispatcher from '../dispatcher';

export const SYNC = 'SYNC';

// Your typical synchronous action creator
// function. Dispatches an action with
// payload data.
export function syncFunc(payload) {
  dispatcher.dispatch({
    type: SYNC,
    payload
  });
}

这种类型的函数现在可能看起来很熟悉了。我们希望我们的单元测试验证这个函数是否正确地调用了dispatch()方法。现在让我们看看这个测试:

// We want to test the real "syncFunc()" implementation.
jest.unmock('../actions/sync-func');

import dispatcher from '../dispatcher';
import { syncFunc } from '../actions/sync-func';

// The "dispatch()" method is mocked by
// Jest. We'll use it in the test to validate
// our action.
const { dispatch } = dispatcher;

describe('syncFunc()', () => {
  it('calls dispatch()', () => {

    // Calling "syncFunc()" should dispatch an
    // action. We can verify this by making sure
    // that the "dispatch()" was called.
    syncFunc('data');
    expect(dispatch.mock.calls.length).toBe(1);
  });

  it('calls dispatch() with correct payload', () => {
    syncFunc('data');

    // After calling "syncFunc()", we can get
    // argument information from the mock.
    const args = dispatch.mock.calls[1];
    const [ action ] = args;

    // Make sure the correct information was
    // passed to the dispater.
    expect(action).toBeDefined();
    expect(action.type).toBe('SYNC');
    expect(action.payload).toBe('data');
  });
});

这完全符合我们的预期。第一步是告诉 Jest 不要模拟 sync-func 模块中的内容,使用unmock()函数。Jest 仍然会模拟其他所有内容,包括分发器。所以当这个测试调用syncFunc()时,它实际上是调用模拟的分发器。当它这样做时,模拟记录了关于调用的信息,然后我们使用这些信息在我们的测试断言中确保一切按预期工作。

很简单,对吧?当我们需要模拟异步动作创建器函数时,事情会变得有点复杂,但我们在下一节会尝试简化一切。

异步函数

Jest 使我们能够通过模拟所有无关部分来轻松隔离给定单元测试应该测试的代码。一些事情可以通过 Jest 模拟生成器轻松处理。其他事情则需要我们的干预,正如你将在下面的例子中看到的那样。所以,让我们开始,看看我们试图测试的异步动作创建器函数:

import dispatcher from '../dispatcher';
import request from '../request';

export const ASYNC = 'ASYNC';

// Makes a "request()" call (which is really
// just an alias for "fetch()") and dispatches
// the "ASYNC" action with the JSON response
// as the action payload.
export function asyncFunc() {
  return request('https://httpbin.org/ip')
    .then(resp => resp.json())
    .then(resp => dispatcher.dispatch({
      type: ASYNC,
      payload: resp
    }));
}

这个动作创建器向一个公共 JSON 端点发送请求,然后将响应作为动作负载来分发ASYNC动作。如果我们使用的request()函数看起来很像全局的fetch()函数,那是因为它就是那个函数。请求模块只是简单地导出它,如下所示:

// We're exporting the global "fetch()" function
// so that Jest has an opportunity to mock it.
export default fetch;

这看起来似乎没有意义,但实际上并没有涉及任何开销。这就是我们能够轻松模拟代码中所有网络请求的方式。如果我们为单元测试模拟这个请求模块,这意味着我们的代码不会尝试连接到远程服务器。为了模拟这个模块,我们只需要在__mocks__目录中创建一个同名的模块,与__tests__目录并列。Jest 将找到这个模拟并替换导入时的真实模块。现在让我们看看模拟request()函数的源代码:

// Exports the mocked version of the "request()"
// function our action creators use. In this case,
// we're emulating the "fetch()" function and the
// "Response" object that it resolves.
export default function request() {
  return new Promise((resolve, reject) => {
    process.nextTick(() => {
      resolve({
        json: () => new Promise((resolve, reject) => {

          // This is where we put all of our mock fetch
          // data. A given function should just test
          // the properties that it's interested in,
          // ignoring the rest.
          resolve({ origin: 'localhost' });
        })
      });
    });
  });
}

如果这段代码看起来有点糟糕,不要担心——它只限于这个位置。它所做的只是复制这个模块所替代的本地fetch()函数的接口(因为我们实际上不想获取任何东西)。这种方法棘手的地方在于,我们代码中的任何request()调用都将获得相同的解析值。但只要我们的代码可以忽略它不关心的属性,并且我们可以将测试数据保持到最小,这应该就没有问题。

到目前为止,我们已经有一个模拟的网络层,这意味着我们现在可以实施实际的单元测试了。让我们继续进行:

jest.unmock('../actions/async-func');

// The "dispatcher" is mock while "asyncFunc()"
// is not.
import dispatcher from '../dispatcher';
import { asyncFunc } from '../actions/async-func';

describe('asyncFunc()', () => {

  // For testing asynchronous code that returns
  // promises, we use "pit()" in place of "it()".
  pit('dispatch', () => {

    // Once the call to "asyncFunc()" has resolved,
    // we can perform our test assertions.
    return asyncFunc().then(() => {
      // Collect stats about he mock
      // "dispatch()" method.
      const { calls } = dispatcher.dispatch.mock;
      const { type, payload } = calls[0][0];

      // Make sure that the asynchronous function
      // dispatches an action with the appropriate
      // payload.
      expect(calls.length).toBe(1);
      expect(type).toBe('ASYNC');
      expect(payload.origin).toBe('localhost');
    });
  });
});

关于这个测试,有两点需要注意。一是它使用pit()函数作为it()函数的直接替代。二是asyncFunc()函数本身返回一个承诺。这两个方面是 Jest 使编写异步单元测试变得如此简单的原因。这个例子中困难的部分不是测试,而是我们需要建立的基础设施,以便模拟像网络请求这样的东西。多亏了 Jest 为我们处理的一切,我们的单元测试代码实际上比其他情况下要小得多。

测试存储

在上一节中,我们使用了 Jest 来测试动作创建函数。这与测试任何其他 JavaScript 函数没有太大区别,只是 Flux 动作创建者需要以某种方式将它们创建的动作调度到存储上。Jest 通过自动模拟某些组件来帮助我们实现这一点,这无疑将帮助我们测试存储组件。

在本节中,我们将查看测试动作被调度到存储并存储发出更改事件的路径。然后,我们将思考初始存储状态以及这可能导致单元测试应该能够捕获的 bug。使所有这些工作涉及考虑实现可测试的存储代码,这是我们在这本书中还没有考虑过的。

测试存储监听器

存储组件可能很难与其他组件隔离。这反过来使得为存储编写单元测试变得困难。例如,一个存储通常会通过传递一个回调函数来向调度器注册自己。这个函数将根据传递给它的动作有效载荷改变存储的状态。这之所以是一个挑战,是因为它与调度器紧密耦合。

理想情况下,我们希望将调度器完全从单元测试中移除。我们只是在单元测试中测试我们的存储代码,所以不希望调度器中发生的任何事情干扰测试结果。这种情况发生的可能性很小,因为调度器实际上并没有太多事情要做。然而,与我们的所有 Flux 组件保持一致并尽可能完全隔离它们会更好。我们在上一节中看到了 Jest 如何帮助我们。我们只需要以某种方式将这个原则应用到存储上——在单元测试期间将它们与调度器解耦。

这是一个可能需要重新考虑我们编写存储代码的方式的情况——有时为了代码质量,需要稍作修改以便它既好又可测试。例如,我们通常注册到调度器的匿名函数变成了存储方法。这允许测试直接调用该方法,跳过整个调度机制,这正是我们想要的。现在让我们看看存储代码:

import { EventEmitter } from '../events';
import dispatcher from '../dispatcher';
import { DO_STUFF } from '../actions/do-stuff';

var state = {};

class MyStore extends EventEmitter {
  constructor() {
    super();

    // Registers a method of this store as the
    // handler, to better support unit testing.
    this.id = dispatcher.register(this.onAction.bind(this));
  }

  // Instead of performing the state transformation
  // in the function that's registered with the
  // dispatcher, it just determines which store
  // method to call. This approach better supports
  // testability.
  onAction(action) {
    switch (action.type) {
      case DO_STUFF:
        this.doStuff(action.payload);
        break;
    }
  }

  // Changes the "state" of the store, and emits
  // a "change" event.
  doStuff(payload) {
    this.emit('change', (state = payload));
  }
}

export default new MyStore();

如您所见,onAction()方法已注册到调度器,并且每当有动作被调度时都会被调用。doStuff()方法将响应DO_STUFF动作发生的特定状态转换从onAction()方法中分离出来。这并不是严格必要的,但它为我们提供了另一个单元测试的目标。例如,我们本可以保留匿名回调函数不变,并直接针对doStuff()方法进行测试。然而,如果我们的测试使用来自调度器的相同类型的有效载荷数据调用onAction(),我们将获得更好的存储测试覆盖率。

精明的读者可能已经注意到,这个商店从不同于平常的地方导入EventEmitter——../events。我们有自己的事件模块吗?我们现在有了,并且它与上一节中fetch()函数的思路相同。我们提供了一个自己的模块,Jest 可以对其进行模拟。这是 Jest 模拟EventEmitter类的一个简单方法。我们当时太忙于思考分发器,以至于忘记了在测试中将我们的商店与事件发射器解耦。让我们看看事件模块,这样你就可以看到我们仍然在暴露大家所熟知和喜爱的“老式”EventEmitter

// In order to mock the Node "EventEmitter" API,
// we need to expose it through one of our own modules.
import { EventEmitter } from 'events';
export { EventEmitter as EventEmitter } ;

这意味着我们的商店继承的方法将由 Jest 进行模拟,这是完美的,因为现在我们的商店完全与其他组件代码隔离,我们可以使用模拟收集的数据来进行一些测试断言。现在让我们为这个商店实现单元测试:

// We want to test the real store...
jest.unmock('../stores/my-store');

import myStore from '../stores/my-store';

describe('MyStore', () => {
  it('does stuff', () => {

    // Directly calls the store method that's
    // registered with the dispatcher, passing it
    // the same type of data that the dispatcher
    // would.
    myStore.onAction({
      type: 'DO_STUFF',
      payload: { foo: 'bar' }
    });

    // Get some of the mocked "emit()" call info...
    const calls = myStore.emit.mock.calls;
    const [ args ] = calls;

    // We can now assert that the store emits a
    // "change" event and that it has the correct info.
    expect(calls.length).toBe(1);
    expect(args[0]).toBe('change');
    expect(args[1].foo).toBe('bar');
  });
});

这种方法的优点在于,它非常接近数据在商店中流动的方式,但实际上并不依赖于其他组件来运行测试。测试数据以与实际分发器组件相同的方式进入商店。同样,我们知道商店通过测量模拟实现正确地发出了事件数据。这就是商店的责任结束的地方,测试的责任也是如此。

测试初始条件

当我们的 Flux 商店变得庞大且复杂后,我们很快就会学到,它们变得越来越难以测试。例如,如果一个商店响应的动作数量增加,那么我们想要测试的状态配置数量也会增加。为了帮助适应我们商店的单元测试,能够设置商店的初始状态将非常有帮助。让我们看看一个允许我们设置初始状态并响应几个动作的商店:

import { EventEmitter } from '../events';
import dispatcher from '../dispatcher';
import { POWER_ON } from '../actions/power-on';
import { POWER_OFF } from '../actions/power-off';

// The initial state of the store...
var state = {
  power: 'off',
  busy: false
};

class MyStore extends EventEmitter {

  // Sets the initial state of the store to the given
  // argument if provided.
  constructor(initialState = state) {
    super();
    state = initialState;
    this.id = dispatcher.register(this.onAction.bind(this));
  }

  // Figure out which action was dispatched and call the
  // appropriate method.
  onAction(action) {
    switch (action.type) {
      case POWER_ON:
        this.powerOn();
        break;
      case POWER_OFF:
        this.powerOff();
        break;
    }
  }

  // Changes the power state to "on", if the power state is
  // currently "off".
  powerOn() {
    if (state.power === 'off') {
      this.emit('change', 
        (state = Object.assign({}, state, {
          power: 'on'
        }))
      );
    }
  }

  // Changes the power state to "off" if "busy" is false and
  // if the current power state is "on".
  powerOff() {
    if (!state.busy && state.power === 'on') {
      this.emit('change', 
        (state = Object.assign({}, state, {
          power: 'off'
        }))
      );
    }
  }

  // Gets the state...
  get state() {
    return state;
  }
}

export default MyStore;

这个商店响应POWER_ONPOWER_OFF动作。如果你查看处理这两个动作状态转换的方法,你会发现结果取决于当前状态。例如,开启商店需要商店已经关闭。关闭商店的限制更加严格——商店必须关闭且不能忙碌。这类状态转换需要使用不同的初始商店状态进行测试,以确保预期中的正常路径以及边缘情况都能按预期工作。现在让我们看看这个商店的测试:

// We want to test the real store...
jest.unmock('../stores/my-store');

import MyStore from '../stores/my-store';

describe('MyStore', () => {

  // The default initial state of the store is
  // powered off. This test makes sure that
  // dispatching the "POWER_ON" action changes the
  // power state of the store.
  it('powers on', () => {
    let myStore = new MyStore();

    myStore.onAction({ type: 'POWER_ON' });

    expect(myStore.state.power).toBe('on');
    expect(myStore.state.busy).toBe(false);
    expect(myStore.emit.mock.calls.length).toBe(1);
  });

  // This test changes the initial state of the store
  // when it is first instantiated. The initial state
  // is now powered off, and we've also marked the
  // store as busy. This test makes sure that the
  // logic of the store works as expected - the state
  // shouldn't change, and no events are emitted.
  it('does not powers off if busy', () => {
    let myStore = new MyStore({
      power: 'on',
      busy: true
    });

    myStore.onAction({ type: 'POWER_OFF' });

    expect(myStore.state.power).toBe('on');
    expect(myStore.state.busy).toBe(true);
    expect(myStore.emit.mock.calls.length).toBe(0);
  });

  // This test is just like the one above, only the
  // "busy" property is false, which means that we
  // should be able to power off the store when the
  // "POWER_OFF" action is dispatched.
  it('does not powers off if busy', () => {
    let myStore = new MyStore({
      power: 'on',
      busy: false
    });
    myStore.onAction({ type: 'POWER_OFF' });

    expect(myStore.state.power).toBe('off');
    expect(myStore.state.busy).toBe(false);
    expect(myStore.emit.mock.calls.length).toBe(1);
  });
});

第二个测试可能是最有趣的,因为它确保由于商店的状态转换逻辑方式,没有事件被动作触发。

性能目标

是时候转换思路,思考测试我们 Flux 架构性能的问题了。测试特定组件的性能可能会很困难,原因与测试组件功能一样——我们必须将其与其他代码隔离开来。另一方面,我们的用户并不一定关心单个组件的性能——他们只关心整体的用户体验。

在本节中,我们将讨论我们在性能方面通过我们的 Flux 架构试图实现的目标。我们将从应用程序的用户感知性能开始,因为这是性能不佳架构中最重要的一面。接下来,我们将考虑测量我们 Flux 组件的原始性能。最后,我们将考虑为开发新组件时设定性能要求的好处。

用户感知性能

从用户的角度来看,我们的应用程序要么感觉响应迅速,要么运行缓慢。这种感觉被称为用户感知性能,因为用户实际上并没有测量完成某件事情所需的时间。一般来说,用户感知性能关乎挫败感阈值。每当我们需要等待某事时,挫败感就会增长,因为我们感觉无法掌控局势。我们无法做任何事情来让它加快速度,换句话说。

一种解决方案是分散用户的注意力。有时候,我们的代码必须处理某些事情,而没有办法缩短它所需的时间。在这个过程中,我们可以让用户了解任务进度。根据任务类型,我们甚至可能能够展示一些已经处理过的输出。另一个答案是编写高性能的代码,这是我们始终应该努力追求的。

用户感知性能对我们正在构建的软件产品至关重要,因为如果它被认为运行缓慢,也会被认为质量低下。最终,用户的意见才是最重要的——这就是我们衡量我们的 Flux 架构是否扩展到可接受水平的方法。用户感知性能的缺点是它无法量化,至少在细粒度层面上是这样。这就是我们需要工具来帮助我们衡量组件性能的地方。

测量性能

性能指标告诉我们代码中性能瓶颈的具体位置。如果我们知道性能问题的所在,那么我们就能更好地解决它们。从 Flux 架构的角度来看,例如,我们想知道动作创建者是否需要很长时间才能响应,或者存储是否需要很长时间来转换其状态。

有两种类型的性能测试可以帮助我们在开发 Flux 架构的过程中保持对任何性能问题的控制。第一种测试类型是分析,我们将在下一节中更详细地探讨这一点。第二种性能测试类型是基准测试。这种测试类型在较低级别进行,适用于比较不同的实现。

唯一的问题是——我们如何使性能测量成为日常生活的常态,我们可以用这些结果做什么?

性能要求

考虑到我们拥有必要的性能测试工具,似乎可以定义一些关于性能的要求。例如,如果有人正在实现一个存储库,我们能否引入一个性能要求,即存储库在发出更改事件时不能超过 x 毫秒?好处是我们可以对我们的架构的性能有合理的信心,甚至到组件级别。坏处是涉及的复杂性。

首先,新代码的开发会明显减慢,因为我们不仅要测试功能正确性,还要达到严格性能标准。这需要时间,而且回报可能微乎其微。假设我们花费大量时间提高某个组件的性能,因为它勉强满足要求。这意味着我们在做一些对用户来说无形的事情上白费力气。

这并不是说性能测试不能自动化,或者根本不应该进行。我们只是需要明智地选择在哪里投入我们的时间来测试 Flux 代码的性能。性能的最终决定者是用户,因此很难设定具体的要求,这些要求意味着“足够好”的性能,但尝试达到无人能察觉的优化性能却很容易浪费时间。

分析工具

通过网络浏览器提供的各种分析工具通常足以解决我们界面中出现的任何性能问题。这些工具包括构成我们 Flux 架构的组件。在本节中,我们将介绍浏览器开发者工具中发现的三个主要工具,我们将使用这些工具来分析我们的 Flux 架构。

首先是动作创建函数,特别是异步函数。然后我们将考虑我们的 Flux 组件的内存消耗。最后,我们将讨论 CPU 利用率。

异步操作

网络总是应用中最慢的一层。即使我们进行的 API 调用相对较快,它与其他 JavaScript 代码相比仍然很慢。如果我们的应用程序没有进行任何网络请求,它将会非常快。但它也不会很有用。一般来说,JavaScript 应用程序依赖于远程 API 端点作为它们的数据资源。

为了确保这些网络调用不会导致性能问题,我们可以利用浏览器开发者工具的网络分析器。这以极大的细节显示了任何给定请求正在做什么,以及它需要多长时间来完成。例如,如果服务器响应请求需要很长时间,这将在请求的时间线上反映出来。

使用这个工具,我们还可以看到在任何给定时刻未完成的请求数量。例如,可能在我们应用程序中有一个页面正在用请求猛烈地敲击服务器并使其不堪重负。在这种情况下,我们必须重新思考设计。在这个工具中查看的每个请求都允许我们深入到发起请求的代码。在 Flux 应用程序中,这应该始终是一个动作创建函数。有了这个工具,我们总是知道哪些动作创建函数从网络角度来看是有问题的,并且我们可以对它们采取措施。

存储内存

下一个可以帮助我们测试 Flux 架构性能的开发者工具是内存分析器。内存显然是我们必须小心处理的东西。一方面,我们必须考虑系统上运行的其它应用程序,避免占用过多内存。另一方面,当我们试图小心处理内存时,我们最终会频繁地进行分配/释放,触发垃圾回收器。很难确定一个组件应该使用的最大内存量。应用程序需要它需要的。

在 Flux 方面,我们最感兴趣的是内存分析器能告诉我们关于我们存储的信息。记住,随着应用程序的增长,我们很可能会在存储上遇到可扩展性问题,因为它们将不得不处理更多的输入数据。当然,我们也会想关注我们的视图组件消耗的内存,但最终是存储控制视图将消耗多少或多少内存。

内存分析器有两种方式可以帮助我们更好地理解 Flux 存储的内存消耗。首先,是内存时间线。这个视图显示了内存随时间分配/释放的情况。这很有用,因为它让我们看到内存是如何被使用的,就像用户与应用程序交互一样。其次,内存分析器允许我们拍摄当前内存分配的快照。这是我们确定正在分配的数据类型及其代码的方式。例如,通过快照,我们可以看到哪个存储占用了最多的内存。

CPU 利用率

如前一个关于内存分析器的章节所示,频繁的垃圾回收可能会影响响应性。这是因为垃圾回收器会阻止其他 JavaScript 代码的运行。CPU 分析器实际上可以显示垃圾回收器从其他代码中夺走了多少 CPU 时间。如果很多,那么我们可以找到更好的内存策略。

然而,当我们分析 CPU 时,我们再次应该将注意力转向 Flux 架构的商店组件。简单的原因是这将带来最大的投资回报。我们可能面临的可扩展性问题集中在用于处理商店中动作负载的数据转换函数。除非这些函数足够高效以处理进入系统的数据,否则架构不会扩展,因为我们的代码过度利用了 CPU。有了这个,我们将把注意力转向基准测试对我们系统可扩展性至关重要的函数。

基准测试工具

在性能测试的谱系一端,是用户感知的性能。这就是我们的客户在抱怨卡顿的地方,而且确实很容易复现这个问题。这可能是因为视图组件、网络请求,或者是我们商店中某些导致用户体验不佳的问题。在谱系的另一端,我们有代码的原始基准测试,我们希望获得准确的计时,以确保我们使用的是最高效的实现。

在本节中,我们将简要介绍基准测试的概念,然后我们将展示一个使用 Benchmark.js 比较两种状态转换实现的示例。

基准测试代码

当我们基准测试我们的代码时,我们是在比较一种实现与另一种实现,或者我们可以比较三个或更多实现。关键是隔离实现与其他组件,并确保它们各自有相同的输入并产生相同的输出。在某种程度上,基准测试就像单元测试,因为我们有一个被隔离作为单元的代码块,并使用工具来测量和测试其性能。

在执行这类微基准测试时,一个挑战是准确的计时。另一个挑战是创建一个不受其他事物干扰的环境。例如,尝试在网页中运行 JavaScript 基准测试可能会面临来自 DOM 等事物的干扰。Benchmark.js处理获取我们代码最准确测量的繁琐细节。话虽如此,让我们跳入一个示例。

注意

与单元测试不同,基准测试不一定是我们希望保留和维护的东西。这会带来太多的负担,而且当有成百上千个基准测试时,基准测试的价值往往会降低。可能有一些例外,我们希望为了说明目的而在存储库中保留基准测试。但一般来说,一旦代码实现或现有代码的性能得到改善,基准测试可以安全地被丢弃。

状态转换

当我们尝试扩展系统时,Flux 存储内部发生的状态转换有可能使系统停止运行。正如你所知,我们架构中的其他 Flux 组件扩展性良好。问题在于增加的请求量和数据量。需要高效执行转换这些数据的基本函数。我们可以使用 Benchmark.js 这样的工具为与存储数据一起工作的代码构建基准测试。以下是一个示例:

import { Suite } from 'benchmark';

// The "setup()" function is used by each benchmark in
// the suite to create data to used within the test.
// This is run before anything is measured.
function setup() {

  // The "coll" array will be available in each
  // benchmark function because this source gets
  // compiled into the benchmark function.
  const coll = new Array(10000)
    .fill({
      first: 'First',
      last: 'Last',
      disabled: false
    });

  // Disable some of the items...
  for (let i = 0; i<coll.length; i += 10) {
    coll[i].disabled = true;
  }
}

new Suite()

  // Adds a benchmark that tests the "filter()"
  // function to remove disabled items and the
  // "map()" function to transform the string
  // properties.
  .add('filter() + map()', () => {
    const results = coll
      .filter(item => !item.disabled)
      .map(item => ({
        first: item.first.toUpperCase(),
        last: item.last.toUpperCase()
      }));
  }, { setup: setup })

  // Adds a benchmark that tests a "for..of" loop
  // to build the "results" array.
  .add('for..of', () => {
    const results = [];

    for (let item of coll) {
      if (!item.disabled) {
        results.push({
          first: item.first.toUpperCase(),
          last: item.last.toUpperCase()
        });
      }
    }
  }, { setup: setup })

  // Adds a benchmark that tests a "reduce()"
  // call to filter out disabled items
  // and perform the string transforms.
  .add('reduce()', () => {
    const results = coll
      .reduce((res, item) => !item.disabled ?
        res.concat({
          first: item.first.toUpperCase(),
          last: item.last.toUpperCase()
        }) : res);
  }, { setup: setup })

  // Setup event handlers for logging output...
  .on('cycle', function(event) {
    console.log(String(event.target));
  })
  .on('start', () => {
    console.log('Running...');
  })
  .on('complete', function() {
    const name = this.filter('fastest').map('name');
    console.log(`Fastest is "${name}"`);
  })
  .on('error', function(e) {
    console.error(e.target.error);
  })
  // Runs the benchmarks...
  .run({ 'async': true });
  // →
  // Running...
  // filter() x 1,470 ops/sec ±1.00% (86 runs sampled)
  // for..of x 1,971 ops/sec ±2.39% (81 runs sampled)
  // reduce() x 1,479 ops/sec ±0.89% (87 runs sampled)
  // Fastest is "for..of"

如你所见,我们只需将两个或更多基准函数添加到测试套件中,然后运行它。输出是特定性能数据,比较了各种实现。在这种情况下,我们正在过滤和映射一个包含 10,000 项的数组。从性能角度来看,"for..of"方法脱颖而出,是最佳选择。

基准测试的重要之处在于它可以很容易地排除错误的假设。例如,我们可能会假设因为"for..of"比其他实现表现更好,所以它自动是最好的选择。然而,这两种替代方案并不落后太多。所以,如果我们真的想使用 reduce() 实现功能,这样做可能没有扩展风险。

注意

这本书附带代码实现了一些技巧,以便使用 Babel 使此示例与 ES2015 语法兼容。如果你使用 Babel 对生产代码进行转换,这是一个特别好的主意,这样你的基准测试就能反映现实情况。同时,在 package.json 中添加一个 npm bench 脚本也很方便,以便轻松访问。

摘要

本章的重点是测试我们的 Flux 架构。我们采用两种类型的测试来完成这项工作:功能和性能。使用功能单元,我们验证构成我们的 Flux 架构的代码单元是否按预期运行。使用性能单元,我们验证代码是否达到预期的性能水平。

我们介绍了 Jest 测试框架,用于为我们的动作创建者和存储实现单元测试。然后,我们讨论了浏览器中可以帮助我们解决高级别性能问题的各种工具。这些都是以可感知的方式影响用户体验的类型。

我们以对代码进行基准测试来结束这一章。这是在低级别发生的事情,很可能与我们的存储的状态转换功能有关。现在,我们需要考虑 Flux 架构对整个软件开发生命周期的影响。

第十四章. Flux 与软件开发生命周期

Flux 首先关注的是信息架构。这就是为什么 Flux 是一组模式而不是框架实现的原因。当我们设计可扩展的前端架构时,相对于整体系统的设计,具体的实现几乎无关紧要。单向数据流和同步更新轮次等因素对系统的可扩展性有着持久的影响。事实上,Flux 的影响足以改变我们开发软件的方式。

在本章中,我们将通过 Flux 的视角来审视软件开发生命周期。我们将以对 Flux 实现开放可能性的讨论开始本章。然后,我们将比较在新 Flux 项目开始时发生的开发活动类型与成熟 Flux 项目发生的情况。

我们还将思考使 Flux 一开始就具有吸引力的概念,以及如何提取这些想法并将它们应用于其他软件系统。最后,我们将以创建单体 Flux 系统与打包 Flux 组件的比较来结束本章。

Flux 具有开放性

JavaScript 框架的一个问题是,它们只是可能解决方案全谱中的一种实例化。一种解决方案并不像我们希望的那样通用。即使是只包含少量模式的 Flux 这样的规范也是开放的,可以解释。它们只是模式的事实使得一个群体可以按照自己的方式实现软件,而另一个群体则使用相同的模式以他们认为合适的方式实现软件。

在本节中,我们将重申 Flux 只是一组要遵循的模式的事实。我们将回顾使用 Flux 库的可能性,每个库都对实现 Flux 模式有不同的看法。然后,我们将考虑实现我们自己的 Flux 组件的权衡。

实现选项 1 – 只遵循模式

对于我们来说,Flux 只是一组要遵循的模式。我们甚至可能不会完全遵循它们。模式的有效性并不重要——重要的是我们从设计中获得了 Flux 的基本价值。例如,动作描述了已经发生的事情,并携带新数据的有效载荷进入系统。一旦新数据被分发,它就会沿着一个方向继续,直到被渲染。Flux 恰好使用了分发器和存储的概念。如果我们愿意,我们可以把我们的 Flux 实现称为传送带。如果数据流是单向且可预测的,那么我们就达到了 Flux 的一个目标。

同样,我们可以根据喜好实现调度器和 store 组件。我们可能对 store 组件进行一些调整,以更好地服务于我们的应用程序。这些调整可能是出于性能原因,也可能是为了方便开发者。只要数据流保持单向和同步,引入这些是完全可以接受的。

单向数据流和同步更新轮次的想法并不仅限于 Flux。我们可以在其他架构的约束下工作,如 MVC,并实现相同的原则。Flux 的独特之处在于它起源于挫败感。Facebook 的工程师们决定他们需要一个工具来明确地表达如何正确地实现这些设计原则。

实现选项 2 – 使用 Flux 库

我们当然不必自己实现每个 Flux 组件。在 Flux 库方面有很多选择。有趣的是,这个 Flux 库生态系统强化了 Flux 可以被解释的断言。也许最好的例子就是 Redux。这个库并不是 Flux 文档中概述的概念的实现。相反,Redux 采取了不同的路线来实现 Flux 原则。

例如,在 Redux 中没有调度器,我们只能创建一个包含还原函数的 store。重要的是我们仍然获得了单向数据流和同步的更新轮次。然后是 Alt.js,它在实现 Flux 方面采取了更传统的做法,因为它具有与 Flux 文档中概述的相同抽象。但 Alt.js 还在这些概念之上构建了自己的想法,使得实现 Flux 更加容易和愉快。

当我们决定利用 Flux 库时,是不是必须全盘接受?不一定。这种全有或全无的恐惧源于那些规定做事方式的单体框架,而且没有简单的方法来绕过这些问题。使用库的想法是能够挑选和选择你需要的部分来组合更大的行为。以 Flux 架构中的视图层为例——这通常由 React 组件组成。然而,Redux 或 Alt.js 并不要求我们使用 React。Redux 足够小,我们可以直接使用其 store 组件来处理我们的应用程序状态,而 Alt.js 有几个较小的模块供我们选择——可能有一些我们永远不会使用。

自行实现 Flux

考虑到有这么多实现 Flux 系统的方法,我们自己实现是否有任何实用性?换句话说,如果我们不依赖众多 Flux 库之一,而是自己实现 Flux 组件,是否会重蹈覆辙?根本不会。有很大可能性,没有任何 Flux 库能满足我们试图达成的目标。或者,可能有几个关于 Flux 组件的问题我们想要定制,这样依赖一个我们打算完全更改的实现就不再有意义。

本书中的大部分代码都是基于我们自己的 Flux 组件实现。我们依赖 Flux 派发器的参考实现,但后来我们实现了自己的版本,并没有遇到太多困难。自己实现 Flux 组件的积极方面是我们有自由调整组件以满足我们应用发展的需求。当我们依赖他人的实现时,这样做会更困难。

一种可能性是,我们使用像Alt.js这样的库来获取灵感,以实现我们自己的版本。这样,我们可以在修改它们的同时实现该库中的酷炫功能。另一方面,我们可能更倾向于直接使用现成的 Flux 库。最好的办法是在构建 Flux 架构框架时考虑这类事情。一开始不要依赖任何库,但尽早决定是否要使用像 Redux 这样的库,这样你就不必丢弃太多组件。

开发方法论

在本节中,我们将探讨在 Flux 项目不同阶段发生的开发方法论。请记住,这些只是指导方针,因为方法论可以从一个团队到另一个团队有相当大的差异。如果两个不同的团队正在实现 Flux 系统,无疑会有一些共同点。

首先,我们将思考在一个新的 Flux 项目初始阶段会发生什么。然后,我们将思考那些已经成熟起来的 Flux 项目,以及向系统中添加新功能的过程可能是什么样子。

预先 Flux 活动

许多软件开发方法论都不赞成大范围的预先设计。原因很简单——我们在编写和测试任何软件之前花费了太多时间进行设计。逐步交付软件片段给我们提供了一个机会来验证我们在编写代码时可能做出的任何假设。问题是,Flux 是否需要大范围的预先设计,或者我们能否逐步实现 Flux 系统的各个部分?

正如你在本书前面看到的,设计 Flux 架构的第一步是编写代码。起初,我们只对产生骨架架构感兴趣,以便我们可以了解组件将需要哪些类型的信息。我们最初不花时间实现 UI 组件,因为这很可能是时间陷阱,并且会分散我们对其他 Flux 组件(如存储和动作)的思考。

问题是,构建骨架架构能否融入软件开发常规流程中,而不需要过多的前期设计?我认为可以。

我们不想在骨架架构上花费太多时间,因为这只会导致无谓的讨论。然而,我们可以为构建骨架架构的各个部分设定冲刺目标,并与更大的团队进行审查。实际上,冲刺演示可能是一个理想的论坛,以决定我们是否已经构建了足够的骨架架构,并且我们对它是否满意。然后就是开始认真构建功能的时候了。

成熟化 Flux 应用程序

一旦我们远远超出骨架架构阶段,我们希望有一个稳固的产品,其中包含客户会喜欢的功能。理想情况下,这意味着我们已经找到了 Flux 架构的甜蜜点——它具有良好的可扩展性,易于维护,并且我们能够通过交付新功能来保持客户的满意度。换句话说,应用程序已经成熟。那么我们是如何达到这个阶段的,又是如何保持其发展的?

让我们考虑一个我们被要求构建的功能。我们有一个全能型程序员团队来构建它。我们应该如何将这个功能分解为实施任务?Flux 使得这一点相对容易理解,因为组件类型有限。所以如果我们能组织一个小团队来交付一个功能,那么一个人可以专注于实现视图,另一个人专注于存储和动作,还有一个人来构建后端数据服务。以下是一个团队和他们构建的 Flux 组件以实现应用程序功能的示例:

成熟化 Flux 应用程序

另一种方法是有团队专注于相同类型的组件。例如,一个存储团队可能会跨越多个功能,但每个成员在任何给定时间都会工作在一个存储组件上。这种方法较差,因为专注于同一可交付成果的 Flux 程序员团队对如何使功能提供最大客户价值有集体见解。

从 Flux 中借鉴想法

Flux 迫使我们以新的有趣方式思考应用的信息架构。采用这种新方法很少是在真空中发生的。这些想法往往会传播到技术栈的其他部分。在 Flux 中,数据流方向和以功能驱动的信息架构的架构原则脱颖而出,显示出积极的影响。如果这些事情可以对前端代码产生积极影响,为什么不能影响整个系统的设计呢?

单向数据流

通过 Flux 架构中数据单向流动可能是它能够扩展的关键方面。单从数据流的角度来看,我们编写的代码更容易理解。在某些地方,这种方法可能稍微有点冗长,但这是我们为了提高可预测性而有意做出的权衡。例如,在一些框架中发现的双向数据绑定功能,我们可以通过编写更少的代码来完成任务。然而,这却是以牺牲可预测性为代价的开发者便利性。

这实际上是从 Flux 中学到的一种可能适用于我们技术栈其他领域的教训。例如,是否有难以理解的代码片段,因为通过它们流动的数据在多个方向上移动?我们能改变这种情况吗?

可能很难像 Flux 那样强制执行单向数据流,但我们可以至少思考这给应用前端带来的好处,并尝试将相同的原理应用到其他代码中。例如,我们可能无法实现单向数据流,但我们可以通过移除特别难以预测的流来简化组件。

信息设计为王

Flux 架构从用户交互的信息开始,逆向工作,直至 API。这种方法与其他前端架构不同,在其他架构中,你有 API 实体,然后创建前端模型,视图(或视图模型)确定创建与用户相关的信息的必要转换。将信息放在首位的一个挑战是,我们可能会提出一些从 API 角度来看根本不可行的方案。

然而,如果情况确实如此,那么我们可能一开始就有一个功能失调的团队结构,因为很容易孤立于自己的技术泡沫(后端、网络、前端等),但在以功能驱动的产品中这根本行不通。所有贡献者都需要了解堆栈每一层的状况。

如果我们能够整理好团队,让每个贡献者都完全了解代码库的各个部分正在发生的事情,那么我们就可以在功能开发中采取“信息为王”的态度。Flux 在这方面表现良好,而且实际上这是为我们的客户服务的最佳方式。如果我们知道需要什么信息,我们就可以想出如何获取它。

另一方面,我们对什么可以做和什么不可以做有所偏见,因为我们已经有一个可以工作的 API。然而,这永远不应该成为我们何时以及如何实现功能的决定因素。就像 Flux 一样,我们应该围绕功能所需的信息来设计我们的抽象,而不是反过来。

打包 Flux 组件

在本节的最后部分,我们将从包的角度来思考大型 Flux 应用程序的组成。首先,我们将论证 Flux 应用程序的单一分布,以及这种做法变得不可行的时候。然后,我们将讨论包以及它们如何帮助我们扩展 Flux 应用程序的开发工作。最后,我们将通过一个例子来展示这可能如何运作。

单一 Flux 的案例

任何陷入依赖地狱的人都知道那是一个令人不愉快的地方。一般来说,我们通过过度依赖第三方包来引发这些问题。例如,我们可能从一个庞大的库中使用了几个组件,或者我们可能使用了一个极其简单的库来完成我们自己可以编写的事情。无论如何,我们最终拥有的依赖项比我们项目的大小和范围所证明的要多。

就因为我们正在为我们的应用程序实现 Flux 架构,并不意味着我们必须为了扩展而扩展。换句话说,我们仍然可以使用 Flux 来处理简单的应用程序,并承认目前还没有必要对其进行扩展。在这种情况下,我们可能最好尽可能避免依赖项。

我们简单的 Flux 应用程序的组成也可以是单一的。通过这种方式,我并不是说把所有东西都放入几个模块中。一个单一的 Flux 应用程序将以单个 NPM 包的形式分发。我们可能可以这样做相当长一段时间。例如,我们可以成功地将软件发布多年而不会出现任何问题。然而,当可扩展性成为一个问题时,我们必须重新思考最佳的方式来组合和打包我们的 Flux 应用程序。

包实现规模

应用程序最终会成为其自身成功的受害者。如果一个应用程序能够长时间存在并从客户那里获得足够的关注,它最终会拥有比它实际能够处理的更多功能。这并不是说我们的 Flux 架构不能处理很多功能——它可以。但从客户的角度来看,他们可能不需要或不需要使用其他客户使用的一切。

这要求我们认真思考我们的 Flux 架构的组成,因为我们可以肯定我们需要对功能进行更精细的管理。换句话说,可安装的功能。但是,这些组件以及我们通过它们安装的包需要多精细呢?我认为顶级功能可能是一个很好的度量单位。

例如,我们通常在一个单独的存储中模拟我们应用程序的给定顶级功能的状态。其他功能有自己的存储,我们可以依赖它们,依此类推。这意味着我们的应用程序需要考虑这样一个事实,即某个特定的功能组件可能没有安装在系统上。例如,如果我们创建一个实现用户管理功能的 Flux 组件,加载这些组件的应用程序将需要这个功能,就像它需要任何其他第三方包一样。

可安装的 Flux 组件

在本节中,我们将通过一个示例应用程序——尽管它很简单——来展示我们如何安装应用程序组件的主要部分。能够从我们的核心应用程序中移除主要部分是有益的,因为这将它们与应用程序解耦,并使得在其他地方使用这些包变得更容易。

让我们先看看应用程序的主要模块,这将有助于为构成两个主要功能的另外两个 NPM 包设定上下文:

// The React components we need...
import React from 'react';
import { render } from 'react-dom';

// The stores and views from our "feature packages".
import { Users, ListUsers } from 'my-users';
import { Groups, ListGroups } from 'my-groups';

// The components that are core to the application...
import dispatcher from './dispatcher';
import AppData from './stores/app';
import App from './views/app';
import { init } from './actions/init';

// Constructs the Flux stores, passing in the
// dispatcher as an argument. This is how we're
// able to get third-party Flux components to
// talk to our application and vice-versa.
const app = new AppData(dispatcher);
const users = new Users(dispatcher);
const groups = new Groups(dispatcher);

// Re-render the application when the store
// changes state.
app.on('change', renderApp);
users.on('change', renderApp);
groups.on('change', renderApp);

// Renders the "App" React component, and it's
// child components. The dispatcher is passed
// to the "ListUsers" and the "ListGroups"
// components since they come from different
// packages.
function renderApp() {
  render(
    <App {...app.state}>
      <ListUsers
        dispatcher={dispatcher}
        {...users.state}
      />
      <ListGroups
        dispatcher={dispatcher}
        {...groups.state}
      />
    </App>,
    document.getElementById('app')
  );
}

// Dispatches the "INIT" action, so that the
// "App" store will populate it's state.
init();

我们将从顶部开始——在这里,我们从 my-usersmy-groups 包中导入存储和视图。这是我们的应用程序代码,但请注意,我们没有使用相对导入路径。这是因为它们作为 NPM 包安装。这意味着另一个应用程序可以轻松共享这些组件,并且它们可以独立于使用它们的那些应用程序进行更新。在这些导入之后,我们有应用程序的其他组件。

注意

苹果的法律团队会很高兴看到我命名存储为 AppData 而不是 AppStore

接下来,我们创建存储实例。你可以看到每个存储都有一个引用传递给它。这是我们与依赖于它们来组合更大应用程序的 Flux 组件进行通信的方式。我们稍后会查看存储。

renderApp() 函数随后渲染主要的 App React 组件,以及来自我们的 NPM 包的两个组件作为子组件。正是这个函数我们在每个存储实例上进行了注册,所以当任何这些存储改变状态时,UI 将重新渲染。最后,调用 init() 动作创建函数,它填充了主要导航。

这个主模块对于能够将较小的、可单独安装的 Flux 包组合成较大的应用程序至关重要。我们在这里导入并配置它们。分发器是主要的通信机制——它被传递给存储和视图。我们不需要修改超过一个文件就能导入并使用大型应用程序的功能,这对于扩大开发工作非常重要。

现在我们将查看应用程序存储(不是苹果的)以了解导航数据是如何驱动的:

import { EventEmitter } from 'events';
import { INIT } from '../actions/init';

// The initial state of the "App" store has
// some header text and a collection of
// navigation links.
const initialState = {
  header: [ 'Home' ],
  links: [
    { title: 'Users', action: 'LOAD_USERS' },
    { title: 'Groups', action: 'LOAD_GROUPS' }
  ]
};

// The actual state is empty by default, meaning
// that nothing gets rendered.
var state = {
  header: [],
  links:[]
};

export default class App extends EventEmitter{
  constructor(dispatcher) {
    super();

    this.id = dispatcher.register((action) => {
      switch(action.type) {

        // When the "INIT" action is dispatched,
        // we assign the initial state to the empty
        // state, which triggers a re-render.
        case INIT:
          state = Object.assign({}, initialState);
          break;

        // By default, we empty out the store's state.
        default:
          state = Object.assign({}, state, {
            header: [],
            links: []
          });
          break;
      }

      // We always emit the change event.
      this.emit('change', state);
    });
  }

  get state() {
    return Object.assign({}, state);
  }
}

在这里,你可以看到这个存储有两个状态集——一个是存储的初始状态,另一个是传递给视图组件进行渲染的实际状态。状态默认有空的属性,这样使用这个存储的视图实际上不会渲染任何内容。《INIT》动作将导致状态从initialState填充,这导致视图被更新。

现在我们来看看这个视图:

import React from 'react';
import dispatcher from '../dispatcher';

// The "onClick()" click handler will dispatch
// the given action. This argument is bound when
// the link is rendered. Actions that are dispatched
// from this function can be handled by other packages
// that are sharing this same dispatcher.
function onClick(type, e) {
  e.preventDefault();
  dispatcher.dispatch({ type });
}

// Renders the main navigation links, and
// any child elements. Nothing is rendered
// if the store state is empty.
export default ({ header, links, children }) => (
  <div>
    {header.map(title => <h1 key={title}>{title}</h1>)}
    <ul>{
      links.map(({ title, action }) =>
        <li key={action}>
          <a
            href="#"
            onClick={onClick.bind(null, action)}>{title}
          </a>
        </li>
      )
    }</ul>
    {children}
  </div>
);

当存储状态为空时,默认情况下,渲染的只是一个空的div和一个空的ul。这足以完全从屏幕上移除视图。点击事件很有趣。它使用分发器来分发动作。动作类型来自存储数据,默认情况下,这个应用程序实际上并没有对LOAD_USERSLOAD_GROUPS动作做任何事情。但是我们在主模块中导入并设置的两个包确实会监听这些动作。这是使这种方法可扩展的一个重要部分——不同的 NPM Flux 包可以分发或响应动作——但这并不意味着任何动作都会实际发生。

这就是我们的应用程序的核心。现在我们将逐步介绍my-users包。my-groups包几乎相同,所以我们在这里不会列出那个代码。首先我们有存储:

import { EventEmitter } from 'events';
import { LOAD_USERS } from '../actions/load-users';
import { LOAD_USER } from '../actions/load-user';

// The initial state of the store has some header
// text and a collection of user objects.
const initialState = {
  header: [ 'Users' ],
  users: [
    { id: 1, name: 'First User' },
    { id: 2, name: 'Second User' },
    { id: 3, name: 'Third User' }
  ]
};

// The state of the store that gets rendered by
// views. Initially this is empty so nothing is
// rendered by the view.
var state = {
  header: [],
  users: []
};

export default class Users extends EventEmitter{
  constructor(dispatcher) {
    super();

    this.id = dispatcher.register((action) => {
      switch(action.type) {

        // When the "LOAD_USERS" action is dispatched,
        // we populate the store state using the initial
        // state object. This causes the view to render.
        case LOAD_USERS:
          state = Object.assign({}, initialState);
          break;

        // When the "LOAD_USER" action is dispatched,
        // we update the header text by finding the user
        // that corresponds to the "payload" id, and using
        // it's "name" property.
        case LOAD_USER:
          state = Object.assign({}, state, {
            header: [ state.users.find(
              x => x.id === action.payload).name ]
          });
          break;

        // By default, we want to empty the store state.
        default:
          state = Object.assign({}, state, {
            header: [],
            users: []
          });
          break;
      }

      // Always emit the change event.
      this.emit('change', state);
    });
  }

  get state() {
    return Object.assign({}, state);
  }
}

该存储处理两个关键动作。第一个是LOAD_USERS,它接受初始状态并使用它来填充存储状态。LOAD_USER动作改变头部状态的内容,并且当点击用户链接时触发这个动作。默认情况下,存储状态会被清除。现在让我们看看渲染存储数据的 React 组件:

import React from 'react';
import { LOAD_USER } from '../actions/load-user';

// The "click" event handler for items in the users
// list. The dispatcher is passed in as an argument
// because this Flux package doesn't have a dispatcher,
// it relies on the one from the application.
//
// The "id" of the user that was clicked is also passed
// in as an argument. Then the "LOAD_USER" action
// is dispatched.
function onClick(dispatcher, id, e) {
  e.preventDefault();

  dispatcher.dispatch({
    type: LOAD_USER,
    payload: id
  });
}

// Renders the component using data from the store
// state that was passed in as props.
export default ({ header, users, dispatcher }) => (
  <div>
    {header.map(h => <h1 key={h}>{h}</h1>)}
    <ul>{users.map(({ id, name }) =>
      <li key={id}>
        <a
          href="#"
          onClick={
            onClick.bind(null, dispatcher, id)
          }>{name}
        </a>
      </li>
    )}</ul>
  </div>
)

与你典型的 Flux 视图相比,这个视图的关键区别在于分发器本身被作为属性传递。然后,随着链接的渲染,分发器实例被绑定到处理函数的第一个参数。

我强烈建议下载并尝试使用这个示例中的代码。安装的两个包非常简单,仅足够说明我们如何将基本机制放在适当的位置,从而能够将主要功能从应用程序中分离出来,并作为可安装的包。

摘要

本章在软件开发生命周期的更大背景下探讨了 Flux。由于 Flux 是一套我们遵循的架构模式,因此在实现方面它们在很大程度上是开放的。在 Flux 项目的开始阶段,重点在于迭代交付骨架架构的各个部分。一旦我们拥有了一个具有多个功能的成熟应用程序,焦点就转向了复杂性管理。

我们讨论了其他技术栈领域可能希望从 Flux 中借鉴想法的可能性。例如,单向数据流意味着副作用的可能性更小,整个系统也更加可预测。最后,我们以探讨如何可能地使用由 Flux 组件构成的独立可安装特性来组合更大的应用程序来结束这一章节。

我希望这本书对 Flux 架构的阅读能有所启发。目标并不是一定要确定 理想 的 Flux 实现——我认为没有这样的事情。相反,我想要传达与 Flux 的重要原则相伴随的思考方式。如果你发现自己正在实现某些东西,并开始思考单向数据流和可预测性,那么我可能已经成功了。

posted @ 2025-09-26 22:10  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报