关于javascript:可扩展前端3-状态层

41次阅读

共计 17206 个字符,预计需要花费 44 分钟才能阅读完成。

引子

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

原文:Scalable Frontend #3 — The State Layer

  • Origin
  • My GitHub

注释


状态树,实际上就是繁多起源

在解决用户界面时,无论咱们应用的应用程序的规模有多大,必须要治理显示给用户或由用户更改的状态。起源可能是从 API 获取的列表、从用户的输出取得、来自本地存储的数据等等。不论这些数据来自何处,咱们都必须对其进行解决,并应用长久化办法使其放弃同步,无论是近程服务器还是浏览器存储。

精确地说,这就是咱们所说的 本地状态(local state),咱们应用程序应用和依赖的特定的一部分数据。有很多起因能够解释为什么、何时、何地更新和应用状态,如果咱们不恰当地治理它,它可能很快失控。即便是一张简略的报名表单,也可能须要解决很多状态:

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

噢!听起来很辣手,对吧?

在本文中,咱们将探讨如何以正当的形式治理本地状态,始终牢记代码库的可扩展性和架构设计准则,以防止状态层和其它层之间的耦合。应用程序的其余部分不应该晓得状态层或正在应用的库,如果有的话。咱们只须要通知视图层如何从状态中获取数据,以及如何散发 actions,它们将调用组成咱们应用程序行为的用例。

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

如果你想要更多理解这个主题,这里有个很好的对于 Flux 思维模式的演示。

当初,有好几个风行的状态治理库,其中一些特定于某些生态系统,比方 NgRx 是用于 Angular。为了相熟起见,咱们将应用 Redux,然而本文中提到的所有概念实用于所有的库,甚至没有库的状况。记住这一点,你应该应用最适宜你和你的团队的计划。不要因为一个库到处有宣传,应用它就感到有压力。如果对你实用,那就去用吧。

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

在古代前端应用程序的状态治理中,咱们会发现这四个是最常见的对象类型。用 actions 将事件从副作用的影响中分离出来的想法并不陈腐。事实上,这些公民都是基于成熟的想法,例如 event sourcing、CQRS 和 mediator 设计模式。

它们独特运作的形式是,通过集中存储和更改状态的形式,限度在一个中央并散发 actions(又称事件)来触发状态更改。一旦更改利用于状态,咱们会告诉对其感兴趣的局部,它们会更新本人以反映新的状态。这是单向数据流循环。


循环

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”。

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

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

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

它看起来可能很简单,但请留神,状态和转换的良好定义能够进步代码的清晰度,从而更容易以明确和简洁的形式增加新状态。当初咱们的条件将只关怀以后状态,咱们将不再须要解决简单的布尔表达式:

{(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 Thunk、Redux Saga、Vuex Actions、NgRx 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 函数。但请记住,只有当你的函数须要密集解决时,才须要记忆化。

接下来

这篇文章比预期的要长一点,但咱们很快乐地说,这篇文章和上一篇文章一起,涵盖了咱们用来开发可扩大前端应用程序的最常见模式。

当然,古代应用程序还有其它问题须要解决,如身份验证、错误处理和款式,这些将在当前的文章中探讨。在下一篇文章中,咱们将探讨视图层和状态层之间的交互,以及在放弃它们解耦的同时,如何使它们相互依赖,还有路由相干。再见!

举荐链接

  • Rethinking Web App Development at Facebook
  • Lift State Up
  • Structuring Reducers
  • Getting started with Redux
  • Building React Applications with Idiomatic Redux
  • Vuex Docs
  • NgRx Docs
  • Advanced Redux Patterns: Selectors
  • Redux: Colocating Selectors with Reducers
  • Robust React User Interfaces with Finite State Machines
  • Redux modules and code-splitting
  • Welcome to the world of Statecharts
  • Decomposing the TodoMVC app with state diagrams
  • Writing tests for Redux
  • Writing tests for VueX
  • Writing tests for NgRx

参考资料

  • Scalable Frontend #3 — The State Layer

正文完
 0