About the Speaker: Benjamin Encz
Benjamin Encz 是 PlanGrid 的一名工程师。他自 2011 年起就开始在 iOS 平台上开发了。最近,他专注于探索如何优化软件质量以及优化软件开发。今年,他已经发表了多个关于函数式编程和软件架构的演讲。
@benjaminencz Website
大家好,我是 Benjamin Encz,在 San Francisco 的 PlanGrid 工作。今天我想要谈论的是 Swift 中的单向数据流架构,我过去这几周一直在研究它。除此之外,我也会谈论一下 MVC 的终结。我们一直以来都用着 MVC,但是在过去的几年中,我在既有的、不断增长的代码库中见到了不少问题,通常这些问题都是由 Apple 版本的 MVC 所导致的。我希望,即使您是 MVC 的狂热爱好者,我也能够给您提供一个能在 iOS 中使用的 MVC 替代品。
首先,让我们先回到 1979 年,也就是 MVC 理念刚提出的时候。当时,人们正在探索图形用户界面应该长什么样子。人们通过终端在电脑上工作了很长一段时间后,最后他们发明出了我们现在所熟知的经典图形用户界面。
他们所做的工作中,不仅包括了解决如何搭建操作系统,还包括了告诉开发者该如何为系统编写应用。其中一个问题是,我们应该如何在屏幕上展示信息,以便用户能够直观地进行操作。一位来自挪威的研究员 Trygve Reenskaug 在 Xerox PARC 参观了一年,随后发明了 MVC 架构来解决让电脑中的数据能够给用户访问的问题。实际上,他是从用户的角度出发来解决这个问题的,而不是以开发者的角度来解决的。
相反,我觉得 MVC 并不应该成为一个架构,因为它并没有告诉开发者如何开发出一个直观的用户界面。MVC 的核心问题在于,对于同一个模型来说可能会有多种用户交互的方式。作为一个架构,MVC 的定义比较模糊。然而,对于我们 Cocoa 开发者来说,最重要的其实是 Apple 怎么理解 MVC 的,以及他们是怎么在框架和核心视图控制器中使用这项技术的。视图控制器和视图以及模型相互传递数据,视图中执行操作、传递模型、等待响应,随后更新视图。这样一来,很容易让视图控制器中加入很多的应用逻辑。
MVC 并没有解决现在 iOS 和 Mac 应用开发过程中会遇到的许多问题,比如说处理复杂的 UI、持久化存储、网络以及导航等等。MVC 实际上并不是一个功能完整的应用架构,但是我们有些时候会这么认为。我认为 MVC 模型应该用在视图层 (view layer) 上,因为将视图从模型中分离出来绝对是一个非常好的主意,然而对于整个应用来说 MVC 并不是一个好的架构方式。
我确认了两个关于视图控制器的具体问题,一个是事必躬亲(micromanagers),另一个是复杂的应用状态管理。
视图控制器是应用的核心所在,并且它常常会与应用中的各个单独部分进行交互,因此控制器很容易就变成一个鸡毛蒜皮小事都管的妈妈桑。对于初学者来说,他们很难理清视图控制器和模型、视图层之间的职责所在。然而,即使你擅长移除视图控制器中多余的代码,视图控制器仍然需要将所有这些不同的调用模块联系在一起。控制器需要与视图层进行交互,必须要等待用户回应,必须要知道回应的涵义,并且它们还需要知道这个回应对哪些视图有效。
即使你很擅长设计有表现力的 API,那么你仍可能会在视图控制器中写出这样一个方法:
func userLoggedInWithUsername(username: String, password: String) { apiClient.authenticateUser(username, password: password) { response, error in if (error == nil) { let nextViewController = ... navigationController.pushViewController(nextViewController) } else { showErrorMessage(error) } } }
这行代码是一个回调函数,当用户输入用户名和密码后,就会从视图中调用这个函数。在这里,您希望实现用户登录操作。您可以使用用户名和密码,然后与你模型层中的某个东西(类似于 apiClient
)进行交互。你调用其中的 authenticateUser()
函数,传入用户名和密码,然后在控制器中等待回调。就基本情况而言,这就是我们通常的实现方式。
这意味着你的控制器必须要知道这个 API 调用所可能具有的网络回应是什么,还必须要知道这些回应对每个视图会造成什么样的影响。我们必须要在这里执行错误处理,我们还必须基于这些操作展示或者隐藏不同的视图。这意味着这个视图控制器已经涉及到了你应用中的不同领域,即使你将代码分离开来,你仍然会在您的控制器中得到一大堆的“胶水代码 (glue code)”。
第二个更重要的问题就是状态管理 (state management) 了。让我们想想应用中有哪些地方使用了所谓的状态呢,我觉得大概有四类:
前两个状态是基于视图堆栈 (view stack) 进行管理的。我们在拥有核心逻辑的数据库中也会使用状态。有些时候,在一些外部单例中也会有状态的存在,而这正是 MVC 框架中缺乏状态管理机制所带来的问题。
通过这个结构,我们会遇到很多问题,有很多人问我“我如何在视图控制器之间传递信息呢?”。你并不会想要将这些中间数据写到数据库当中,例如网络请求结束之前的注册详情信息。中间数据的累积会导致单例的滥用以及其他问题的发生。这种问题通常是由于多个状态副本导致,并且如果你允许应用来回导航的话,那么你必须要确保在最新的视图控制器的状态也是最新的,这样才能够确保状态在其他视图控制器中可用。在你的所有控制器中,通过委托、回调等进行状态的维护,很可能让代码变得杂乱不堪。
第一个问题是,视图控制器需要知晓详细的业务逻辑信息。它们必须切实知晓,在应用中各种为不同操作而定义的回应类型里面,我可以使用哪些;以及,这些回应是如何对视图产生影响的。视图控制器同样需要管理一大堆状态——几乎所有不在数据库中出现的状态都会在视图控制器中出现。这显然是一个非常复杂的工作,因为同样还存在各种类型的中间状态,你不会想存储这些中间状态的,然而你又希望能够将其暂存起来,直到这些数据被放到数据库中,或者发送到服务器上。这种通信请求状态往往会导致视图控制器中产生大量的额外代码。
状态管理和状态传递发生在 Ad-hoc 阶段,因此你必须要决定在回调与视图控制器之间,还是在委托与视图控制器之间,亦或者是在其他方法与视图控制器之间,传递这些状态。如果你此前搭建过 iOS 应用的话,你可能已经见识到了当你从导航栈 (navigation stack) 中回跳的时候,因为状态的过时对旧控制器所带来的麻烦,因为 MVC 很难正确地进行状态管理。
最后,为这些臃肿的视图控制器创建一个用以定义应用如何工作的模型是非常难的,同样也是非常愚蠢的。当我开始学习 Web 应用的时候,我完完全全对这些代码是非常生疏的,但是我可以通过阅读 API 接口,了解 Web 应用支持哪些操作,应用流程路线是什么,在 HTTP 请求回应中能够调用哪些方法。在 iOS 应用中,这些东西都隐藏在视图控制器内部。要深入理解这些特性这非常困难的。说了这么多,现在是时候换成代码了。几周以前,我的一个做 Web 开发的朋友给我介绍了一个名为 Redux 的框架。
Redux 是 Facebook 开发的 flux framework 框架 的一个替代品,或者说是一个变种,而 Facebook 现如今的绝大多数 Web 应用都在使用着 Flux 框架。它最核心的理念是“信息流是单向流动的”。我们不会在分离的视图控制器之间传递信息,我们也并不会使用委托回调方法。信息流将会以一个非常明确的方式构建并配置。
关于学习 Redux,有两个全新的重要概念。第一个是 状态 (state) ,它实际上是一种数据结构。你只需要使用一种数据结构就可以定义整个应用的状态,包括 UI 状态、以及所有你在应用中使用的模型状态。这个状态被保存在 缓存 (store) 当中,接着应用中的视图或者其他监听者就可以在每次整个应用中的状态发生更新时立刻得到通知。因此,只要状态发生了改变, 视图 (views) 都能够得到最近的应用状态,然后基于此状态对其表现进行更新。除非有 动作 (actions) 发送,否则的话在这条单向传递链上的状态都不会发生变化。状态一般是不可修改的,你要触发状态变化的唯一途径就是向缓存中发送一条动作。
这些动作描述了你想要执行的变化。例如,删除用户、添加用户、改变用户名。这其中还有一些组件用来完成整个步骤:Reducer 和 Observer。 Reducer 是一段实际执行状态变化的代码。当某个动作进入到缓存中的时候,它会将自身发送给所有的 Reducer,每个 Reducer 都会获取当前的应用状态以及该动作,然后依据该动作对应用状态进行改变。这些各种各样的 Reducer 分别负责你应用状态的某一层。例如,其中一个用以处理用户,另外一个用于处理某个特定视图控制器的视图状态,等等。
(State, Action) -> State
这些动作将会进入到产生新状态的 Reducer 当中。这个新状态随后会被提交到所有的视图以及其他 Observer 当中。在大多数情况下, Observer 通常是视图对象,但是你实际上也可以自定义。例如,你可以让一个实现分析的对象变为 Observer。这样,你就可以让分析代码变为一个单独的组件,用以监听对应的动作,而不是放到视图控制器里面。
关于 Reducer 的一个重要方面就是,它们是纯粹的函数,我们随后会好好谈论一下为什么这点非常重要。本质上,它们可以获取当前的应用状态,获取动作,随后它们会基于动作的描述查看这个动作的行为,从而计算出新的状态并将这个状态返回。除此之外它们就没有执行其他操作了。我们再次总结一遍,动作实际上是某个状态变化的公开描述。所以动作并不是方法,它们当中不存在任何代码,它们只是一行消息,通知 Reducer 应该执行何种状态变化。
为了让 Redux 不那么抽象,我在这将给大家展示一个我搭建的开源框架的部分代码。这个框架的名字是 SwiftFlow,它将 Redux 带到了 iOS 当中。我想基于一个简单的示例应用:计数器,给大家展示一下 SwiftFlow 的用法。计数器应用允许你能够增加以及减少计数,此外它还拥有一个导航栏。
我们如何通过这个框架来搭建应用呢?首先让我们先看一下应用状态的模样。应用状态实际上是一个数据结构,并且当它从缓存中读取出来的时候,它应该是一个不可修改的结构体,同时也是一个独一无二的状态副本。
struct AppState: StateType, HasNavigationState { var counter: Int = 0 var navigationState = NavigationState() }
这个状态中最重要的两个组件就是业务逻辑和导航状态了。这个应用的具体业务逻辑相当简单,就是一个计数变量,我们在初始状态下将其设置为 0 即可。在更复杂的应用当中,你可能会有成千上万个变量。第二,由于这个应用支持导航栏,因此我们拥有一个 Tab Bar 控制器,我实际上建立了一个名为 SwiftFlow router 的组件,它专门为这个框架而建,允许你通过这个单向的数据流来使用导航。因此,我们不再直接展示视图控制器,实际上应该是发送改变应用路线的一个动作,然后就会展示回应中定义的视图控制器。这项功能在一个分离的组件中定义的,并且提供该导航状态的组件也是分离开来的。
通过向你的数据结构中加入此导航状态,你现在就可以使用我编写好的 Router 了。你可以在 这个文档 中读到更详细的内容。最后一件我想指出的事情是,该结构体实现了两个协议,第一个是 StateType
协议,这只是一个标记而已;第二个是 HasNavigationState
。这里的理念是 Router 实际上并不切实知道我们应用状态结构是什么样的——我们可以选择任何状态来加入此导航状态,但是 Router 必须知道我们的应用支持导航状态。
因此,实现这个协议需要我们在状态里面加入一个变量,就是 navigationState
。Router 可以在其中存储导航状态,允许我们无需知道应用的确切模样,就可以构建可注入的外部组件,以及与我们的应用状态协同工作。
这里列出了一个非常简单的视图控制器:
func newState(state: AppState) { counterLabel.text = "/(state.counter)" } @IBAction func increaseButtonTapped(sender: UIButton) { mainStore.dispatch( Action(CounterActionIncrease) ) } @IBAction func decreaseButtonTapped(sender: UIButton) { mainStore.dispatch( Action(CounterActionDecrease) ) }
第一行是一个方法,当你监听缓存的时候它就会被调用。只要你需要监听缓存,那么你就必须实现这个 newState 方法,当每次应用状态更新时,它都会被回调。至于参数,你可以使用最新的应用状态,这里它的责任就是更新视图,以便对发生的变化做出反应。在这个简单的应用当中,我们所做的就是设置计数标签的文本,将其值设置为最新值。
对于改变应用状态,我们有两种情况,那就是当我们分别按下增加或者减少按钮的时候,在这种情况下我们就在调度队列中发送动作。我们通过访问缓存,调用调度方法,然后在其中传递动作。我们再说一遍,动作只是状态改变的描述。这些动作都会被发送到缓存当中,在那里有一个用来处理的 Reducer。
Reducer 非常、非常简单:
struct CounterReducer: Reducer { func handleAction(state: AppState, action: Action) -> AppState { var state = state switch action.type { case CounterActionIncrease: state.counter += 1 case CounterActionDecrease: state.counter -= 1 default: break } return state } }
它实现了 Reducer
协议,该协议需要其实现 handleAction()
函数。在 handleAction()
当中,它将获取最新的应用状态,并且对相应的动作进行处理。这里我们简单的用 switch 对动作类型进行判断,然后分别执行相应的操作,对状态进行改变。如果你有更为复杂的 Reducer 的话,你不应该直接在 case 语句中直接调用方法或者改变状态,你应该调用一个通用方法来进行处理,不过其他思路大致也差不多。如果我们要增加计数,那么我们给 counter + 1 即可,如果我们要减少计数,给 counter - 1 就好。最后,我们就给应用返回最新的状态。
所有这些代码其实不是很让人兴奋,但是它有一个非常、非常精妙的作用。通过使用这种架构,如果你整个应用程序实际只拥有一个数据源,并且只能够通过一个接口进行修改的话,那么你可以通过你所拥有的应用状态实现状态回滚,形象点称为“时间旅行”。这样,就可以让 UI 回到之前的状态。你可以想象,这些状态和状态转换非常适用于状态管理。
作为框架的一部分,我设立了一个单独的缓存,你可以将其加入到应用当中,这个缓存用来记录用户所有的动作,从而支持动作回滚。这个缓存允许我能够通过导航栈实现状态回滚功能。我在这里并没有对状态转换添加动画,但是要实现的话也是非常简单的。这使得实现状态恢复和寻找崩溃变得简单,因为你可以回滚到造成应用崩溃的状态当中。
这里实现状态回滚的代码非常简洁,但同时也比真实的应用要简单。我想要指出一些问题。第一个是你不能只用一个数据结构存储整个应用状态。我们只在一个结构中存储整个应用的信息似乎是非常荒谬的。要处理这种情况,有两种不同的策略:
第一个是你可以将状态划分为不同的子状态。我已经在导航状态中将其指出来了,其本身就是注入到我们应用状态中的一个结构。但是你可以自己创建状态结构,因此应用状态就可能会变成许多子状态的集合。一个复杂应用的状态结构可能会有 50 行代码之多,这是可以理解的。
第二个就是在状态内部,你实际上做的只是存储不能被导出的信息。例如,对于 Router 来说我们不想存储视图控制器。因此我们不必将其序列化并将其存储到缓存当中,我们只需要存储能允许我们重新创建视图控制器的信息就可以了,确切来说就是存储之前我们使用的那些配置。Router 可以是一个独一无二的字符串,就像浏览器中的 URL 一样,描述视图控制器的顺序。接着我们的外部系统可以获取到最新的状态,然后从 Router 中进行重现。因此,任何可以被导出、被重建的东西都不应该直接将其放在缓存当中。例如恢复图像,我们所要做的就是获取一个下载链接,或者从我们的文件系统中获取缓存内容。这可以减少状态中的冗余。
为了处理异步的网络请求,我们要使用 动作-创建者 (action-creator) 的概念。在一个简单的例子中,例如计数示例,只要一个状态发生变化,就立即在调度队列中执行动作。然而,有些时候状态变化可能会被推迟,比如说网络请求等等。动作-创建者是一个用以执行某种操作的方法,可以是异步也可以不是异步的,并且在最后,决定是否在调度队列中执行动作。我们所能做的就是执行此网络请求,然后等待网络回应返回,接着我们再从缓存中拿出动作并执行。
借助这些观念,我在一个真实的应用中实际操作了一下,它使用 Twitter API 进行搜索,它是开源的你可以 在这里 找到它。我可以使用应用状态回到我的搜索记录当中,就像状态回滚一样,恢复我之前检索的文本、之前检索的结果,以及恢复导航栈。这表明,只要状态配置正确,那么通过 Router 对诸如图像处理之类的东西进行网络请求操作就是可行的。
然而,在 iOS 中使用 Redux 还面临着不少的挑战。第一个就是有些时候 UIKit 会让用这种方法进行协作变得困难。例如,例子中的 Router 想要在每次你想改变展示的视图控制器的时候,会先执行动作,而不是直接展示视图控制器。当你使用诸如 Tab Bar 视图控制器之类组件的时候这往往会造成很大的问题。Tab Bar 视图控制器会在点击的时候立即改变当前活跃的视图控制器,而不是使用调度队列执行动作。我在此所需要做的就是找到此 Tab Bar 视图控制器的委托,告诉它不应该显示视图控制器,而是先操作调度队列中的动作,等待动作完成后再来实际触发展示。在解决这个框架带来的问题过程当中,我们有很多工作要做,不过我认为目前我所做的大家都是可以接受的。
第二个就是编码 (encoding) 和解码 (decoding) 了。这个功能只有在你想要将你的状态存储到硬盘,并打算执行热加载 (hot reloading) 的时候才有必要,通过这个功能你可以在应用重启之后,迅速回到上一个保存的状态当中。为了实现这个功能,每个动作都必须要序列化为某种数据类型,以便能够存储在硬盘上。我选择了 JSON 来实现此功能,这意味着在内部,这些动作实际上是无类型的。它们实际上是一个字符串类型,并且拥有一个用来存储 JSON 元素的容器。我在这里为搭建出一个好的 API 奋战了好久,现在我认为,它工作得非常棒。现在你可以在外面使用带有类型的动作,它们会在存储到硬盘前,被转换为简单的无类型动作。
不幸的是,目前你仍然必须要自己写转换代码。我打算为此提供一些代码来避免样板代码的出现,以及手写序列化和反序列化的痛苦。
最后,我们需要限制全局状态的访问。这是因为在这个架构中,每个监听缓存的组件都会获取到整个最新的应用状态。实现此功能的一种方法是为状态增加协议,只有通过这个特定协议定义的方法才能够访问应用状态,这只会暴露整个状态中的一小部分。例如,这也正是 Router 所做的工作。Router 拥有导航栏状态协议,他通过这个协议来查看应用状态,它只能够看到整个状态中的一个入口。它完全不知道你目前已积累的其他状态。
这里的最佳策略就是为所有不同的子状态都定义一个协议,从而让这些组件只允许看到它们特定的子状态。这可以帮助您避免在其他视图控制器中的状态,或者其他全局状态中构建依赖。我同样也在文档中对此进行了提及。
总而言之,这就是为什么我认为,Swift Flow 框架非常值得大家去尝试。首先,它拥有一个很好的 功能分离理念 ,这对构建大规模的软件来说是非常重要的。这是因为现在,视图在这个架构中拥有了一个非常清晰的作用。首先,它们将会获取一个状态,然后它们必须要在屏幕上自己去表现出这个最新的状态。通过一个简单的接口—— newState()
方法来完成这项操作,与此同时这也是你唯一可以基于最新的应用状态来改变视图状态的地方。没有多余的回调,也没有多余的需要时刻跟踪的状态变量。
除此之外,视图控制器就不必再去监听网络回应,它们无需知道你 API 返回过来的错误类型是什么,而是控制器发起当前动作,然后等待获取到最新的应用状态,除非得到网络回应,否则的话你发起的这个动作是不会被执行的。因此,你的视图控制器不再需要等待回调,它们只需要发起一个动作,然后这个动作就会静默下来,直到有新的状态出现,这样才会将这个动作展示出来。如果你想要展示错误信息的话,你只需要将其注入到状态对象当中,接着在你的 newState()
函数中添加一段简单的代码,即:如果错误发生,那么就显示该视图。视图的更新和动作的发生因此得以很好的分离。
我们同样也有 解耦的目的和行为 。动作仅仅只是目的而已,它们没有任何代码存在。例如,在一个动作当中,我们将删除用户信息的动作改变为更新用户信息;这个动作实际上没有任何代码存在于其中,这正是我们架构的强大之处。它允许我们在实际行为发生后,再来扩展该目的的实际含义。因此就无需再去所有调用此方法的地方,然后再去修改实现,我们现在拥有了不同的行为和不同的 Reducer,它们可以基于这个方法响应同样的动作,以及对我们的应用进行完善。
例如,如果您想要为从数据库中删除的每个实体添加分析功能,或者您其添加一个日志实体,那么你只需要向应用中添加另一个响应所有删除操作的 Reducer 即可。你无需让整个应用知晓这个特性,也无需修改其他已有的代码。
通过这个结构你也可以获得一个 干净、清晰的 API 。您的每一个动作描述了每个应用状态改变的单独方式。如果你也允许使用模式的话,就不会产生导致任何副作用的函数了,也没有别的地方可以让别人向代码中注入别的东西,从而使代码追踪变得难以进行——因为别人看到的只是一系列动作而已。这些动作有多少并不重要,它们同样还清晰地描述了哪种修改是允许发生的。
如果你想要知道这些动作是如何被响应的,那么你只能到 Reducer 中进行查看。这种方式 可以以可预见的、明确的状态完成操作 。如果在此时问你当前应用的状态是什么,你可能会毫无头绪。通过这个架构,你实际上将获得数据结构,你可以将其打印在控制台中,这样你就可以明确看到当前应用状态是什么样的了。
此外,通过明确的应用状态和描述变化的动作, 我们的程序现在就拥有了一个雏形 。现在,如果我的团队中来了一个新的开发者,他们想要看看现有的特性都有些什么,那么他们只需要查看应用状态、Reducer 以及动作列表即可,他们就无需深入到几百个视图控制器中去查找了。
最后,我认为也是非常重要的,就是我们可以 轻松的将状态进行传递 。我们无需考虑回调、委托或者需要手动更新的中间拷贝 (intermadiate copies) 状态之类的东西。我们以一个非常简单的模式,跟随这个信息流,就可以没有任何意外的执行系统的行为了。
我想要感谢我的前同事 Gerald Monaco ( @devknoll ),他向我介绍了 Redux。我同样要感谢 Dan Abramov ( @dan_abramov ),他是 Redux 的发明者,Redux 给我所有的 SwiftFlow 代码带来了灵感。最后,感谢 Thoughtbot 的 Jake Craige ( @jakecraige ),他给我的这个实现提供了不少的反馈。
问:我们在一个 Android 应用中使用了类似的架构,然后在动画展示方面遇到了很多问题。您对此有什么看法吗?
Benji:我使用 Router 结构,通过阻塞线程然后等待完成闭包被调用来实现动画展示。我会将这个完成闭包放入到整个 Router 系统中,随后展示视图,当动画完成之后,再调用这个完成闭包。在此完成之前,Router 中将不会有其他动作进行。
我将动画像一系列动作一样将他们在队列中排列起来。如果你触发了动画动作,那么随后会花费几秒钟的时间等待这些动作加载,然后将其余的动作添加到队列的末尾,因此要让动画状态基本赶上你执行的动作,需要一点点时间进行等待。动画状态实际上滞后于真实的应用内部状态。
问:当改变很小时,对整个视图控制器层级进行状态更新会带来性能影响吗?
Benji:迄今为止,我忽略了绝大多数的性能问题,我认为这对 App Store 上很多应用来说都是很实际的。实际发生的状态变化量通常是比较小的。此外,视图控制器是否响应状态更新取决于它们是否在屏幕上显示,因此实际上,在你的导航栏堆栈中,可能只有三到四个视图控制器在实际对状态进行着监听。我决定先把这个架构干净地设计出来,随后再对其性能优化进行配置。
问:ReactJS 使用多个缓存来与它们自己的组件进行交互,而 Redux 使用一个巨大的缓存来实现此功能,并且这个缓存是通过协议暴露了外观 (Facade),这样做有什么好处呢?
Benji:Redux 旨在减少 Flux 因多个缓存导致的某些复杂性。很多开发者意识到,一旦用户进行的更新量非常大,多个缓存往往会导致缓存与用户之间建立复杂的依赖关系,而这反而是人们应当尽力避免的。在 Flux 应用中,面对三四个不同的缓存,你需要等待不同的动作发生,某些时候你可能会惊愕地发现这些缓存被阻塞的时间比预期的药厂很多,这使得大家对信息流的理解变得更为复杂。Redux 的想法是将其全部放在一个缓存中,为系统简化整个信息流。
问:在使用多个彼此依赖的 Reducer 的时候,您有遇到过相同的问题吗?
Benji:Reducer 不能够互相建立依赖;它们的执行都是同步的。所有的异步动作都发生在这个系统的外部,因此 Reducer 通常在几毫秒内就完成运行,没有任何的依赖或者是等待发生。
See the discussion on Hacker News .
Sign up to be notified of new videos — we won’t email you for any other reason, ever.