可扩展前端3  —  状态层

引子

Scalable Frontend 2 — Common Patterns 第三篇,继续翻译记录。javascript

原文:Scalable Frontend #3 — The State Layercss

正文

77-head
状态树,实际上就是单一来源html

在处理用户界面时,不管咱们使用的应用程序的规模有多大,必需要管理显示给用户或由用户更改的状态。来源多是从 API 获取的列表、从用户的输入得到、来自本地存储的数据等等。无论这些数据来自何处,咱们都必须对其进行处理,并使用持久化方法使其保持同步,不管是远程服务器仍是浏览器存储。前端

准确地说,这就是咱们所说的 本地状态 (local state),咱们应用程序使用和依赖的特定的一部分数据。有不少缘由能够解释为何、什么时候、何地更新和使用状态,若是咱们不恰当地管理它,它可能很快失控。即便是一张简单的报名表单,也可能须要处理不少状态:vue

  • 当用户与字段交互时,检查是否填写了有效数据;
  • 跳过未触及字段的验证,直到表单提交;
  • 当下拉选择一个国家时,触发获取该国家下州的请求,而后缓存响应;
  • 根据所选国家,更改语言下拉可选项。

噢!听起来很棘手,对吧?java

在本文中,咱们将讨论如何以合理的方式管理本地状态,始终牢记代码库的可扩展性和架构设计原则,以免状态层和其它层之间的耦合。应用程序的其他部分不该该知道状态层或正在使用的库,若是有的话。咱们只须要告诉视图层如何从状态中获取数据,以及如何分发 actions ,它们将调用组成咱们应用程序行为的用例。react

在过去的几年中,JavaScript 社区中出现了许多用于管理本地状态的库,这些库之前主要由双向数据绑定竞争者控制,例如 Backbone、Ember 和 Angular 。直到随着 Flux 和 React 的出现,单向数据流才变得流行起来,人们意识到 MVC 对于前端应用程序并不很适用。随着在前端开发中大量采用函数式编程技术,咱们能够理解为何像 Redux 这样的库如此流行并影响了整整一代的状态管理库。git

若是你想要更多了解这个主题,这里有个很好的关于 Flux 思惟模式的演示

如今,有好几个流行的状态管理库,其中一些特定于某些生态系统,好比 NgRx 是用于 Angular 。为了熟悉起见,咱们将使用 Redux ,可是本文中提到的全部概念适用于全部的库,甚至没有库的状况。记住这一点,你应该使用最适合你和你的团队的方案。不要由于一个库处处有宣传,使用它就感到有压力。若是对你适用,那就去用吧。github

公民:actions、action 建立者、reducers 和 store

在现代前端应用程序的状态管理中,咱们会发现这四个是最多见的对象类型。用 actions 将事件从反作用的影响中分离出来的想法并不新鲜。事实上,这些公民都是基于成熟的想法,例如 event sourcingCQRSmediator 设计模式。vuex

它们共同运做的方式是,经过集中存储和更改状态的方式,限制在一个地方并分发 actions(又称事件)来触发状态更改。一旦更改应用于状态,咱们会通知对其感兴趣的部分,它们会更新本身以反映新的状态。这是单向数据流循环。

77-one-way
循环

Actions and action 建立者

Actions 一般被实现为具备两个属性的对象:type 属性和传递给 store 执行对应操做的数据 data 。例如,触发建立用户的 action 多是如下格式:

{
  type: 'CREATE_USER',
  userData: { name: 'Aragorn', birthday: '03/01/2931' }
}

须要注意的是,type 属性的实现因所使用的状态管理库而异,但大多数状况下它都是一个字符串。另外,请记住,示例中的 action 自己并不建立用户;它只是一条消息,告诉 store 使用 userData 建立用户。

Action 建立者是把建立 action 对象抽象为一个可复用单元的函数

可是,若是咱们须要从代码中的多个位置触发相同的 action ,好比测试套件或另外一个文件,该怎么办?咱们如何使其可重用,并对分发它的单元隐藏 action 类型?咱们使用 action 建立者!Action 建立者是把建立 action 对象抽象为一个可复用单元的函数。咱们前面的示例能够由下面的 action 建立者封装:

const createUser = (userData) => ({
  type: 'CREATE_USER',
  userData
});

如今,每当咱们须要分发 CREATE_USER 的 action 时,咱们导入这个函数并使用它来建立将分发到咱们 store 的 action 对象。

Store

store 是咱们真实状态的惟一来源,是咱们存储和修改状态的惟一的地方。每次更改状态时,咱们都会向 store 分发一个 action ,描述想要执行的更改,并在须要时提供额外的信息(分别对应示例中的 type 和 userData)。这意味着咱们永远不该该在同一位置使用和更改状态,而是让 store 来更新状态。在这种模式的大多数实现中,咱们都会 订阅 ,当 store 执行变动时会获得相应通知,以便对更改作出反应。

store 是咱们真实状态的惟一来源。

好了,如今咱们知道 store 能够用于两个主要目的:分发 actions 和向订阅者触发事件。在 React 应用程序中,一般用 Redux 建立 store ,使用 react-redux’ connect 分发 actions(mapDispatchToProps)和监听变动(mapStateToProps)。但咱们也能够用一个根组件,使用 Context API 来存储状态,相应的使用 Context.Consumer 来分发 actions 和监听变动。或者咱们能够用一个更简单的方式:状态提高。对于 Vue ,有一个跟 Redux 很相似的库 Vuex ,咱们使用 dispatch 触发 actions ,用 mapState 来监听 store 。一样的,咱们能够用 @ngrx/store 在 Angular 应用程序中作一样的事情。

尽管存在差别,但全部这些库都有一个共同的理念:单向循环。每次须要更新状态时,咱们都会将 actions 发送到 store ,而后进行执行并通知监听者。千万不要回头或跳过这些步骤。

Reducers

但 store 如何更新状态并处理每一个 action 类型?这就是 reducers 派上用场的地方。老实说,它们并不老是被称为“reducers”,例如,在 Vuex 中,它们被称为“mutations”。但中心思想是同样的:一个获取应用程序当前状态和正在处理的 action ,返回一个全新的状态,或者使用设置器对当前状态进行修改的函数。store 将更新委托给此函数,而后将新状态通知给监听者。这就结束了循环!

每一个 reducer 都应该可以处理咱们应用中的任何 action 。

在结束这部分以前,有一条很是重要的规则须要强调:每一个 reducer 都应该可以处理咱们应用中的任何 action 。换句话说,一个 action 能够同时由多个 reducer 处理。所以,这条规则容许单个 action 在状态的不一样部分触发多个更改。这里有个很好的例子:在一个 AJAX 请求完成后,咱们能够在 reducer X 中的根据响应更新本地状态,在 reducer Y 中隐藏提示器,甚至在 reducer Z 中显示一条成功的消息,其中每一个 reducer 都有更新状态不一样部分的单一责任。

状态设计

当咱们开始编写应用程序时,总会想到一些问题:

  • 状态应该是什么样的?
  • 应该放什么进去?
  • 应该有什么样的形态?

这些问题恐怕没有正确的答案。咱们惟一有把握的是一些特定于库的规则,这些规则规定了如何更新状态。例如,在 Redux 中,reducer 函数应该是单一肯定的,而且具备 (state,action) => state 签名。

也就是说,咱们能够遵循一些实践来摆脱复杂性并提升 UI 性能,其中的一些是通用的,适用于咱们选择的任何状态管理技术。其它的一些则适用于像 Redux 这样的特定的工具,与具备很强函数特性的辅助函数结合,用来分解 reducer 逻辑。

在深刻研究以前,我建议你先查看你用来管理状态的库的文档。在大多数状况下,你会发现你不知道的高级技术和辅助方法,甚至本篇文章中没有介绍,但更适用你正在使用的状态管理方案的概念。除此以外,你能够查看第三方库,或者本身构建函数来实现这一点。

状态形态

状态指的是咱们须要管理的数据,形态指的是咱们如何构造和组织这些数据。形态与数据源无关,但与咱们如何构造 reducer 逻辑密切有关。

一般,这个形态是用一个普通的 JavaScript 对象表示,它造成了初始状态树,但也可使用任何其它值,好比纯数字、数组或字符串。对象的优势是容许将状态组织和划分为有意义的片断,其中根对象的每一个键都一个子树,能够表示公共或部分数据。在包含文章和做者的基本博客应用程序中,状态的形态可能以下所示:

{
  articles: [
    {
      id: 1,
      title: 'Managing all state in one reducer',
      author: {
        id: 1,
        name: 'Iago Dahlem Lorensini',
        email: 'iagodahlemlorensini@gmail.com'
      },
    },
    {
      id: 2,
      title: 'Using combineReducers to manage reducer logic',
      author: {
        id: 2,
        name: 'Talysson de Oliveira Cassiano',
        email: 'talyssonoc@gmail.com'
      },
    },
    {
      id: 3,
      title: 'Normalizing the state shape',
      author: {
        id: 1,
        name: 'Iago Dahlem Lorensini',
        email: 'iagodahlemlorensini@gmail.com'
      },
    },
  ],
}

请注意,articles 是状态的顶级键,它造成了一个表明相同概念数据的子树。咱们在每篇文章中也都有一个嵌套的子树来表示做者。通常来讲,咱们应该避免嵌套数据,由于它增长了 reducer 的复杂性。

Redux 的这篇文档介绍了如何根据你的定义域层和应用程序状态,将数据类型构造到状态形态上。即便你没有使用 Redux ,也去看看!数据管理对于任何类型的应用程序来讲都是司空见惯的,对于学习如何对数据进行分类并组织造成你的状态形态,那是一篇很是好的文章。

Reducers 合并

上一个示例的状态形态中只显示了一个键,但实际应用程序一般有多个定义域要表示,这意味着一个 reducer 函数中将有更多的更新逻辑。然而,这违背了一个重要的规则:reducer 函数应该精简且聚焦(单一责任原则),以便易于阅读、理解和维护。

在 Redux 中,咱们能够经过内置的 combineReducers 函数实现这一点。这个函数接受一个对象,其中每一个键表示状态的一个子树,并返回一个带有描述名称的组合 reducer 函数。让咱们将 authorsarticles 的 reducer 合并到一个 rootReducer 中:

import { combineReducers } from 'redux'

const authorsReducer = (state, action) => newState

const articlesReducer = (state, action) => newState

const rootReducer = combineReducers({
  authors: authorsReducer,
  articles: articlesReducer,
})

传递给 combineReducer 的键将用于造成状态的最终形态,其数据将由与各自键相关联的 reducer 函数进行转换。所以,若是咱们传递 authors 键和 authorsReducer 函数,rootReducer 返回的将是由 authorsReducer 函数管理的 state.authors

当咱们更深刻的拆分 reducer 函数时,合并 reducers 也很棒。假设 articlesReducer 须要处理这种状况:跟踪在获取文章的过程当中发生的错误。因此如今咱们状态中 articles 的键将再也不是一个数组,而是一个以下的对象:

{
  isLoading: false,
  error: null,
  list: [] // <- this is the array of articles itself
}

咱们能够在 articlesReducer 内部处理这种新状况,但在同一个地方咱们会有更多的声明要处理。幸运的是,这能够经过将 articlesReducer 分解成更小的部分来解决:

const isLoadingReducer = (state, action) => newState

const errorReducer = (state, action) => newState

const listReducer = (state, action) => newState

const articlesReducer = combineReducers({
  isLoading: isLoadingReducer,
  error: errorReducer,
  list: listReducer,
})

除了 combinerReducers ,还有其它方法能够分解 reducer 逻辑,但咱们将说明转交给 Redux 文档,文档对可复用技术例如高阶 reducer 、切片 reducer ,和减小样板代码的方法进行了很好的描述。请注意,这些方法也适用于 VueX 模块(本文将再次说起)和 NgRx

归一化(Normalization)

你注意到在咱们的博客示例中,每篇文章都嵌套了一个做者吗?不幸的是,当一个做者关联多篇文章时,这会致使数据重复,这样使更新做者的行为成为一场噩梦,由于咱们须要确保重复的做者数据也获得更新。更糟糕的是,因为没必要要的从新渲染,性能会降低。

但有一个解决方案:咱们能够像数据库那样归一化关联的数据。该技术关键在于为每一个数据类型或定义域提供一个“表”,经过它们的 ID 引用关联的实体。Redux 建议以下:

  • 把全部的实体保存在一个叫 byId 的对象中,实体的 ID 做为键,实体做为值,
  • 一个称为 allIds 的 ID 数组,表示实体的顺序。

在咱们的示例中,在对数据进行标准化后,咱们会获得以下结果:

{
  articles: {
    byId: {
      '1': {
        id: '1',
        title: 'Managing all state in one reducer',
        author: '1',
      },
      '2': {
        id: '2',
        title: 'Using combineReducers to manage reducer logic',
        author: '2',
      },
      '3': {
        id: '3',
        title: 'Normalizing the state shape',
        author: '1',
      },
    },
    allIds: ['1', '2', '3'],
  },
  authors: {
    byId: {
      '1': {
        id: '1',
        name: 'Iago Dahlem Lorensini',
        email: 'iagodahlemlorensini@gmail.com',
      },
      '2': {
        id: '2',
        name: 'Talysson de Oliveira Cassiano',
        email: 'talyssonoc@gmail.com',
      }
    },
    allIds: ['1', '2'],
  },
}

这种结构更轻量。因为没有重复项,做者只在一个地方进行更新,所以触发的 UI 更新更少。咱们的 reducer 更简单,对应项一致且便于查找。

开始归一化数据时一个常见问题是:

如何将这些数据的关联部分塑形成咱们的状态?

虽然没有硬性规定,但一般将定义域的“表”放在名为 entities 的顶级对象中。在咱们的文章示例中,将会是这样的:

{
  currentUser: {},
  entities: {
    articles: {},
    authors: {},
  },
  ui: {},
}

那么 API 发送的数据怎么处理?由于数据一般以嵌套格式返回,因此在存储到状态树以前须要对其进行归一化。咱们可使用 Normalizer 库来实现这一点,它容许根据定义模式类型和关系来返回归一化的数据。去查看他们的文档,了解更多关于它用法的详细信息。

对于使用较小应用程序或不想使用库的一些人,能够经过如下几个函数手动实现归一化:

  • replaceRelationById 函数用嵌套对象的 ID 替换自身,
  • extractRelation 函数从主要实体中提取嵌套对象,
  • byId 函数按照 ID 对实体进行分组,
  • allIds 函数收集全部的 ID 。

因此让咱们建立这些函数:

const replaceRelationById = (entities, relation, idKey = 'id') => entities.map(item => ({
  ...item,
  [relation]: item[relation][idKey],
}))

const extractRelation = (entities, relation) => entities.map(entity => entity[relation])

const byId = (entities, idKey = 'id') => entities
  .reduce((obj, entity) => ({
    ...obj,
    [entity[idKey]]: entity,
  }), {})

const allIds = (entities, idKey = 'id') => [...new Set(entities.map(entity => entity[idKey]))]

很简单,对吧?如今咱们须要从相应的 reducer 中调用这些函数。让咱们以第一篇文章的结构为例:

const articlesReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'RECEIVE_DATA':
      const articles = replaceRelationById(action.data, 'author')

      return {
        ...state,
        byId: byId(articles),
        allIds: allIds(articles),
      }
    default:
      return state
  }
}

const authorsReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'RECEIVE_DATA':
      const authors = extractRelation(action.data, 'author')

      return {
        ...state,
        byId: byId(authors),
        allIds: allIds(authors),
      }
    default:
      return state
  }
}

在 action 分发后,咱们将对 articles 表中的 ID 进行了归一化,没有嵌套数据,authors 表也将被归一化,没有任何重复。

常见模式

在以前的文章中,咱们讨论适用于定义域层、应用层、基础设施层和输入层的模式。如今让咱们讨论一下保持状态层合理和易于理解的模式。它们中的一些只在特定的状况下使用。

选择器(Selectors)

有时咱们须要的不只仅是从状态中提取数据:咱们可能须要经过过滤或分组来计算派生状态,以便在派生数据变化时从新渲染视图。例如,若是咱们按已完成的项筛选 TODO 列表,若是未完成的项进行了更新,咱们不须要从新渲染视图,对吧?另外,直接在用户端计算数据会使数据与其形态相耦合,若是咱们须要重构状态,其反作用就是咱们还须要更新用户端的代码。这正是咱们用选择器能够避免的问题。

选择器顾名思义是选择与特定上下文相关数据的函数。它们接收整个状态的一部分做为参数,并按照使用者的指望进行计算。让咱们回到以 React+Redux 为例的 TODO 列表。使用选择器先后的代码是什么样的?

/* view/todo/TodoList.js */

const TodoList = ({ todos, filter }) => (
 <ul>
  {
    todos
      .filter((todo) => todo.state === filter)
      .map((todo) =>
        <li key={todo.id}>{ todo.text }</li>
      )
  }
 </ul>
);

const mapStateToProps = ({ todos, filter }) => ({
  todos,
  filter
});

export default connect(mapStateToProps)(TodoList);
/* state/todos.js */
import * as Todo from '../domain/todo';

export const getTodosByFilter = (todos, filter) => (
  // notice that we isolate the domain rule into the domain/todo entity
  // so if the shape of the todo object changes it will only affect our entity file, not here :)
  todos.filter((todo) => Todo.hasState(todo, filter))
);

// ---------------------------------

/* view/todo/TodoList.js */

import { getTodosByFilter } from '../../state/todos';

const TodoList = ({ todos }) => (
 <ul>
  {
    todos
      .map((todo) =>
        <li key={todo.id}>{ todo.text }</li>
      )
  }
 </ul>
);

const mapStateToProps = ({ todos, filter }) => ({
  todos: getTodosByFilter(todos, filter)
});

export default connect(mapStateToProps)(TodoList);

咱们能够看到,重构后的组件不知道集合中存在什么类型的 TODO ,由于咱们将此逻辑提取到一个名为 getTodosByFilter 的选择器中。这正是选择器的做用所在,因此当你注意到组件对你的状态了解得太多时,考虑下使用选择器。

当你注意到组件对你的状态了解得太多时,考虑下使用选择器。

选择器还为咱们提供了利用一种称为 memoization 的性能改进技术的可能性,只要原始数据保持完整,就能够避免从新渲染和从新计算数据。在 Redux 中,咱们可使用 reselect 库来实现记忆化的选择器,你能够在 Redux 文档 中阅读相关信息。

若是你使用的是 Vuex ,已经有一种内置的选择器实现方法名为 getter 。你会发现 “getters”与 Redux 选择器的思惟方式彻底相同。NgRx 也有一个选择器功能,它甚至能够为你执行记忆化!

若是你想知道在哪里放置你的选择器,继续阅读,你很快就会发现!

鸭子/模块(Ducks/Modules)

咱们说过架构与文件组织不是同一回事,但文件组织能反映架构这是很好的,还记得这句话吗?鸭子模式正是关于这一点:它遵循了 Common Closure Principe (CCP) 的定义,即:

一个包中的类应该是针对相同类型的变动。影响一个包的变动会影响包中全部的类。

— Robert Martin

一个鸭子(或模块)是一个汇集了属于同一功能特性的 reducer、actions、action 建立者和选择器的文件,这样若是咱们须要添加或更改一个新的 action ,就只须要改动一个文件。

等等,这种模式是针对 Redux 应用程序的吗?固然不是!尽管 Ducks 这个名字的灵感来自 Redux 这个词,可是咱们能够按照它的思惟方式来使用任何咱们想要的状态管理方法,即便不使用库。

对于 Redux 用户来讲,这里有关于使用 ducks 方法的文档。对于 Vuex 应用程序,有一个叫作 modules 的东西,它基于相同的思想,但对 Vuex 来讲更“原生”,由于它是核心 API 的一部分。若是你用 Angular 和 NgRx ,有一个基于 Ducks 的提议,叫作 NgRx Ducks

但有个缺陷。Ducks 方式建议咱们在 duck 文件的顶部保留 action 名称,对吧?这可能不是最好的决策,由于这将使来自其它文件的 reducer 很难处理咱们应用程序的 任何 action ,由于它们将被迫重写 action 的名称。咱们能够为应用程序的全部 action 名建立一个单独的文件来避免这个问题,每一个 duck 均可以导入和使用这个文件。此文件将按功能对 action 名称进行分组,并为每一个 action 名指定导出。举个例子:

export const AUTH = {
  SIGN_IN_REQUEST: 'SIGN_IN_REQUEST',
  SIGN_IN_SUCCESS: 'SIGN_IN_SUCCESS',
  SIGN_IN_ERROR: 'SIGN_IN_ERROR',
}

export const ARTICLE = {
  LOAD_ARTICLE_REQUEST: 'LOAD_ARTICLE_REQUEST',
  LOAD_ARTICLE_SUCCESS: 'LOAD_ARTICLE_SUCCESS',
  LOAD_ARTICLE_ERROR: 'LOAD_ARTICLE_ERROR',
}

export const EDITOR =  {
  UPDATE_FIELD: 'UPDATE_FIELD',
  ADD_TAG: 'ADD_TAG',
  REMOVE_TAG: 'REMOVE_TAG',
  RESET: 'RESET',
}
import { AUTH } from './actionTypes'

export const reducer = (state, action) => {
  switch (action.type) {
    // ...
    case AUTH.SIGN_IN_SUCCESS: // <- same action for different reducers
      return {
        ...state,
        user: action.user,
      }
    // ...
  }
}
import { AUTH } from './actionTypes'

export const reducer = (state, action) => {
  switch (action.type) {
    // ...
    case AUTH.SIGN_IN_SUCCESS: // <- same action for different reducers
    case AUTH.SIGN_IN_ERROR:
      return {
        ...state,
        showSpinner: false,
      }
    // ...
  }
}

状态机(State Machines)

有时使用咱们的状态变量来管理多个布尔值或多个条件,以找出应该渲染的内容,可能过于复杂。一个表单组件可能须要考虑多种可能性:

  • 字段尚未被触碰,因此不要显示验证信息;
  • 字段被触碰而且无效,所以显示验证信息;
  • 字段没有被触碰,但提交按钮被点击了,因此显示验证信息;
  • 字段是有效的,提交按钮被点击,因此显示提示器并禁用字段;
  • 请求成功,所以隐藏提示器并显示确认信息;
  • 请求失败,所以隐藏提示器,激活字段,并显示错误信息。

你能想象咱们会用多少个布尔值吗?可能会产生相似这样的结果:

{
  (isTouched || isSubmited) && !isValid && <ErrorMessage errors={errors} />
}

{
  isValid && isSubmited && !errors && <Spinner />
}

当咱们试图使用数据来定义应该呈现的内容时,咱们一般会获得这样的代码,因此咱们添加了一堆布尔变量,并试图以一种合理的方式来协调它们——结果证实是很是困难的。可是,若是咱们试图把全部这些可能性归类为一些明确的、名副其实的状态呢?想一想看,咱们的接口将始终处于如下状态之一:

  • Pristine
  • Valid
  • Invalid
  • Submitting
  • Successful

请注意,从任何给定的状态,都有咱们没法转换到的状态。咱们不能从“Invalid”转变到“Submitting”,但咱们能够从“Invalid”转变到“Valid”,而后再转变到“Submitting”。

状态机的理念是定义一组可能的状态以及它们之间的转换。

这种状况用计算机科学的概念来解释更为合适,称为有限状态机,或是为这种状况专门建立的一种变体,称为状态图。状态机的理念是定义一组可能的状态以及它们之间的转换。

在咱们的示例中,状态机将会是这个样子:

77-state-machine

它看起来可能很复杂,但请注意,状态和转换的良好定义能够提升代码的清晰度,从而更容易以明确和简洁的方式添加新状态。如今咱们的条件将只关心当前状态,咱们将再也不须要处理复杂的布尔表达式:

{
  (currentState === States.INVALID) && <ErrorMessage errors={errors} />
}

{
  (currentState === States.SUBMITTING) && <Spinner />
}

好了,那么咱们如何在代码中实现状态机呢?首先要明白的是,它没必要是一个复杂的实现。咱们能够有一个表示当前状态名称的普通字符串,咱们的 reducer 处理的每个 action 更新该字符串。举个例子:

import Auth from '../domain/auth';
import { AUTH } from './actionTypes';

const States = {
  PRISTINE: 'PRISTINE',
  VALID: 'VALID',
  INVALID: 'INVALID',
  SUBMITTING: 'SUBMITTING',
  SUCCESS: 'SUCCESS'
};

const initialState = {
  currentState: States.PRISTINE,
  data: {}
};

export const reducer = (state = initialState, action) => {
  switch(action.type) {
  case AUTH.UPDATE_AUTH_FIELD:
    const newData = { ...state.data, ...action.data };

    return {
      ...state,
      // ...
      data: newData,
      currentState: Auth.isValid(newData) ? States.VALID : States.INVALID
    };
  case AUTH.SUBMIT_SIGN_IN:
    if(state.currentState === States.INVALID) {
      return state; // makes it impossible to submit if it's invalid
    }

    return {
      ...state,
      // ...
      currentState: States.SUBMITTING
    };
  case AUTH.SIGN_IN_SUCCESS:
    return {
      ...state,
      // ...
      currentState: States.SUCCESS
    };
  }

  return state;
};

但有时咱们须要更大的具备更多状态和转换的状态机,或者出于其它缘由咱们只须要一个特定的工具。对于这些状况,咱们可使用相似 XState 的东西。请记住,状态机对状态管理是不可知的,所以不管使用 Redux、Context API、Vuex、NgRx,甚至没有库,咱们均可以拥有它们!

若是你想了解更多,在这篇文章的最后有几个连接,提供了关于状态机和状态图的更多信息。

常见陷阱

即便遵循一个好的架构,在开发咱们的前端应用程序时也有一些诱人的陷阱须要避免。咱们说 诱人 是由于尽管它们看起来无害,但它们有很大的潜力最终致使反噬。咱们来谈谈关于状态层的一些注意事项。

不要为不一样的目的复用相同的异步操做

你还记得本系列的第一篇文章吗?当时咱们谈到之后端应用程序中的控制器的方式处理 actions ,不在其中包含业务规则,并将工做委托给用例?让咱们回到这个话题,但首先,让咱们定义一下咱们所说的“有反作用的 actions”是什么意思。

当某些操做的结果影响到本地环境以外的一些东西时,就会产生反作用。在咱们的例子中,让咱们考虑一个反作用,当一个 action 不只仅是改变本地状态时,好比还发送一个 AJAX 请求或者将数据持久化到 LocalStorage 。若是咱们的应用程序使用 Redux ThunkRedux SagaVuex ActionsNgRx Effects ,甚至是执行请求的特殊类型的 action ,那就是咱们所指的。

使 actions 相似于控制器的缘由是它们都暗含告终果。它们执行整个用例和它们的反作用,这就是为何咱们不复用控制器,也不该该复用带有反作用的 action 。咱们试图为不一样的目的复用同一个 action 时,咱们也会继承它的全部反作用,这是不可取的,由于它使代码更难理解。让咱们用一个例子来简化一下。

想象一个 loadProducts action 经过 AJAX 加载一个产品列表,并在请求的过程当中显示一个提示器(在咱们的例子中将使用一个 Redux Thunk action):

const loadProductsAction = () => (dispatch, _, container) => {
  dispatch(showSpinner());

  container.loadProducts({
    onSuccess: (products) => {
      dispatch(receiveProducts(products));
      dispatch(hideSpinner());
    },
    onError: (error) => {
      dispatch(loadProductsError(error));
      dispatch(hideSpinner());
    }
  });
};

好的,可是如今咱们想不时地从新加载这个列表,使它始终保持最新,因此第一个念头就是复用这个操做,对吧?若是咱们但愿在后台进行更新而不显示提示器,该怎么办?有人可能会说,能够为此添加一个 withSpinner 标志,因此咱们这样作:

const loadProductsAction = ({ withSpinner }) => (dispatch, _, container) => {
  if(withSpinner) {
    dispatch(showSpinner());
  }

  container.loadProducts({
    onSuccess: (products) => {
      dispatch(receiveProducts(products));
      if(withSpinner) {
        dispatch(hideSpinner());
      }
    },
    onError: (error) => {
      dispatch(loadProductsError(error));
      if(withSpinner) {
        dispatch(hideSpinner());
      }
    }
  });
};

这已经变得很奇怪了,由于在使用标志时须要考虑一些复用,可是让咱们暂时忽略它。

如今,若是咱们但愿为成功的状况触发另外一个不一样的 action ,咱们应该怎么作?也将其做为参数传递?咱们越是试图让一个 action 通用,它就越复杂,越不聚焦,你能发现这个吗?咱们怎样才能解决这个问题,而且仍然复用这个 action ?最好的答案是:咱们不用。

抵制复用有反作用 actions 的冲动。

对于这样的状况,抵制复用有反作用 actions 的冲动!它们的复杂性最终会变的难以忍受、难以理解和难以测试。相反,尝试建立两个利用相同用例的明确 actions :

const loadProductsAction = () => (dispatch, _, container) => {
  dispatch(showSpinner());

  container.loadProducts({
    onSuccess: (products) => {
      dispatch(receiveProducts(products));
      dispatch(hideSpinner());
    },
    onError: (error) => {
      dispatch(loadProductsError(error));
      dispatch(hideSpinner());
    }
  });
};
const refreshProductsAction = () => (dispatch, _, container) => {
  container.loadProducts({
    onSuccess: (products) => {
      dispatch(refreshProducts(products));
    },
    onError: (error) => {
      dispatch(loadProductsError(error));
    }
  });
};

好极了!如今咱们能够看到这两个 actions ,并确切地知道它们应该在何时使用。

注意,当一个有反作用的 action 使用另外一个也有反作用的 action 时,一样也适用。咱们不该该这样作,由于调用 action 将继承被调用 action 的全部反作用。

不要让你的视图依赖于 action 的返回

咱们已经知道复用 actions 会使代码更难理解。如今想象一下,咱们的组件依赖于这些 action 反作用的返回值。听起来不算太糟,对吧?

但这会使咱们的代码更难理解。假设咱们正在调试一个获取产品的 action 。调用这个 action 后,咱们意识到已获取了此产品的评论列表,但咱们不知道它来自何处,并且咱们肯定它不是来自 action 自己。如今变的愈来愈复杂了,不是吗?

// action

const loadProduct = (id) => (dispatch, _, container) => {
  container.loadProduct({
    onSuccess: (product) => dispatch(loadProductSuccess(product)),
    onError: (error) => dispatch(loadProductError(error)),
  })
}

// component

componentDidMount() {
  const { productId, loadProduct, loadComments } = this.props

  loadProduct(productId)
    .then(() => loadComments(productId))
}

咱们将 actions 看成控制器,可是咱们会在后端应用程序中链式调用控制器吗?我不这么认为。

永远不要依赖 actions 返回的链式回调,也不要作任何其它相似的事情。若是应该在 action 调用完成后完成某些事情,则 action 自己应该处理它。

所以,做为第二条规则,永远不要依赖 actions 返回的链式回调,也不要作任何其它相似的事情。若是应该在 action 调用完成后完成某些事情,则 action 自己应该处理它——除非它是另外一层的责任,好比重定向(这其实是视图层的责任,咱们将在本系列的下一篇文章中讨论),你的 actions 应该是应用程序的入口点,因此不要将重定向调用散布到全部组件上。

// action

const loadProduct = (id) => (dispatch, _, container) => {
  container.loadProduct(id, {
    onSuccess: (product, comments) => dispatch(loadProductSuccess(product, comments)),
    onError: (error) => dispatch(loadProductError(error)),
  })
}

// component

componentDidMount() {
  const { productId, loadProduct } = this.props

  loadProduct(productId)
}

不要存储计算数据

有时,咱们须要将原始数据转换为人类可读的值,例如价格和日期。假设咱们有一个产品模型,并收到相似于 {name:'product name',price:14.9} 的东西,其中包含一个普通数字形式的价格。如今咱们的工做是在向用户展现以前格式化这些数据。

因此要记住,当一个值能够用一个纯函数(也就是说,给定相同的输入,咱们老是获得相同的输出,)进行转换时,咱们其实不须要将它存储到咱们的状态中;咱们能够在这个值将显示给用户的地方调用一个转换函数。在 React 视图中,它将像 <p>{formatPrice(product.price)}</p> 这样简单。

咱们常常看到开发人员存储 formatPrice(product.price) 的返回值,这可能会致使缺陷。若是咱们想要将此值发送回服务器,或者咱们须要用它在前端进行计算,会发生什么状况?在这种状况下,咱们须要将它转换回一个普通的数字,这是不理想的,不存储它能够彻底避免这些。

有人可能会说,在渲染中屡次调用函数可能会影响性能,但使用诸如记忆化之类的技术,咱们会避免每次都对其进行处理。所以,性能不是不作的借口。可使用 mem 这样的简单库,也能够将此函数调用抽象到一个组件中,像这样 <FormatPrice>{product.price}</FormatPrice> 并使用自带的 React.memo 函数。但请记住,只有当你的函数须要密集处理时,才须要记忆化。

接下来

这篇文章比预期的要长一点,但咱们很高兴地说,这篇文章和上一篇文章一块儿,涵盖了咱们用来开发可扩展前端应用程序的最多见模式。

固然,现代应用程序还有其它问题须要处理,如身份验证、错误处理和样式,这些将在之后的文章中讨论。在下一篇文章中,咱们将讨论视图层和状态层之间的交互,以及在保持它们解耦的同时,如何使它们相互依赖,还有路由相关。再见!

推荐连接

参考资料

相关文章
相关标签/搜索