Flux 数据流两三事儿
Flux是由
Facebook 在 2014 年 7 月提出的一种 React
应用体系架构,主要用于解决多层级组件之间数据传递以及状态管理的问题。并由此派生出了Reflux、Redux、MobX等一系列单向数据流框架。为 Web
前端页面实现组件化拆分之后,组件间的通信与协同机制提供了一套较为完善的方法学。其核心理念在于将所有应用状态放置在Store
内进行统一管理,视图层组件只能通过触发Action
修改Store
中的应用状态。
本文首先系统的概括 Facebook
官方的Flux以及单向数据流思想,然后遵循近几年Flux
衍生框架的发展历程,逐步进行概括性的分析与比较,并顺带介绍了
Vue 技术栈当中的类 Flux 框架 VueX,最后,由于通常将Action
视为
Flux 工作流的核心与起点,本文还对《Flux
Standard Action》自述文档进行了翻译,以期更为全面的展现 Flux
生态的演进过程。
Flux
Flux 是 Facebook 官方构建 Web 前端应用体系架构,通过数据的单向流动有效补足了 React 组件间通信的短板,Flux 架构思想主要由如下 4 个部份组成:
- Action:视图层发出的动作信息,可以来自于用户交互,也可能来自于服务器响应。
- Dispatcher:派发器,用来接收 Actions 并执行相应回调函数。
- Store:用来存放应用的状态,一旦发生变化就会通知视图进行重绘。
- View:React 组件视图。
单向数据流
所谓的单向数据流(unidirectional data
flow)是指用户访问View,View发出用户交互的Action,Dispatcher收到Action之后,要求Store进行相应更新。Store更新后会发出一个change
事件,View收到change
事件后更新页面的过程。这样数据总是清晰的单向进行流动,便于维护并且可以预测。
Dispatcher
dispatcher
集中管理 Flux
应用程序的全部数据流,本质上是store
上注册的回调函数,主要用于分发action
到store
,并维护多个store
之间的依赖关系(官方实现是通过
Dispatcher 类上的 waitFor()方法)。
当Action
Creator发起一个新的action
到dispatcher
,应用中的所有store
都将通过注册的回调函数接收到action
。伴随应用程序的增长,dispatcher
会变得极为重要,因为需要它通过指定顺序的回调函数去管理store
之间的依赖。Store
会声明式的等待其它store
完成更新后再相应的更新自己。
dispatcher
是官方 Flux 当中action
和store
的粘合剂。
Store
store
包含应用的state
和逻辑,作用类似于传统
MVC 中的Model
,但它管理着多个对象的状态。
store
将自己注册到dispatcher
,并提供一个接收action
作为参数的回调函数。在store
注册的回调函数中,将会通过基于action
类型的switch
判断语句进行解释操作,并为store
的内部方法提供适当的钩子函数。这允许action
通过dispatcher
对store
当中的state
进行更新。在这些store
被更新之后,会广播一个事件声明其状态已经被改变,从而让view
可以设置新的state
并更新自己。
View
在 React
嵌套视图层级结构的顶部,有一种特殊的视图可以监听其所依赖的store
广播的事件,这种视图称为控制器视图(controller-view)。因为它提供了从store
获取数据的粘合代码,并传递这些数据给子组件。
当控制器视图接收到来自store
的事件时,会首先通过store
公有的getter()
方法获取新数据,然后调用组件自身的setState()
或forceUpdate()
方法,使其本身以及子组件的render()
方法都得到调用。
通常会将store
上的全部state
传递给单个对象的视图链,允许不同的子组件按需进行使用。除了将控制器的行为保持在视图层次结构的顶部,从而让子视图尽可能地保持功能上的纯洁外;将存储在单个对象中的整个状态传递下来,也可以减少我们需要管理的props
的数量。
有些时候,可能需要向更深的视图层次结构添加额外的控制器视图以保持组件的简单,这可能有助于更好的封装与指定数据域相关的那部分视图层次结构。然而值得注意的是,这些更深层次的控制器视图会引入新的数据流入口,从而破坏单向数据流并引发各种奇怪的错误,而且难以 debug。因此,在决定添加更深层次控制器视图之前,需要仔细的进行权衡。
Action
dispatcher
会暴露一个接收action
的方法,去触发store
的调度,并包含数据的payload
([ˈpeɪləʊd]
n.有效载荷)。action
的建立可能会被封装到用于发送action
至dispatcher
的语义化的帮助函数(Action
Creator)当中。例如:我们需要改变to-do-list
应用中的 1
个to-do-item
,就需要在TodoActions
模块中建立签名为updateText(todoId, newText)
的函数,该函数能被视图组件里的事件处理器调用以响应用户交互。这个
Action Creator
方法还需要添加一个type
属性到action
,这样当action
在store
中被解释的时候可以被正确的响应。前面例子中,type
属性的值可以是TODO_UPDATE_TEXT
。
action
也可能来自其它地方,比如服务器。在组件数据初始化的过程中,或者服务器返回错误代码,以及服务器存在需要提供给应用程序的更新的时候。
Reflux
Reflux是一种 Flux
单向数据流体系架构的具体实现,主要由action
和store
组成,其中action
会在重新回到视图组件之前初始化新的数据并传递到store
,而视图组件只能通过发送action
去改变store
中的数据。
相当长一段时间里,开源社区普遍认为官方 Flux 又臭又长过于学院派,因此
Reflux 实现大幅精简 Flux
的各类晦涩概念(最大的变化是移除了dispatcher
),只保留如下
3 个主要概念:
- 建立 Action。
- 建立 Store。
- 连接 React 组件和 Store。
存在炫技的倾向,将简单概念复杂化解读是开发人员编写技术文档一个通病。
建立 Action
调用Reflux.createAction()
并传入可选的配置对象就能新建一个
Action,这个配置对象的可选项如下:
1 | { |
当然,建立 Action 时也可以缺省传入配置对象,如同下面代码中这样:
1 | let statusUpdate = Reflux.createAction(); |
Reflux 中 Action 是一个能够被其它函数所调用的普通函数式对象。
还可以调用Reflux.createActions([...])
一次性建立多个action
。
1 | // 现在Actions对象拥有了多个action |
Reflux 中的 Action 还可以使用子 Action
异步加载文件,进行preEmit
和shouldEmit
检查(Action
发出事件之前被调用),并拥有多个易于使用的快捷方式。
异步 Action 处理
正如上面所描述的,一个 Action
可以简单的通过myAction()
方式进行调用,如果sync
属性被设置为true
则
Action
是同步的,将会立刻通过myAction.trigger()
被执行;如是sync
设置为false
则是异步
Action,将会在 JavaScript 事件循环的下一个 tick
内通过myAction.triggerAsync()
执行,并且 Action
配置对象的children
属性可能会被设置。
1 | let Actions = Reflux.createActions([ |
Action 默认情况下是同步的,除非在配置对象内进行了其它配置,或者 Action 本身还包含了其它子 Action。
当需要通过子 Action 去执行诸如文件加载一类的的异步 Action,那么该 Action 需要监听自身然后去执行这个异步操作,当操作完成的时候调用其子 Action,下面代码简单体现了这一过程:
1 | let action = Reflux.createAction({ children: ["delayComplete"] }); |
建立 Store
Flux 建立store
的方式非常类似于 React
组件,通过继承Reflux.Store
对象就可以得到一个store
,该store
和组件一样都拥有一个可以通过setState()
方法进行更新的state
属性。
可以在store
对象的constructor()
方法内设置state
的初值,并使用listenTo()
设置指定action
的监听器。
1 | class StatusStore extends Reflux.Store { |
上面例子中名为statusUpdate
的 Action
被调用后,store
上的onStatusUpdate()
回调函数会传入
Action 上携带的参数。例如:Action
以statusUpdate(true)
方式调用,那么onStatusUpdate()
函数中的status
参数值就为true
。
Store 可以通过this.listenables()
方便的整合多个
Action,当一个 Action 对象或者一个 Action
对象的数组应用到该函数上,Reflux
可以根据命名约定自动添加监听器。只需要在action
名称之后重命名这些函数的名称,例如自动在actionName
前加上on
,使之变成
Store
中action
事件回调函数的名称onActionName()
。
1 | let Actions = Reflux.createActions(["firstAction", "secondAction"]); |
Flux 中的 Store 非常强大,甚至可以贡献出一个全局状态(正如 Redux 所倡导的那样),可以只对部分状态进行读取或设置,或是对全部状态进行时间旅行、调试。
连接 React 组件和 Store
建立 Action 和 Store 之后,最后一步就是将 Store 与 React 组件连接起来。
Reflux 中通过Reflux.Component
新建 React
组件,而不是继续使用React.Component
。由于Reflux.Component
底层实现上继承了React.Component
,因此两者功能和特性完全相同,唯一区别在于通过继承Reflux.Component
实现的组件能够设置store
并且从中获取state
。
1 | class MyComponent extends Reflux.Component { |
当组件挂载之后,将会建立一个新的StatusStore
的单例对象(如果没有),或是使用一个已经建立的单例对象(由使用这个
Store 的其它组件建立)。但是,这里还可以注意如下 2 点:
- 可以向
this.stores
传递一个store
数组来方便的设置多个 Store。 - 设置一个
this.storeKeys
数组来约束store
上的指定部分会被混入(mixin)到组件的state
。
1 | class MyComponent extends Reflux.Component { |
上面的例子中,将会混入StatusStore
和AnotherStore
中的state
,但是又由于this.storeKeys
只允许混入flag
和info
,因此其它属性(例如
type
属性)不会被混入,而type
在组件的构造方法内已经进行了赋值处理,否则render()
函数渲染时会因为获取不到type
属性的值而报错。
Reflux
以简单直接的方式整合store
到组件上,可以将所有store
聚合到一起,然后让组件有选择性的按需进行过滤。
虽然截止笔者成文为止,Reflux 的最后一次提交记录还停留在一年前的 2017 年 2 月,但是个人认为针对于中小型项目,Reflux 相对后续发展起来的 Redux 更加简单明了一些。
Redux
Redux可以认为是 Flux 思想的一种实现,两者在存在许多相同点的同时,也有诸多方面的异同。
Flux 和 Redux 都规定,将模型的更新逻辑放置在特定的逻辑层(Flux
里是store
,Redux 是reducer
)。Flux 和 Redux
都不允许直接修改store
,而是使用称为action
的普通
JavaScript 对象来对更改进行描述。两者不同之处在于,Redux
并没有dispatcher
概念,通过使用纯函数去代替 Flux
中的事件处理器。
Redux 实质上在官方 Flux 基础上增加了诸多细节,因此各类概念又臭又长的问题依然未有任何实质性改观(如上图),其提出的概念要比其实现的代码略多 ⊙﹏⊙‖。
基本原则
- 单一数据源,整个应用的
state
被储存在一棵对象树,并且这棵对象树只存在于唯一的store
当中。 - State
是只读的,修改
state
的唯一方法是触发action
,action
本质是一个用于描述发生事件的普通 JavaScript 对象。 - 使用纯函数
reducer
来执行修改,从而描述action
如何修改state
树。
Reducer
只是一些纯函数,它接收先前的state
和action
,并返回新的state
。刚开始你可以只有一个reducer
,随着应用变大,你可以把它拆成多个小的reducers
,分别独立地操作state
树的不同部分。
Action
Action
是把数据从应用(视图交互或服务器响应数据)传递到store
的有效载荷,是store
数据的唯一来源,通常需要通过store.dispatch()
将action
传递至store
。
Action 本质上是一个 JavaScript
普通对象,通常约定action
内必须使用一个字符串类型的type
字段来表示将要执行的动作。type
可以被简单的定义为字符串常量,应用规模较大的时候建议使用单独模块存放action
。除type
字段外,action
对象的结构完全由开发人员自行决定。当然也可以参照
Redux 社区制定的《Flux 标准 Action
规范》。
1 | { |
Action 创建函数(即 Flux 中的 Action
Creator)是生成action
的方法,Redux 中的Action
创建函数只是简单返回一个action
。
1 | function addTodo(text) { |
前面介绍的官方 Flux 实现当中,调用Action
创建函数将会触发dispatch
,参考下面的代码:
1 | function addTodoWithFlux(text) { |
Redux 当中,只需要将Action
创建函数的结果传递给dispatch()
即可发起一次dispatch
过程。
1 | // 将Redux创建函数直接传递给dispatch() |
虽然 Redux 的
store
里能直接调用store.dispatch()
,但是多数情况下会使用react-redux提供的connect()
来进行调用。bindActionCreators()
可以自动将多个 Action 创建函数绑定至dispatch()
。
Reducer
Reducer
用来描述如何根据action
对store
进行修改,是action
与store
的黏合剂(官方
Flux 中充当这一作用的是 dispatcher)。
Reducer
本质是一个纯函数,接收旧的state
和action
,返回新的state
。
1 | (previousState, action) => newState; |
一定要保持 Reducer 的纯净,只要传入参数相同,Reducer 返回的下一个
state
就一定相同(没有特殊情况、没有副作用,没有 API 请求、没有变量修改,单纯执行计算)。
下面的代码声明了todos
和todosFilter
两个reducer
。
1 | import { combineReducers } from "redux"; |
每个reducer
只负责管理全局state
中的一部分,其state
参数只对应它管理的那部分state
数据。
1 | // 合并Reducer,是上面代码的语法糖 |
伴随应用规模的膨胀,可能需要将reducer
拆分到不同的文件,
以保持其独立性并专门用于处理不同数据域。因此 Redux
提供了combineReducers()
工具方法来完成上面todoApp
的工作,从而实现简化代码的目的。
1 | // 合并Reducer,是上面代码的语法糖 |
combineReducers()
用来生成一个调用一系列自定义reducer
的函数,每个reducer
根据key
筛选出state
中的具体一部分数据并处理,最后这个生成函数会将所有reducer
的返回结果合并为一个大对象。
Store
Action用来描述发生的行为,Reducer用来根据action
更新state
。Store
则是两者联系的关键,Redux
应用只有一个单一的store
,当需要拆分数据处理逻辑时,应该组合使用reducer
,而非创建多个store
。
- 保存应用的
state
; - 提供
getState()
方法获取state
; - 提供
dispatch(action)
方法更新state
; - 通过
subscribe(listener)
注册监听器; - 使用
subscribe(listener)
返回的函数注销监听器。
1 | import { createStore } from "redux"; |
Middleware 与 Thunk
Redux
的Middleware可以提供action
发起之后,到达reducer
之前的扩展点,因此可以利用Middleware进行日志记录、创建崩溃报告、调用异步接口或路由等。
通过使用指定的Middleware(redux-thunk、redux-promise、redux-rx),Action
创建函数(Action
Creator)除了返回action
对象外还可以返回函数、Promise、Observable,这种情况下
Action 创建函数就被称为thunk([θʌŋk]
形实转换程序)。
Action
创建函数返回的函数会被redux-thunk
中间件执行,该函数不需要保持纯净,可以执行异步请求或者dispatch
一个或多个action
。
1 | import fetch from "isomorphic-fetch"; |
远程 API 请求通常需要发起 3 种异步 Action,分别用于通知
reducer
请求开始、成功、失败,可以考虑向action
添加一个status
字段来区分 3 种状态。
Redux
提供的createStore()
创建的store
只支持同步数据流,需要通过
Redux
的applyMiddleware()
方法应用redux-thunk
去支持异步数据流。
1 | import { createStore, applyMiddleware } from "redux"; |
连接 Redux 与 React
React 编写的 App 组件需要连接至
Redux,使其能够dispatch actions
以及从Redux store
读取state
。
首先,需要通过react-redux提供的<Provider>
包裹应用的根组件,使得store
可以被根组件下的所有子级组件访问(通过
React 的 context 特性实现)。
1 | import React from "react"; |
然后,通过react-redux提供的connect()
方法将包装好的组件连接至
Redux。尽量只做一个顶层的组件,或者route
处理。
从connect()
包装的组件可以得到一个dispatch()
作为组件的props
,以及获取全局state
中所需的任意内容。connect()
方法的唯一参数是selector
,该方法从store
接收到全局的state
,然后返回组件中需要的
props
。
1 | import { |
Mobx
Mobx 是最新的 React 状态管理解决方案,其设计思想大量借鉴自 Vuex,能够方便的对状态进行自动更新,并且提供了十分好用的装饰器语法糖。
首先,通过下面语句,安装 Mobx 及其绑定库、装饰器语法支持。
1 | ➜ npm install mobx --save |
然后,修改.babelrc
打开装饰器语法支持:
1 | { |
如果使用 VSCode 编辑器,启用 Mobx 装饰器语法支持后,需要添加如下设置项,开启语法支持避免编辑器出现错误提示。
1 | { |
基本概念
- State:状态,驱动 Mobx 应用程序的数据。
- Action:动作,一段可以改变状态 State 的代码,例如:用户事件、后端推送等。严格模式下,MobX 强制只能使用 Action 修改 State。
- Derivation:[deri'veiʃən]
推导,衍生,源自状态并且不会再有进一步的相互作用,MobX
将衍生分为如下两种类型:
- Reaction:反应,指 State 状态发生改变时自动产生的变化,在 MobX 当中较为常用。
- Computed values:计算值,通过纯函数监听可观察的 State,并根据这些 State 的变化重新来计算新值。
简单例子
1 | import { observable, autorun } from "mobx"; |
结合 React 使用
1 | import { observable } from "mobx"; |
计算值 computed
1 | import { observable, computed } from "mobx"; |
Mobx 相对于 Redux,最大的进步在于将 Redux 当中繁琐的 Reducer 声明进行了简化,通过类似于 VueX 的显式双向绑定方式,实时将需要更新的值反映至相应组件,是一款现代化 Flux 框架的正确打开方式。
Vuex
Vuex 是由 Vue 前端生态圈提出的一种应用状态管理库,能够与 Vue
无缝进行集成。如果关闭其严格模式,则不需要再书写繁琐的
Mutation(类似于 Redux 中 Reducer 的作用),而直接将 Vuex
的store
与 Vue
组件的data
进行响应式绑定,这个对于单张页面内多组件间存在大量交互数据的场景非常有用。
1 | const appStore = new Vuex.Store({ |
State
和 React 生态圈下的 Flux 解决方案一样,Vuex
同样通过一个状态对象管理全部的应用状态,换而言之,每个应用将仅包含一个Store
实例。
1 | const store = new Vuex.Store({ |
Getter
Vuex
允许在Store
定义getter
,其作用类似于前面提到的
Mobx
的@computed
计算属性。getter
返回值会根据其所依赖的状态进行缓存,只有该依赖状态发生了变化的情况下才会重新计算。
1 | const store = new Vuex.Store({ |
Mutation
开启严格模式的情况下,Vuex
更改Store
中的状态的唯一方法是提交mutation
,其作用和用法类似于
Redux 中的reducer
。
1 | const store = new Vuex.Store({ |
Action
类 Flux 框架,总是少不了 Action 的存在,Vuex
中action
与mutation
的不同之处在于:action
只能提交mutation
,而不能直接对store
进行修改,副作用的操作(例如异步的请求)可以书写在action
当中。
1 | const store = new Vuex.Store({ |
Module
在应用较为复杂的场景下,单一的Store
对象可能变得非常臃肿,因此有必要通过模块化进行更细粒度的划分。Vuex
提出的模块化Store
为此提供了非常良好的体验,允许将Store
分割为模块(module),每个模块拥有自己的state
、mutation
、action
、getter
甚至嵌套的子模块。
1 | const module1 = { |
结构良好的 Vuex 项目
添加 Vuex
热重载与模块化支持,并将嵌套的子Store
解耦至子组件的代码目录,便于查看与管理。
1 | import Vue from "vue"; |
嵌套的子Store
中需要使用namespaced: true,
开启模块命名空间,所有getter
、action
、mutation
都会根据模块注册的路径调整命名。
1 | export default { |
除此之外,Vuex 提供的一系列 mapper 语法糖能够便捷的融合 Vuex 的各类特性,有着良好的书写体验与开发效率。整体来看,Vuex 是过去诸多 Flux 框架实现的集大成者,对 React 生态下 Mobx 的开发有着非常深刻的影响,堪称现代化 Flux 框架实现的典范。
FSA
Redux 社区制订了《Flux
Standard
Action》规范,这是一套人机友好并且较为通用的Action
对象定义规范,下面代码是一个满足FSA标准的
Action 对象。
1 | { |
诸多 Flux 实现在处理异步队列时,往往会增加
FETCH_SUCCESS
和FETCH_FAILURE
两个 Action 类型,这样的方式其实并不理想,因为它重载了 2 个独立的关注点:标识 action 是否需要表达错误、从全局 action 队列中消除某一类型 Action 的歧义,而在 FSA 当中会将error
视为头等概念。
FSA 标准主要为了达成如下 3 个设计目标:
- 简单:对象结构简单、直接、灵活。
- 人性化:便于开发人员编写和阅读。
- 有效:Action 该能够创建有用的工具和进行抽象。
type
action
的type
属性用于指明发生动作的性质,通常是一个字符串常量。如果两种类型是相同的,那么其type
属性必须(通过===
)严格等价。
payload
可选的payload
属性用于表示动作的有效载荷,可以是任何类型的值。任何非表达类型或状态的
Action
相关信息,都应该是payload
的一部分。如果error
属性为true
,那么payload
属性应该是一个错误对象,类似于拒绝一个带有错误对象的
Promise。
error
可选的error
属性用于在action
出现错误的时候被设置为true
,主要起到一个错误标志位的作用。因为根据上面的约定,错误对象本身需要放置到payload
属性上。如果error
属性拥有除true
之外的其它值(undefined
或null
),则action
并不能被解析为错误。
meta
可选的meta
元属性可以是任何类型的值,主要用于放置一些额外的信息,并非有效载荷payload
的一部分。
FSA 提供的工具函数
FSA 提供了flux-standard-action项目,可以通过下面命令安装:
1 | npm i flux-standard-action --save |
该 npm 包提供了如下 2 个辅助函数:
isFSA(action)
:判断action
是否满足 FSA 标准。isError(action)
:action
出现错误的时候返回true
。
redux-actions、redux-promise、redux-rx等第三方库都遵循了 FSA 规范。
Flux 数据流两三事儿