实现更好的Redux架构的10个技巧


当我开始使用React时,没有Redux。只有Flux架构,以及它的十几个竞争实现。
现在React中有两个明显的数据管理赢家:Redux和MobX,后者甚至不是Flux实现。Redux已经流行起来,不仅仅是用于React了。您可以找到其他框架的Redux架构实现,包括Angular 2. 例如,请参阅ngrx:store

旁注:MobX很酷,我可能会选择Redux来处理简单的UI,因为它不那么复杂,也不那么冗长。也就是说,MobX没有为您提供Redux的一些重要功能,在您确定适合您项目的功能之前了解这些功能非常重要。

说明:RelayFalcor是状态管理的其他有趣解决方案,但与Redux和MobX不同,它们必须分别由GraphQL和Falcor Server支持,并且所有中继状态对应于一些服务器持久数据。AFAIK既没有为客户端的瞬态状态管理提供好的故事。通过将Relay或Falcor与Redux或MobX混合和匹配,您可以享受两者的好处,区分仅客户端状态和服务器持久状态。一句话:今天客户端的状态管理层没有明显的单一赢家。使用正确的工具完成手头的工作。

以下是可帮助您构建更好的Redux应用程序的提示。

1.了解Redux的好处
您需要牢记Redux的几个重要目标:

  1. 确定性视图渲染
  2. 确定性的状态再产生

确定性对于应用程序可测试性以及诊断和修复错误非常重要。如果您的应用程序视图和状态是不确定的,则无法知道视图和状态是否始终有效。你甚至可以说非确定性本身就是一个错误。

但有些事情本质上是不确定的。比如用户输入和网络I / O的时间。那么我们怎么能知道我们的代码是否真的有用呢?很简单:隔离。

Redux的主要目的是将状态管理与I / O副作用隔离开来,例如渲染视图或使用网络。当副作用被隔离时,代码变得更加简单。当所有的网络请求和DOM更新不是纠缠不清时,理解和测试业务逻辑要容易得多。

当您的视图渲染与网络I / O和状态更新隔离时,您可以实现确定性视图渲染,这意味着:给定相同的状态,视图将始终呈现相同的输出。它消除了诸如竞争条件等问题的可能性,即异步内容会随机消除视图中的数据,或者在视图处于渲染过程中时破坏状态数据。

当一个新手考虑创建一个视图时,他们可能会想,“这里需要用户模型,所以我将启动一个异步请求来获取,当这个promise被解析时,我将用他们的名字更新用户组件。还需要待办事项,所以我们将获取,当promise完成时,我们将循环获取结果并将它们绘制到屏幕上。“

这种方法存在一些主要问题:

  1. 您永远不会拥有在任何给定时刻呈现完整视图所需的所有数据。在组件开始执行其操作之前,您实际上并未开始获取数据。
  2. 不同的获取任务可以在不同的时间进入,巧妙地改变视图渲染序列中发生的事情的顺序。要真正理解渲染序列,您必须了解无法预测的内容:每个异步请求的持续时间。流行测验:在上面的场景中,首先获取到的是用户组件还是待办事项?答:这是一场比赛!
  3. 有时,事件侦听器会改变视图状态,这可能会触发另一个渲染,从而使序列更加复杂。

将数据存储在视图状态,并为异步事件侦听器提供访问以改变该视图状态的关键问题是:

“非确定性=并行处理+共享状态” ~Martin Odersky(Scala设计师)

混合数据提取,数据处理和视图渲染三个问题,将形成时间维度上的意大利面(纠缠不清)。

flux体系结构的作用是强制执行严格的分离和序列,每次遵循这些规则:

  1. 首先,我们进入一个已知的固定状态......
  2. 然后我们渲染视图。没有什么可以为此渲染循环再次更改状态。
  3. 给定相同的状态,视图将始终以相同的方式呈现。
  4. 事件侦听器侦听用户输入和网络请求处理程序。当他们得到它们时,就会将行动发送到存储。
  5. 调度动作时,状态将更新为新的已知状态,并重复该序列。只有派出的行动才能引发状态。

简而言之,这就是Flux:用于UI的单向数据流架构。

使用Flux体系结构,视图会侦听用户输入,将其转换为操作对象,并将其分派到存储store。存储更新应用程序状态并通知视图再次呈现。当然,视图很少是输入和事件的唯一来源,但这没有问题。其他事件侦听器调度操作对象,就像视图一样:
重要的是,Flux中的状态更新是事务性的。操作对象不是简单地在状态上调用更新方法,而是直接操作值,而是调度到存储。操作对象是事务记录。您可以将其视为银行交易 - 即要进行更改的记录。当您向银行存款时,5分钟前的余额不会被清除。而是将新余额附加到交易历史记录中。操作对象将事务历史记录添加到应用程序状态。

操作对象如下所示:

{
  type: ADD_TODO,
  payload: 'Learn Redux'
}

操作对象提供的是能够保持所有状态事务的运行日志。该日志可用于以确定的方式重现状态,这意味着:

给定相同的初始状态和相同的顺序相同的事务,您总是得到相同的状态作为结果。
这具有重要意义:

  1. 易测试性
  2. 轻松撤消/重做
  3. 时间旅行调试
  4. 耐用性 - 即使状态被消除,如果您有每笔交易的记录,您也可以重现它。

谁不想掌握空间和时间?交易状态为您提供时间旅行超级能力。

2.某些应用程序不需要Redux
如果您的UI工作流程很简单,那么所有这些都可能过度。如果你正在制作一个井字游戏,你真的需要撤消/重做吗?游戏很少持续超过一分钟。如果用户搞砸了,你可以重置游戏并让他们重新开始。
如果:

  • 用户工作流程很简单
  • 用户不进行协作
  • 您不需要管理服务器端事件(SSE)或websockets
  • 您可以从每个视图的单个数据源中获取数据

可能应用程序中的事件序列可能非常简单,以至于事务状态的好处不值得付出额外的努力。
也许你不需要Fluxify你的应用程序。这样的应用程序有一个更简单的解决方案。看看MobX

但是,随着应用程序复杂性的增加,随着视图状态管理的复杂性增加,事务状态的价值随之增长,并且MobX不提供开箱即用的事务状态管理。
如果:

  • 用户工作流程很复杂
  • 您的应用拥有各种各样的用户工作流程(兼顾普通用户和管理员)
  • 用户可以协作
  • 您正在使用Web套接字或SSE
  • 您正在从多个端点加载数据以构建单个视图

您可以从事务状态模型中获益,使其值得付出努力。Redux可能适合你。
网络套接字和SSE与此有什么关系?当您添加更多异步I / O源时,更难理解具有不确定状态管理的应用程序中发生了什么。确定性状态和状态交易记录从根本上简化了这样的应用程序。
在我看来,大多数大型SaaS产品至少涉及一些复杂的UI工作流程,应该使用事务状态管理。大多数小型实用程序应用程序和简单原型不应该。使用正确的工具完成工作。

3.Reducers

Redux = Flux +函数编程

Flux使用操作对象规定单向数据流和事务状态,但没有说明如何处理操作对象。这就是Redux的用武之地。

Redux状态管理的主要构建块是reducer函数。什么是Reducer函数?

在函数式编程中,常用的实用程序`reduce()`或`fold()`用于将reducer函数应用于值列表中的每个值,以便累积单个输出值。下面是一个使用`Array.prototype.reduce()`应用于JavaScript数组的求和reducer的示例:

const initialState = 0;
const reducer = (state = initialState, data) => state + data;
const total = [0, 1, 2, 3].reduce(reducer);
console.log(total); // 6

Redux不是在数组上操作,而是将reducers应用于操作对象流。请记住,操作对象如下所示:

{
  type: ADD_TODO,
  payload: 'Learn Redux'
}

让我们将上面的求和reducer转换为Redux式reducer:

const defaultState = 0;
const reducer = (state = defaultState, action) => {
  switch (action.type) {
    case 'ADD': return state + action.payload;
    default: return state;
  }
};

测试案例:

const actions = [
  { type: 'ADD', payload: 0 },
  { type: 'ADD', payload: 1 },
  { type: 'ADD', payload: 2 }
];

const total = actions.reduce(reducer, 0); // 3

4.Reducers 必须是纯函数
为了实现确定性状态再现,reducer必须是纯函数。没有例外。一个纯粹的函数:

  1. 给定相同的输入,始终返回相同的输出。
  2. 没有副作用。

重要的是,在JavaScript中,所有非基本对象都作为引用传递给函数。换句话说,如果传入一个对象,然后直接改变该对象上的一个属性,该对象也会在该函数外部发生变化。这是副作用。在不知道传入的对象的完整历史记录的情况下,您无法知道调用函数的全部含义。这很糟糕。
Reducer应该返回一个新对象。例如,您可以使用:

Object.assign({}, state, { thingToChange })

数组参数也是引用,你不能通过数组的.push()加入条目到在reducer中的一个数组,因为.push是可变操作,像`.pop()`, `.shift()`, `.unshift()`, `.reverse()`, `.splice()`, 和其他 可变mutator method一样都是.

如果你要安全操作数组,你需要实现 accessor methods,使用.concat(),而不是.push()

看看加入ADD_CHAT到chat reducer:

const ADD_CHAT = 'CHAT::ADD_CHAT';

const defaultState = {
  chatLog: [],
  currentChat: {
    id: 0,
    msg: '',
    user: 'Anonymous',
    timeStamp: 1472322852680
  }
};

const chatReducer = (state = defaultState, action = {}) => {
  const { type, payload } = action;
  switch (type) {
    case ADD_CHAT:
      return Object.assign({}, state, {
        chatLog: state.chatLog.concat(payload)
      });
    default: return state;
  }
};

如您所见,使用`Object.assign()`创建一个新对象,然后使用`.concat()`而不是`.push()`增加条目到数组。
就个人而言,我不担心会意外地改变我的状态,所以最近我一直在尝试使用Redux的不可变数据API。如果我的状态是一个不可变对象,我甚至不需要查看代码就知道该对象没有被意外修改变动。我在一个团队工作并发现意外状态突变的错误之后得出了这个结论。
纯函数比这还要多得多。如果您打算将Redux用于生产应用程序,那么您确实需要很好地掌握纯函数是什么,以及需要注意的其他事项(例如处理时间,日志记录和随机数)。

5.记住:减速器必须是真正的单一来源
你的应用程序中的所有状态都应该只有一个真实来源,这意味着状态存储在一个地方,而其他任何需要状态的地方都应该通过参考其单一的事实来源来访问该状态。
为不同的事物提供不同的真理来源是可以的。例如,URL可以是用户请求路径和URL参数的单一事实来源。也许您的应用程序有一个配置服务,这是您的API URL的唯一事实来源。没关系。然而…
将任何状态存储在Redux存储中时,应通过Redux对该状态进行任何访问。如果不遵守这一原则,可能会导致过时数据或Flux和Redux发明要解决的共享状态突变错误。
换句话说,如果没有单一的事实原则,你可能会失败:

  • 确定性视图呈现
  • 确定性的状态再生成
  • 轻松撤消/重做
  • 时间旅行调试
  • 易测试性

6.对行动类型使用常量
确保在查看操作历史记录时,操作很容易跟踪到使用它们的reducer。如果您的所有操作都有简短的通用名称,例如“CHANGE_MESSAGE”,则很难理解应用中发生了什么。但是,如果动作类型具有更多描述性名称,如“CHAT :: CHANGE_MESSAGE”,那么显然会发生什么事情要清楚得多。
此外,如果您输入拼写错误并发送未定义的操作常量,应用程序将抛出错误以提醒您错误。如果您使用操作类型字符串输入拼写错误,则操作将以静默方式失败。
将Reducer的所有操作类型保存在文件顶部的一个位置也可以帮助您:

  • 保持名称一致
  • 快速了解reducer API
  • 查看拉取请求中的更改内容

7.使用Action Creators将动作逻辑与Dispatch Callers分离
Action Creators好处:
  • 将操作类型常量封装在reducer文件中,这样您就不必在其他任何位置导入它们。
  • 在分派操作之前对输入进行一些计算。
  • 减少样板

让我们使用一个Action Creators动作创建器来生成`ADD_CHAT`动作对象:
// Action creators can be impure.
export const addChat = ({
 
// cuid is safer than random uuids/v4 GUIDs
 
// see usecuid.org
  id = cuid(),
  msg = '',
  user = 'Anonymous',
  timeStamp = Date.now()
} = {}) => ({
  type: ADD_CHAT,
  payload: { id, msg, user, timeStamp }
});

如上所示,我们使用cuid为每个聊天消息生成随机ID,并使用`Date.now()`生成时间戳。这两个都是不合理的操作,在reducer中运行是不安全的 - 但是在动作创建器中运行它们是完全可以的。

使用Action Creators减少样板
有些人认为使用动作创建者会为项目添加样板。相反,您将看到我如何使用它们来大大减少我的Reducer中的样板。
提示:如果将常量,缩减器和动作创建器存储在同一个文件中,则从单独的位置导入它们时,将减少所需的样板。
想象一下,我们希望为聊天用户添加自定义用户名和可用性状态的功能。我们可以在reducer中添加几个动作类型处理程序,如下所示:

const chatReducer = (state = defaultState, action = {}) => {
  const { type, payload } = action;
  switch (type) {
    case ADD_CHAT:
      return Object.assign({}, state, {
        chatLog: state.chatLog.concat(payload)
      });
    case CHANGE_STATUS:
      return Object.assign({}, state, {
        statusMessage: payload
      });
    case CHANGE_USERNAME:
      return Object.assign({}, state, {
        userName: payload
      });

对于较大的Reducer,这可能会增长到很多样板。我构建的很多Reducer可能比这更复杂,有很多冗余代码。如果我们可以将所有简单的属性更改操作一起折叠怎么办?
事实证明,这很容易:

const chatReducer = (state = defaultState, action = {}) => {
  const { type, payload } = action;
  switch (type) {
    case ADD_CHAT:
      return Object.assign({}, state, {
        chatLog: state.chatLog.concat(payload)
      });

    // Catch all simple changes
    case CHANGE_STATUS:
    case CHANGE_USERNAME:
      return Object.assign({}, state, payload);

    default: return state;
  }
};

8.对签名文档使用ES6参数默认值
依赖类型推断插件,如Tern,TypeScript或Flow,可以帮助你检查源代码。
我更喜欢依赖函数签名中可见的默认赋值提供的推理而不是类型注释,因为: 

  • 您不必使用Flow或TypeScript使其工作:而是使用标准JavaScript。
  • 如果您使用的是TypeScript或Flow,则注释对于默认分配是多余的,因为TypeScript和Flow都会从默认分配中推断出类型。
  • 当语法噪音较少时,我发现它更具可读性。
  • 你得到默认设置,这意味着,即使你没有在类型错误上停止CI构建(你会感到惊讶,很多项目都没有),你永远不会有潜伏在你的意外`undefined`参数码。

9.使用选择器进行计算状态和去耦
想象一下,您正在构建聊天应用历史中最复杂的聊天应用。您编写了500k行代码,然后产品团队向您发出新功能要求,这将迫使您更改状态的数据结构。
无需恐慌。你很聪明,可以通过选择器将应用程序的其余部分与你的状态分离。子弹:躲过了。

对于我编写的几乎每个reducer,我创建了一个选择器,只需导出构建视图所需的所有变量。让我们看看我们的简单聊天reducer可能会是什么样子:

export const getViewState = state => state;

是的,我知道。这很简单,甚至不值得一把。你可能以为我现在疯了,但还记得我们之前躲过的子弹吗?如果我们想添加一些计算状态,比如在此会话期间聊天的所有用户的完整列表,该怎么办?我们称之为`recentActiveUsers`。
此信息已存储在我们当前的状态中 - 但不是以易于抓取的方式存储。让我们继续并在`getViewState()`中抓住它:

export const getViewState = state => Object.assign({}, state, {
  // return a list of users active during this session
  recentlyActiveUsers: [...new Set(state.chatLog.map(chat => chat.user))]
});

如果将所有计算的状态放在选择器中,则:

  1. 降低reducer和组件的复杂性
  2. 将您应用的其余部分与状态形状分离
  3. 遵守真理原则的单一来源,即使在你的reducer中也是如此

10.使用TDD:首先编写测试
大多数研究表明,在实施功能之前,编写测试结果会导致错误减少40-80%。
对每个reducer有三种不同的测试:
  1. 直接reducer测试,你刚才看到的一个例子。这些基本上测试reducer产生预期的默认状态。
  2. 动作创建者测试,通过使用某个预定状态作为起点将reducer应用于动作来测试每个动作创建者。
  3. 选择器测试,测试选择器以确保所有预期属性都存在,包括具有预期值的计算属性。