Flux?
Flux是一套方便我们去遵循的架构模式。
Flux的架构
数据入口
在传统前端架构设计中,我们很少考虑如何处理系统的数据入口。我们可能对此有个初步的方案,但是并不具体。例如,通过**MVC(模型-视图-控制器)**架构,让控制器来控制数据流。通常,这很有用。但另一方面,控制器实际控制的只是当数据已经存在后所发生的事情。那么控制器该如何在一开始就获取数据呢?如下图所示。
存在的问题:
初看此图,似乎没什么问题。以箭头标识的数据流应该很容易跟踪。但数据从哪里来的呢?例如,通过用户事件,视图可以创建新的数据,并传递给控制器;根据各控制器之间的层次关系,一个控制器可以产生新数据并传递给另一个控制器。但关于控制器,它能自己创建数据给自己使用吗?
在类似这样的图中,这些问题看起来并不重要。但是,如果我们尝试将它扩展到拥有数百个类似组件后,数据入口在这个系统中的地位就非常重要了。因为Flux是一种用于复杂系统的可扩展架构,所以在这种架构模式下,数据入口是十分重要的。
状态管理
状态是我们在前端开发中经常需要处理的。不幸的是,我们难以在无任何副作用的情况下整合所有的纯函数,这有两个原因:
- 第一,我们的代码需要与 DOM 【文档对象模型(Document Object Model,简称DOM)】有正向或反向的交互,这也是用户在界面中所能感知到的;
- 第二,我们不能把程序里所有的数据都存在DOM中(至少现在不能),这些数据会因为时间的推移和用户的操作而发生变化。
在Web应用中,并没有现存的状态管理的方法,但有多种方式来限制状态改变的数量,以及规定如何发生改变。例如,纯函数不能修改任何状态,它们只能创建新数据。以下是一个类似的示例。
正如你所看到的,此处的纯函数并没有副作用,因为,任何对它们的调用,都不会导致状态的变化。如果状态的改变是不可避免的,那么为什么还要采用这种做法?我们的方法是规定状态在哪里改变。例如,如果只允许部分组件类型可以修改程序里的数据状态,这样,我们就可以掌控哪些源可以引起状态变化。
Flux十分擅长控制状态在哪里发生改变。在本章的后面部分,我们会看到Flux存储器(Stores)如何管理这些状态的改变。Flux如何管理状态的重要性所在,是它在架构层上的处理。设计一套规则来决定哪些组件类型可以变更程序数据,这让我们感到很困扰,而相对于此,通过Flux则不需要花费什么精力来考虑在哪里更改状态。
保持同步更新
数据入口点是同步更新的重要概念。也就是说,除了管理该状态变化的来源,我们还必须要管理这些相对于其他事务的变化顺序。如果数据入口点就是我们的数据,这时候我们应该同步地将状态的改变应用到系统中的所有数据。
让我们花点时间想想这为什么如此重要。**在系统中数据被异步更新时,我们必须考虑竞争条件。竞争条件可能会产生问题,因为一个数据可能依赖于另一个,如果它们以错误的顺序更新,我们会遇到一连串的问题。**下图说明了这个问题。
当事务是异步的时,我们无法控制何时发生状态改变。因此,我们所能做的就是等待异步更新发生,然后检查数据,并确保满足所有的数据依赖。没有自动化工具为我们处理这些依赖,我们只能写很多代码来检查状态。
Flux通过确保同步更新数据存储器解决了这个问题。这意味着,上图所示的情况不会发生。下图更好地展示了Flux是如何处理当今典型的JavaScript应用程序中的数据同步问题的。
信息架构
Flux组件被实现为真实软件的组件,用于执行实际计算。诀窍是,Flux模式使我们可以将信息架构作为首要的设计考量。不是非得通过各种组件及其实现问题来进行筛选,而是我们可以确保得到正确的信息给用户。
一旦我们的信息架构初具规模,更大的应用程序就会接踵而至,作为一种我们试图传达给用户信息的自然扩展。从数据中产生信息是困难的部分。我们不仅仅需要从大量的数据源中提取信息,并且这些信息也必须对用户产生价值。在任何项目中犯这种错误都将面临巨大风险。当处理正确时,我们就可以继续处理特定的应用程序组件,如按钮控件的状态等。
**Flux架构保持数据在存储器中进行转换。存储器是一个信息工厂,原始的数据进入,新的信息产出。存储器控制数据如何进入系统、同步状态变化、定义状态如何变化。**当我们跟随本书深入了解存储器后,将看到它们如何成为信息架构的支柱。
Flux的设计思路问题解决方案
如果Flux只是架构模式的集合,而不是一个软件框架,那么它能解决什么样的问题呢?在本节中,我们将从架构角度来看Flux所能解决的设计思路问题,包括:单向数据流、可追溯性、一致性、组件分层和低耦合组件。这些问题都会在我们的软件中产生风险,尤其是大型可扩展应用。Flux可帮助我们摆脱这些问题。
数据流向
我们正在建立一个信息架构,使得具有复杂功能的应用能够在此之上构建。数据流入系统,并最终到达终点,从而结束整个流程。在入口点和终止点之间所发生的就决定了Flux架构的数据流,如下图所示。
数据流的概念是一个很好的抽象,因为这可以很好地去可视化数据的流向,你可以很清楚地描述它如何进入系统,然后从一个点移动到另一个点,最终流动停止。但在它之前,会有些副作用发生,那就是上图的中间块所关心的问题,因为我们不能确切地知道这个数据流是如何到达终点的。
正如你所见,我们的系统已经很明确地定义了数据的入口和出口。这太棒了,因为这意味着我们可以很自信地说出流经系统的数据流。但问题是,系统中各组件之间的数据流并没有在这张图中展现。图中数据流是没有方向可循的,更确切地说,是多方向的,这糟糕透了。
Flux是一个单向数据流架构。这意味着,上图中的组件层是不可能出现的。现在的问题是,为什么说多向数据流不好?有时候,我们会觉得数据在各组件之间以任意方向传递是很方便的,这并不是个问题,因为传递数据不会破坏我们的架构。然而,当数据在系统中的移动是多方向的时,我们需要花更多的精力去为它们同步。简单来说,如果数据没有按照一致的方向进行流动,就有出错的可能。
**Flux强制数据流的方向,因而降低了因组件更新顺序不当而破坏系统的可能性。**无论什么数据进入系统,都应当按照同样的顺序流入系统,如下图所示。
可回溯性
我们知道,当数据流单向地从系统进入组件中的时候,很容易预测和跟踪所有可能会产生的影响。相反,当一个组件向其他任何一个组件发送数据的时候,却很难捕捉到数据是如何到达的。为什么会这样?通过调试器,我们现在很容易地可以在运行时遍历任何一级,无论有多复杂。但这个概念的问题在于,我们只是调试我们所需要的。
**Flux架构中的数据流本质上是可预测的。**这对于许多活动设计都是十分重要的,而不只是局限于调试。所以在用了Flux架构的应用中,程序开发者能很直观地知道即将发生什么。这种预期是很关键的,因为这样可避免让我们设计出死胡同一样的程序。当我们能够很容易地弄清楚因果时,就可以将大部分时间花在构建应用的功能上,因为这才是用户真正关心的。
通知的一致性
在Flux应用中,我们从一个组件向另一个组件发送数据时,需要保持数据流向的一致性。在保持一致的时候,还需要考虑系统中的数据流向机制。
例如,分发和订阅就是一个在组件间通信十分流行的机制。这种方法所带来的好处就是,我们的组件可以相互通信,并且保持一定程度上的解耦。事实上,这在前端开发中是相当普遍的,因为组件的通信都是由用户的事件所驱动的。这些事件可以被认为是“即发即弃”的。任何其他组件想在某种方式上响应这些事件,都需要去订阅这个特定的事件。
分发和订阅机制所带来的问题:
分发和订阅的机制虽然有它的优点,但它在架构上也遇到了挑战,尤其是在大型复杂的应用中。例如,为了开发某项新功能,我们增加了几个新组件,但是以下这几点一直会困扰着我们:这些组件应该接受来自已有组件的消息更新吗?它们会得到这些更新响应的通知吗?它们应该先响应吗?这就是复杂性所带来的数据依赖问题。
分发和订阅带来的另一个挑战就是,我们有时需要先订阅一个通知,而后取消该订阅。这样,当我们试图在拥有众多组件的系统中,去尝试保持组件完整的生命周期,不仅十分困难,而且会增加丢失事件捕获的几率,从而破坏了一致性。
Flux的解决方案
Flux方法,则是通过维持一个静态的内部组件信息树,来规避上面的问题,从而将消息分发到每个组件中去。换句话说,程序开发者并不需要去挑选组件所需要订阅的事件,而只需要区分出分发给它们的事件中哪些是相关的,剩下的则可以忽略。下面是关于Flux如何分发消息的示意图。
**Flux分发器给每个组件发送事件,没有其他机制可以绕过这种方式。我们需要实现组件内的逻辑来判断此消息是否有用,以取代对消息结构的篡改而导致的难以扩展的问题。并在组件内部来声明对其他组件的依赖,这样对接下来的数据流向很有帮助。**在接下来的章节中,我们会更详细地进行讲解。
简捷的架构分层
分层是一种对组件进行组织的很好的架构方式。一方面是因为分层能够对应用的各种模块进行明确的分类,另一方面是因为分层可以对通信路径做出限制。后一点与Flux架构尤其相关,因为保持其数据流的单向性是十分重要的,而对分层做限制比对独立组件做限制要容易得多。下面所示的是Flux分层的示意图。
上图主要描绘了数据在三个分层之间是如何流动的,并没有展示Flux架构的完整数据流,也没有描述每个分层的细节。但别着急,下一节我们将会对Flux组件的类型进行引导性解释,而分层之间的通信则是本书的重点。
从上图中可以看到,数据从一个分层流向下一个分层,并且保持同一个方向。Flux仅有几层,应用的规模以组件数量来衡量,而分层的数量仍然是固定的。当给一个已经很庞大的应用增添新特性时,会使其复杂度达到上限。所以,除了限制分层数量和数据流方向, Flux架构也限制了哪些分层可以与其他分层进行通信。
例如,动作层(Action Layer)可以与视图层(View Layer)进行通信,并且该通信是单向的。我们可以编写Flux设计的分层,并且不允许跳过某个分层。确保一个分层只能与位于其下紧邻的分层进行通信,可以避免无序引入的代码问题。
低耦合渲染
Flux设计的一个亮点在于架构不用关心UI元素如何被渲染,也就是说,视图层与架构的其他部分是低耦合的。这样设计是有原因的。
Flux首先是一个信息架构,其次才是一个软件架构。我们将首先学习其作为信息架构的思想,最后会学习其作为软件架构的思想。我们知道,视图技术的缺点是它会对架构的其他部分产生副作用,例如一个和DOM进行特殊交互的视图就会产生这样的影响,所以,一旦我们决定使用这项技术,它势必会对信息架构的组织方式产生影响。这并不一定是件坏事,但它将导致我们对最终呈现给用户的信息做出妥协。
实际上,我们真正应该思考的是信息本身,以及信息是如何变化的。哪些相关行为会导致这些变化?数据之间是如何依赖的?Flux不受当下浏览器技术的限制,这使得我们可以优先关注信息本身。因此,在信息架构中插入视图使其变为软件产品是很容易的。
Flux组件
在本节中,我们将开始 Flux 概念之旅,这些概念是规范 Flux 架构的基本组成部分。尽管此处并不会详细介绍如何去实现这些组件,但这为具体的实现方式奠定了基础。本节将对这些组件进行概要性的介绍,并在本书之后的章节中进行实现。
动作
动作就像系统内的动词一样。实际上,如果我们直接从一个句子中获得其中动作的名字,那将是很有帮助的,这些语句是对需要应用来实现的功能的典型描述,下面是一些例子。
- 抓取会话
- 导航至设置页
- 筛选用户列表
- 切换详情区域的显示与隐藏
上面的这些都是一个Web应用所具备的基本功能,当我们将它们在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黄金法则:数据流出视图的唯一方式是分发一个动作。
MV* 所面临的挑战
MV* 是JavaScript前端应用中普遍采用的架构模式。在这里,我们称之为MV* 是因为现在有多个相关模式的变种,但每一个都以数据模型和视图作为核心。在本书后续的讨论中,我们将把它们当作相同的JavaScript架构来看待。
MV* 设计模式非常糟糕,因此它并不能成为开发社区的推动力,而它之所以流行是因为它能够正常运作,以满足我们的需要。虽然 Flux 可以被认为是 MV* 的一套替代方案,但仍旧没有必要去毁掉一个正在正常运作的程序。
世上可能并没有一套完美的架构,Flux也并不意味着很完美。本节的目标不在于贬低MV* 和所有以此而建立的事物,但会去观察 MV* 的弱点,并看看 Flux 在这些地方是如何做出更好提升的。
关注点分离
==MV* 的一个非常有优势的地方在于,它能确保关注点完全分离。也就是说,每个组件都有它自己的职责,这种模式遍布整个架构。==另外,关注点分离是单一责任原则,这将确保关注点的完全分离。
为何我们需要关心这些?简单说来,当我们将职责分离到各组件中时,系统中的各部分便可很轻松地解耦。这意味着我们可以修改任意一处,而这并不会影响到其他内容。这是任何软件系统中都想拥有的特性——不用关注架构。然而,这真的是我们从MV* 中获得的,这真的是我们应该追求的吗?
举个例子,将一个功能拆分为五个清晰的职责,这或许并没有什么意义。对功能行为的解耦可能并不能实际解决任何事,因为,每当我们需要修改某处时,都需要访问这五个组件。所以,相对于帮助我们去精心制作一个智能架构,关注点的分离无异于阻碍了生产力。下方是一个示例功能图示,演示了集中职责的分解。
每当一名开发者需要拆分一项功能以了解它们是如何工作的时候,他们最终会因为在源代码文件中翻来翻去而花费更多时间。这项功能看起来支离破碎,并且在代码组织方面也并无明显优势。现在我们再来看看基于Flux架构所构建的组织模式,如下图所示。
Flux的功能分解方式使得一切更可预测。我们在这里省略了视图自身分解的情形的讨论,因为其并不属于 Flux 的一部分。对于 Flux 架构来说,我们所关心的是,当状态改变被触发时,正确的信息始终能传输给我们的视图。
你可能留意到 Flux 功能中的逻辑和状态,二者保持紧密的耦合。这与 MV* 形成鲜明对比,==MV* 认为应用中的逻辑都应该是独立的实体,并可操作任何数据。而Flux正好相反,我们可以在状态附近发现负责修改其状态的逻辑。==故意设计成这样,这暗示我们不需要花太多精力在关注点分离,否则会导致更多的不便而非帮助。
正如我们将在接下来的章节中所看到的,该数据与逻辑的紧密耦合是Flux存储器的特点。上面的图呈现了这一点,并包含了复杂的功能,这也使得增加更多逻辑和状态变得更为轻松,因为它们始终在功能的外层,而非夹杂在组件树中。
级联更新
当我们有一个组件能正常工作是很棒的,这可以帮助我们处理很多事情,而且,更为重要的是,这一切还是自动化的。组件能为我们完成一切事情,而不需要一个接一个地手动触发这些方法。我们来看看右边这张图。
当我们将输入信息传入到一个大组件后,我们相信它能顺利地自动完成我们所预期的事情。这类组件最好的地方,莫过于我们只需要维护较少的代码。并且,通过组织其子组件间的通信,该组件能够知道如何更新自己。
这是级联效应的开始。我们将告知某一组件执行一些行为,这又会继续导致另一组件做出反应。我们给其提供一些输入信息,这会导致另一组件有所反应,并以此类推。很快,我们就很难搞清楚我们的代码接下来要干什么了。这是因为,我们所关心的事情,都在视图之下隐藏着。此即所谓有心栽花花不开。
上图其实并不至于太糟糕。当然,难以理解的程度取决于这个大组件里包含了多少个子组件,但总的来说,这是个可控的问题。我们再来看看下图,这是上图的衍生版本。
发生了什么?多了三个方框和四条线,这便造成了级联更新的复杂程度大爆炸。现在这个问题将不再是可控的了,因为我们难以简单处理这种复杂状况,并且许多基于这种自动更新的MV*应用都拥有不少于6个组件。
我们设想去封装一些能够自动更新的组件,这有些天真。其问题在于,这通常是不可行的,至少当我们计划还要对软件进行维护时是这样的。Flux避开了这个级联更新的问题,因为只有一个存储器可以改变它自己的状态,而且这通常是为了一个动作而进行的响应。
模型更新的职责
在一个MV* 的架构里,状态被存储于模型当中。若要初始化模型的状态,我们需要从后端API中获取数据。确切说来:我们创建一个新模型,然后告诉这个模型去获取一些数据。然而,MV* 没有提及谁负责更新这些模型。一种说法是,控制器会来掌管对这个模型的控制。但实际上呢?
例如,在视图为了响应用户交互而触发的事件处理函数中,会发生些什么?如果我们只是允许控制器更改模型的状态,那么这个视图事件处理函数会直接联系控制器。下图是控制器在不同场景下修改模型状态的情景。
乍一看,这个控制器设置得很合理。它作为模型的包装来存储状态。这是一个很安全的假设:任何想要改变这其中任一模型的动作,都需要经过控制器。毕竟,控制这些动作是它的责任。无论是来自于API的数据,还是由用户触发的事件,如果它们要改变模型的状态,都需要联系控制器。 随着我们的控制器的增长,由控制器控制的数据模型修改需求,导致越来越多用于控制模型状态的方法出现。如果退后一步并看看所有这些积累的方法,我们会留意到一些没用的中间层。我们通过代理来修改这些状态,这么做能带来什么好处?
另一个导致在MV* 中控制器尝试建立状态的一致更改是条死胡同的原因是,模型自己也可以这么做。例如,在一个模型中设置一个属性,会对其他模型的属性产生副作用。更糟糕的是,在系统的其他地方,可能会对该模型的状态进行监听,这也会导致级联更新问题。
Flux存储器解决级联更新的问题,是通过允许通过动作修改状态来达成的。该机制回答了这里讨论的关于MV* 所面临的挑战。我们没必要担心视图或其他存储器直接对存储器中状态的修改。
单向数据
Flux架构中的基石便是单向数据流了,例如,数据流向是从A点到B点,或者A点到B点到C点,又或者是A点到C点。像这样的方向很重要,其特征是单向数据流,而且规模较小、井然有序。所以,由于我们的架构采用了单向数据流,这便意味着我们的数据绝不可能从B点流向A点。这是Flux架构中的一个重要属性。
我们在之前一节中看到,MV* 架构中的数据流具有难以辨别的方向。在本节中,我们会介绍一些体现单向数据流价值的属性。我们将开始看看数据流中的起始节点和终止节点,然后思考一下如何避免单向数据流的副作用。
从开始到结束
如果数据流是单向的,那么它必须同时拥有开始节点和结束节点。换句话说,我们不支持无尽头的数据去肆意影响该数据流所流经的各组件。当数据流是单向的,并明确定义了开始和结束节点,那么我们是不会拥有闭环流程的。取而代之,我们在Flux中有一个数据流大循环,如下图所示。
这是Flux架构的一个最简单模式,但这确实有助于说明任何给定数据流的起始和终止点。我们将此称为更新轮回(Update Round)。从某种意义上来说,更新轮回是原子性的,它会运行至完结,其间无法停下,除非出现异常的抛出。
更新轮回肩负着更新整个应用状态的大任,而非只是针对订阅了某些类型动作的部分。这意味着随着应用的增长,我们的更新轮回也会增长。当一个更新轮回触及每一个存储器之后,它可能看上去像是数据单向流经全部的存储器,以下是示意图。
从单向数据流的角度来看,我们并不需要关心有多少存储器存在。重要的是需要记住,更新操作并不会被其他分发的动作所打断。
无毒无害
正如我们在MV*架构中所看到的,关于自动修改状态的优势,也正是它的弱势。当我们在隐蔽的规则下面编程时,事实上还会产生一大堆副作用。这会导致无法扩展,主要的原因是,它其实并不会按照我们所想的流程,去把控所有隐藏的关联。Flux则尽可能地避免了副作用。
让我们一起来看看存储器。它们在我们的应用中是状态的仲裁者。当更新了状态,它有可能会导致另一处的代码也因此运行起来。这在Flux中确实会发生:当一个存储器改变状态,如果有视图在该存储器中注册了订阅,便可能会获得此次修改的通知。这是Flux中唯一可能产生的副作用,但这是不可避免的,因为我们有时确实需要在状态变更时更新DOM(文档对象模型)。而 Flux 的区别在于其在涉及数据依赖时避免产生副作用的方式。在用户界面中,对数据依赖关系的典型处理方式是,通知需要相关操作的依赖模型。想一想级联更新的情形,如下图所示。
在Flux中,当两个存储器间存在依赖时,我们只需在依赖存储器中声明这个依赖。这么做是去告诉分发器要确保被依赖的存储器先被更新,然后,依赖存储器就可以直接使用它所依赖的存储器中的数据。由此,所有的更新依旧在同一个更新轮回中执行。
显式优于隐式
通过使用架构模式,大家通常倾向于,隐藏背后那随着时间流逝而变得愈加复杂的抽象概念,来将事情变得简单。最终,系统中越来越多的数据被自动修改,开发者的便利性被隐藏的复杂性所替代。
这是一个显式的扩展性问题。而Flux通过明确动作和数据转化来处理它,而非隐藏抽象概念,这么做更有优势。在本节中,我们将探索和权衡显式所带来的好处。
暗藏隐患的更新
我们已经看到,在本章中,通过隐藏抽象概念来处理隐藏状态变更是多么困难。这虽然能减少一些代码,但当我们回过头再来看这些代码时,却也让我们难以理解整个工作流。有了Flux,状态便可以保存在存储器中,并且存储器会对修改其自身状态负责。当我们了解某一存储器如何更新状态时,所有的状态变换代码就在那里,这就像在仙境一般。
我们有一个存储器,里面包含一个简单的state对象。在构造函数中,存储器在分发器中注册了一个回调函数。所有的状态变化都明确地放在了一个函数里,这就是将数据呈现到用户界面上的地方。我们再也不需要紧紧地跟着数据在各组件中穿梭,因为在Flux中不会出现这种情况。
那么问题来了,这些视图是如何利用整套状态数据的呢?在其他前端架构中,任何一种状态发生变化时,都会通知视图。在前面的实例中,一个可单击属性发生变更时,视图会获得通知,而后显示属性变更时,视图又会收到更新。视图具有逻辑来呈现这两次独立的变化。然而,Flux中的视图不会这么细粒度,取而代之的是,只有当存储器状态发生变化,且状态数据就是提供给视图时,它们才会收到通知。
这里想说的是,我们倾向于那些能够很好地重新渲染全部组件的视图相关技术。这也使得Flux架构非常适合于React。尽管如此,我们也可以使用任何我们喜欢的视图相关技术,这会在本书的稍后部分有所介绍。
集中修改状态的地方
在前面一节我们看到,存储器变化的代码都被封装在存储器里面,这是有意为之的,改变存储器状态的代码就应该在存储器附近。这种代码上的近距离关系,可以大大减轻为了搞清楚——随着自身愈发庞大而导致复杂性增加的系统的困难程度。这使得状态的变更变为显式的,而非抽象和隐式的。
当让存储器来管理所有状态变化的代码时,需要权衡的是,有大量这样的代码。在这段代码中,我们使用了一个简单的 Switch 判断,来处理所有的状态变化逻辑。当有许多情形需要处理时,即有很多case时,这显然会令人头疼。我们会在本书稍后部分进行描述,那里有更为大型和复杂的存储器。现在,我们只需知道可以重构存储器,来优雅地处理大量不同情形,同时保持业务逻辑和状态的强耦合。
这又让我们回到了业务分离原则。通过Flux存储器,数据和对应的操作逻辑并不会分离。这真的是一件坏事吗?当一个动作被分发,存储器会获得与此相关的通知,并随后修改其状态(或忽略这个动作,什么都不做)。修改状态的逻辑被放在了同一个组件中,因为没有更好的理由要将其放到其他地方。
太多动作?
动作使得 Flux 架构中发生的一切事情都是显式的。毫不例外,真的。只要发生变化,都是由于一个动作被分发所导致的。这很棒,因为这能非常容易地找出动作在哪里被分发。甚至在更为庞大的系统中,我们依然能够快速轻松地在代码中找到动作分发,因为它们就只在那些为数不多的地方。例如,我们不会在存储器中找到正被分发的动作。
我们创建的任何功能都有可能拥有数十个动作,或者更多。不过,从架构角度看,我们认为东西越多越糟糕。如果有太多东西,那就会变得难以扩展和开发。这有一定的道理,不过,如果会有大量大型系统中不可避免的东西在其中,那最好是动作。对于动作,再多也不会让人发疯。在应用中描述哪些事情需要发生时,相比较而言,用动作这种较为轻量的方式,并不会让人烦恼。
拥有大量的动作,是否意味着,我们需要把它们塞入一个巨大的动作模块中呢?值得庆幸的是,我们并不需要这么做。因为,虽然动作是任何Flux系统的入口,但这并不意味着不能将其模块化成我们想要的样子。这对于我们开发的所有 Flux 组件都是如此,并且,在本书中,我们将始终会留意这一点,保持代码的模块化。
分层优于嵌套
用户界面事实上是具有嵌套层次的,一部分是因为HTML是基于继承关系的嵌套层次所导致的,另一部分是因为我们呈现给用户的数据结构所导致的。例如,我们会在一些应用中嵌入多级导航,因为我们无法在屏幕中一次性塞入全部内容。自然的,我们的代码就开始从这种嵌套层次结构中变得嵌套起来。这不是条好消息,因为深度嵌套会导致难以理解。 在本节中,我们会了解前端架构中的嵌套层次结构,以及Flux是如何避免复杂的嵌套层次的。首先,我们设想几个上层组件,并且每个都拥有自己的嵌套层次。然后,我们来看看这其中的副作用,以及数据流是如何在Flux分层中流动的。
多组件嵌套
一个应用可能包含少许主要功能,这些功能一般会由顶层大组件或模块来实现。然而,这些组件通常并非是整块的,而是会继续拆分成许多小组件。一些具有通用功能的小组件,有时会被一些大组件重用。例如,下图是一个组件嵌套图,其中的大组件又分解为多个模型、视图和小组件。
在我们的应用架构中,从某种角度看,这是非常合理的。通过观看这幅组件嵌套图,我们可以非常容易地看出应用是如何构建的。在图中,以每个顶层大组件作为根节点来看,其下各嵌套架构就像一个个彼此独立的小宇宙一样,互相隔离。现在,我们又回到关注点分离上了。我们可以开发一项功能,而不影响到其他功能。
这种方法的问题在于,用户界面功能通常依赖其他功能。换句话说,一个组件嵌套的状态经常会依赖另一个组件的状态。那么,当没有机制来控制状态何时变更时,我们应该如何保持两个组件树之间的相互同步?最终,一个嵌套层次中的一个组件,将引入一个任意依赖到另一个嵌套层次中的组件中去。这样用途便单一起来,于是,我们必须引入一套新的跨嵌套依赖,来保证一切都是同步的。
嵌套深度与副作用
嵌套的一个问题在于深度。也就是说,一个嵌套有多深?应用中的功能会不断改变和扩展,这会导致我们的组件树变得越来越高,同时,还会越来越宽。例如,我们的一项功能使用了一个三层深的组件嵌套层次。
然后,我们又新加了一个层级。现在,看来必须在这一层级和更高层级上增加一些新组件。于是,为了建立嵌套层次,我们必须从多个方向扩展:横向和纵向。请看下图。
在多个方向上扩展组件是较为困难的,特别是在组件嵌套层次中,那里面没有清晰的数据流向,也就是说,结束修改某些状态的输入,可以在嵌套层中的任何一级中进入。毫无疑问,这么做会产生一定的不良后果,并且,如果我们依赖其他嵌套层次的组件,那一切希望都可能成为灰烬。
数据流和分层
Flux 拥有非常独特的架构分层,相对于嵌套层次,更具备架构扩展的能力。这个原因非常简单,因为我们只需要在每个架构分层上横向扩展组件即可。我们不需要在一个层级中添加新的组件,更不用添加新的层级。让我们一起来看看以下Flux架构的分层模式图吧。
无论一个应用有多大,都不需要增加新的架构分层。我们可以在这些分层中轻松添加组件。之所以能这么做,而不用在某个层级中创建一团混乱不堪的组件连接,是因为这所有的三个层级,都在一个更新轮回中运作。一个更新轮回开始于动作,而结束于最后一个渲染的视图。数据流在应用中的各层之间穿越,而且还是单一方向的。
应用数据和界面状态
当通过关注点分离,将一个地方的展现和另一个地方的应用数据粘在一起时,我们有两个不同的地方需要管理状态。除了在Flux中,唯一包含状态的地方就在存储器中。在本节中,我们将比较应用数据和UI数据。然后,我们将处理最终导致UI改变的数据变化。最后,将讨论Flux存储器的功能中心化的性质。
两个相同的东西
很多时候,获取自 API 的应用数据会被送往一些视图层。这些视图层也被称为展现层,用于负责将应用数据转换为一些对用户有价值的内容,我们也可以将这个过程称为将数据变为信息。在这些分层中,我们通过状态来完成对 UI元素的呈现,例如一个复选框是否被选中。右图展示了我们通常在组件中如何去组织这两种类型的状态。
这并不适合 Flux 架构,因为存储器保存了状态,包括UI。所以,一个存储器是否需要同时包含应用状态和UI状态?好吧,并没有什么不可以。如果所有东西都有一个在存储器中自治的状态,那就应该能非常容易地对UI元素的状态和应用数据进行分辨。下面是关于Flux存储器中这些状态的示意图。
尝试从其他状态中分离UI状态的一个基本误解是,组件通常依赖于UI状态。甚至, UI状态的不同功能可以以不可预知的方式进行相互依赖。Flux认可这一点,并且不会尝试将UI状态作为一种特殊形态,而从其他应用数据中进行分离。
在存储器中,完成改变的UI状态,可以从许多地方获得。通常,来自我们应用数据的两个或更多项可以决定一个用户接口状态项。一个用户接口状态项可以从其他用户接口状态中获得,也可以从其他形式更复杂的地方获得,就像一个UI状态和其他应用数据。在其他情况下,应用数据应该足够简单,以便可以直接在视图中使用。这里的关键在于,视图包含足够的信息,从而可以渲染它自己,并不需要跟踪自己最终的状态。
强耦合转换
应用数据和UI状态是强耦合在Flux存储器中的,而只有当数据转换器也被强耦合在存储器中,这才是合理的。对于我们来说,修改基于其他应用数据或基于其他存储器状态的UI,是非常容易的。
如果业务逻辑代码并不在存储器中,那么我们需要开始在包含存储器所需逻辑的组件中引入依赖。当然,这可能是一段转换数据的通用业务逻辑,并需要分享在多个存储器中,不过,从高层次角度看,这种情况非常之少。存储器非常适合维护转换状态的业务逻辑,并将其强耦合在其中。如果需要减少复杂的代码,我们可以引入更小、更细粒度的辅助函数,来帮助我们进行数据转换。
也可以使我们的存储器更为通用,使其变得抽象,并且不直接和视图建立直接的相关性。我们将会在本书稍后的部分进行详细介绍。
功能中心化
如果说,改变存储器状态的数据转换被强耦合到该存储器自身,那么这是否意味着,存储器是针对某一特殊功能的?或者说,我们是否需要关心存储器被用于其他功能?当然,我们有时会有许多通用数据,它们不需要在多个存储器中重复多次。但是,通常而言,存储器是具有特定功能导向的。功能是Flux中的一个术语,每个人会根据不同的方式划分具体的用途。
这是与其他基于从API获取的数据模型来构建数据模型的架构所不同的。在那些架构中,它们继续利用这些模型来创建视图模型。任何一个MV*框架都有太多的关于它们模型抽象的功能,例如数据绑定和从API自动获取。它们只关心存储器状态,以及当状态变更时需要发出通知。
当存储器鼓励我们创建和存储适合UI的状态时,我们可以更容易地为用户进行设计。这是Flux存储器与其他架构的模型的最基本区别。在Flux中,UI数据模型会优先载入。存储器内的转换可以确保将正确的状态分发给视图,而其他的都是次要的。