二十四、处理应用状态

从本书的早期开始,您就一直在使用状态来控制 React 组件。状态在任何 React 应用中都是一个重要的概念,因为它构成了用户交互的本质。如果没有状态,就只有一堆空的组件。

在本章中,您将了解 Flux 以及它如何作为信息体系结构的基础。然后,我们将考虑如何构建一个最适合 web 和移动架构的架构。在讨论 React 体系结构的一些限制以及如何克服这些限制之前,我们还将先看看 Redux 库。

信息架构与流量

很难将用户界面视为信息架构。更常见的情况是,我们大致了解 UI 的外观和行为,然后尝试实现它。我一直都在这样做,这是一个很好的方法,可以让事情顺利进行,尽早发现你的方法中的问题,等等。但我喜欢退一步,想象一下没有任何小部件的情况。不可避免地,我构建的东西在状态如何流经各个组件方面存在缺陷。这很好;至少我现在有工作要做。在构建太多之前,我必须确保解决信息体系结构问题。

Flux 是 Facebook 创建的一组模式,帮助开发人员以一种自然适合其应用的方式思考其信息架构。接下来我将介绍 Flux 的关键概念,然后我们可以考虑将这些思想应用到统一的 React 体系结构中。

单向性

在本书前面,我介绍了 React 组件的容器模式。这很简单。容器组件具有状态,但它实际上不呈现任何 UI 元素。相反,它渲染其他 React 组件并以其状态作为属性传递。每当容器状态更改时,子组件都会使用新的属性值重新呈现。这是单向数据流。

Flux 接受了这个想法,并将其应用到一个叫做商店的东西上。存储是保存应用状态的抽象概念。就我而言,React 容器是一个完全有效的通量存储。过一会儿我会有更多关于商店的话要说。首先,我想让您理解为什么单向数据流是有利的。

很可能您已经实现了一个改变状态的 UI 组件,但您并不总是确定它是如何发生的。这是另一个组件中某个事件的结果吗?这是网络通话的副作用吗?当这种情况发生时,您将花费大量时间查找更新的来源。其效果通常是一场“打鼹鼠”的级联游戏。当更改只能来自一个方向时,可以消除许多其他可能性,从而使整个体系结构更具可预测性。

同步更新轮次

当您更改 React 容器的状态时,它将重新渲染其子容器,这些子容器将重新渲染其子容器,依此类推。在通量术语中,这称为更新回合。从时间状态更改到 UI 元素反映此更改的时间,这是循环的边界。能够像这样将应用行为的动态部分分组到更大的块中是很好的,因为这样更容易对因果进行推理。

React 容器组件的一个潜在问题是,它们可以相互交织,并以不确定的顺序进行渲染。例如,如果某个 API 调用在另一轮更新中完成渲染之前完成并导致状态更新,该怎么办?如果不认真对待,异步的副作用可能会累积并演变为不可持续的体系结构。

Flux 架构中的解决方案是强制执行同步更新轮,并将绕过更新轮顺序的尝试视为错误。JavaScript 是一个单线程、从运行到完成的环境,因此我们不妨通过使用它而不是反对它来接受这一事实。更新整个 UI,然后再次更新整个 UI。事实证明,React 对于这项工作来说是一个非常好的工具。

可预测状态转换

所以我们知道,在 Flux 架构中,我们有一个叫做存储的东西,用来保存应用状态。我们知道,当状态发生变化时,它是同步的和单向的,这使得系统作为一个整体更容易预测和推理。然而,我们还可以做一件事来确保不会产生副作用。

我们将所有的应用状态都保存在一个存储中,这很好,但是我们仍然可以通过在其他地方修改数据来破坏它。乍一看,这些突变似乎相当无辜,但它们对我们的体系结构有害。例如,处理fetch()调用的回调函数可能会在将数据传递到存储之前对其进行操作。事件处理程序可能会生成一些结构并将其传递给存储。有无限的可能性。

在商店外执行这些状态转换的问题是,我们不一定知道它们正在发生。把数据突变想象成蝴蝶效应:一个小小的变化会产生深远的影响,而这些影响起初并不明显。解决办法很简单。仅在存储中改变状态,没有例外。通过这种方式跟踪 React 体系结构的因果关系是可以预测的,而且很容易。

在整本书的大多数例子中,我都用Immutable.js表示状态。当您在大规模地考虑通量体系结构中的状态转换时,这将非常有用。控制状态转换发生的位置很重要,但状态不变性也很重要。它有助于强化 Flux 架构的思想,我们将在研究 Redux 时更深入地探讨这些思想。

统一信息架构

让我们花些时间回顾一下我们的应用架构的组成部分:

  • React Web:在 Web 浏览器中运行的应用
  • React Native:在移动平台上本机运行的应用
  • 流量:React 应用中可伸缩数据的模式

记住,React 只是位于渲染目标之上的抽象。两个主要渲染目标是浏览器和移动本机。这个列表可能会增加,所以我们需要以一种不排除未来可能性的方式来设计我们的架构。挑战在于您没有将 web 应用移植到本地移动应用;它们是不同的应用,但用途相同。

话虽如此,有没有一种方法可以让我们仍然拥有某种基于 Flux 思想的统一信息架构,这些架构可以被这些不同的应用使用?不幸的是,我能想出的最好的答案是:有点。您不想让不同的 web 和移动用户体验导致处理状态的方法截然不同。如果应用的目标相同,那么就必须有一些我们可以共享的公共信息,使用相同的流量概念。

困难的部分是 web 和本地移动是不同的体验,因为这意味着我们的应用状态的形状将不同。它必须是不同的;否则,我们将只是从一个平台移植到另一个平台,这就违背了使用 React Native 来利用浏览器中不存在的功能的目的。

那么,让我们看看我们可以实现什么。

实施 Redux

我们将使用一个名为 Redux 的库来实现一个演示 Flux 体系结构的基本应用。Redux 没有严格遵循 Flux 设定的模式。相反,它借鉴了 Flux 的关键思想,并实现了一个小型 API,以使 Flux 的实现更加容易。

该应用本身将是一个新闻阅读器,一个专门为你可能还没有听说过的时髦人士提供的阅读器。这是一个简单的应用,但我想在我们完成实现的过程中强调体系结构方面的挑战。即使是简单的应用,当你真正关注数据的变化时也会变得复杂。

我们将实现此应用的三个版本。我们将从 web 版本开始,然后为 iOS 和 Android 实现移动本机应用。您将看到如何在应用之间共享体系结构概念。当您需要在多个平台上实现相同的应用时,这降低了概念上的开销。我们目前正在实施三个应用,但随着 React 扩展其渲染功能,未来可能会更多。

我再次敦促您从下载本书的代码示例 https://github.com/PacktPublishing/React-and-React-Native 。在这本书中,我没有足够的篇幅来介绍很多小细节,特别是我们将要介绍的这些示例应用。我会尽我最大的努力来涵盖重要的部分,但我不能为你修修补补。

初始应用状态

让我们从通量存储的初始状态开始。在 Redux 中,应用的整个状态由单个存储表示。下面是它的样子:

import { fromJS } from 'immutable'; 

// The state of the application is contained 
// within an Immutable.js Map. Each key represents 
// a "slice" of state. 
export default fromJS({ 
  // The "App" state is the generic state that's 
  // always visible. This state is not specific to 
  // one particular feature, in other words. It has 
  // the app title, and links to various article 
  // sections. 
  App: { 
    title: 'Neckbeard News', 
    links: [ 
      { name: 'All', url: '/' }, 
      { name: 'Local', url: 'local' }, 
      { name: 'Global', url: 'global' }, 
      { name: 'Tech', url: 'tech' }, 
      { name: 'Sports', url: 'sports' }, 
    ], 
  }, 

  // The "Home" state is where lists of articles are 
  // rendered. Initially, there are no articles, so 
  // the "articles" list is empty until they're fetched 
  // from the API. 
  Home: { 
    articles: [], 
  }, 

  // The "Article" state represents the full article. The 
  // assumption is that the user has navigated to a full 
  // article page and we need the entire article text here. 
  Article: { 
    full: '', 
  }, 
}); 

此模块导出一个Immutable.js Map实例。稍后您将了解我们为什么要这样做。但现在,让我们看看这个州的组织。在 Redux 中,您将应用状态划分为片。在本例中,它是一个简单的应用,因此存储只有三个状态片。每个状态片都映射到一个主要的应用功能。

例如,Home键表示应用的Home 组件使用的状态。初始化任何状态都很重要,即使它是空对象或数组,这样我们的组件就具有初始属性。现在,让我们使用一些 Redux 函数来创建一个实际的存储,用于将数据获取到 React 组件。

创建店铺

当应用首次启动时,初始状态很有用。这足以渲染组件,但仅此而已。一旦用户开始与 UI 交互,我们需要一种方法来更改存储的状态。在 Redux 中,为存储中的每个状态片分配一个 reducer 函数。例如,我们的应用将有一个Home减速器、一个App减速器和一个Article减速器。

Redux 中减速机的关键概念是它是纯的并且没有副作用。这就是将Immutable.js结构作为状态派上用场的地方。让我们看看如何将初始状态与最终将改变存储状态的 reducer 函数联系起来:

import { createStore } from 'redux'; 
import { combineReducers } from 'redux-immutable'; 

// So build a Redux store, we need the "initialState" 
// and all of our reducer functions that return 
// new state. 
import initialState from './initialState'; 
import App from './App'; 
import Home from './Home'; 
import Article from './Article'; 

// The "createStore()" and "combineReducers()" functions 
// perform all of the heavy-lifting. 
export default createStore(combineReducers({ 
  App, 
  Home, 
  Article, 
}), initialState); 

很简单!AppHomeArticle函数的命名方式与其操作的状态片完全相同。这使得随着应用的增长,添加新状态和 reducer 函数变得更加容易。

我们现在有一个 Redux 商店,可以使用了(我们将很快研究 reducer 函数)。但我们仍然没有将它连接到实际呈现状态的 React 组件。现在让我们来看看如何做到这一点。

店铺供应商和路线

Redux 有一个Provider组件(从技术上讲,它是提供它的react-redux包),用于包装我们应用的顶级组件。这将确保应用中的每个组件都可以使用 Redux 存储数据。

在我们正在开发的 hipster 新闻阅读器应用中,我们将用Provider组件包装Router组件。然后,当我们构建组件时,我们知道存储数据将可用。下面是它的样子:

import React from 'react'; 
import { Provider } from 'react-redux'; 
import { 
  Router, 
  Route, 
  IndexRoute, 
  browserHistory, 
} from 'react-router'; 

// Components that render application state. 
import App from './components/App'; 
import Home from './components/Home'; 
import Article from './components/Article'; 

// Our Redux/Flux store. 
import store from './store'; 

// Higher order component for making the 
// various article section components out of 
// the "Home" component. The only difference 
// is the "filter" property. Having unique JSX 
// element names is easier to read than a bunch 
// of different property values. 
const articleList = filter => props => ( 
  <Home {...props} filter={filter} /> 
); 

const Local = articleList('local'); 
const Global = articleList('global'); 
const Tech = articleList('tech'); 
const Sports = articleList('sports'); 

// Routes to the home page, the different 
// article sections, and the article details page. 
// The "<Provider>" element is how we pass Redux 
// store data to each of our components. 
export default ( 
  <Provider store={store}> 
    <Router history={browserHistory}> 
      <Route path="/" component={App}> 
        <IndexRoute component={Home} /> 
        <Route path="local" component={Local} /> 
        <Route path="global" component={Global} /> 
        <Route path="tech" component={Tech} /> 
        <Route path="sports" component={Sports} /> 
        <Route path="articles/:id" component={Article} /> 
      </Route> 
    </Router> 
  </Provider> 
); 

我们通过获取初始状态并将其与 reducer 函数组合而创建的存储被传递到<Provider>。这意味着,当我们的 reducer 导致 Redux 存储发生更改时,存储数据会自动传递给每个应用组件。下面我们来看看App组件。

App 组件

App组件始终可见,包括页面标题和指向各种文章类别的链接列表。当用户在用户界面上移动时,App组件始终被渲染,但其子组件会发生变化。让我们看一下组件,然后我们将分解它的工作原理:

import React, { PropTypes } from 'react'; 
import { IndexLink } from 'react-router'; 
import { connect } from 'react-redux'; 

const App = ({ 
  title, 
  links, 
  children, 
}) => ( 
  <main> 
    <h1>{title}</h1> 
    <ul style={categoryListStyle}> 
      { /* Renders a link for each article category. 
           The key thing to note is that the "links" 
           value comes from a Redux store. */ } 
      {links.map(l => ( 
        <li key={l.url} style={categoryItemStyle}> 
          <IndexLink 
            to={l.url} 
            activeStyle={{ fontWeight: 'bold' }} 
          > 
            {l.name} 
          </IndexLink> 
        </li> 
      ))} 
    </ul> 
    <section> 
      {children} 
    </section> 
  </main> 
); 

App.propTypes = { 
  title: PropTypes.string.isRequired, 
  links: PropTypes 
    .arrayOf(PropTypes.shape({ 
      name: PropTypes.string.isRequired, 
      url: PropTypes.string.isRequired, 
    })).isRequired, 
  children: PropTypes.node, 
}; 

export default connect( 
  state => state.get('App').toJS() 
)(App); 

JSX 内容本身非常简单。它需要一个title属性和一个links属性。这两个值实际上都是来自 Redux 存储的状态。请注意,我们正在导出一个使用connect()函数创建的高阶组件。此函数接受一个回调函数,该函数将存储状态转换为组件所需的属性。

在本例中,我们需要App状态。使用toJS()方法将此映射转换为普通 JavaScript 对象非常简单。这就是 Redux 状态传递给组件的方式。以下是App组件的渲染内容的外观:

The App component

暂时忽略那些令人惊叹的文章标题;我们将简要地回到这些方面。标题和类别链接由App组件提供。文章标题由App的子组件呈现。

注意所有类别是如何加粗的?这是因为它是当前选定的类别。如果选择了本地类别,则所有文本将返回常规字体,本地文本将被加粗。这都是通过 Redux 状态控制的。让我们看看现在的减速器功能:

import { fromJS } from 'immutable'; 
import initialState from './initialState'; 

// The initial page heading. 
const title = initialState.getIn(['App', 'title']); 

// Links to display when an article is displayed. 
const articleLinks = fromJS([{ 
  name: 'Home', 
  url: '/', 
}]); 

// Links to display when we're on the home page. 
const homeLinks = initialState.getIn(['App', 'links']); 

// Maps the action type to a function 
// that returns new state. 
const typeMap = fromJS({ 
  // The article is being fetched, adjust 
  // the "title" and "links" state. 
  FETCHING_ARTICLE: state => 
    state 
      .set('title', '...') 
      .set('links', articleLinks), 

  // The article has been fetched. Set the title 
  // of the article. 
  FETCH_ARTICLE: (state, payload) => 
    state.set('title', payload.title), 

  // The list of articles are being fetched. Set 
  // the "title" and the "links". 
  FETCHING_ARTICLES: state => 
    state 
      .set('title', title) 
      .set('links', homeLinks), 

  // The articles have been fetched, update the 
  // "title" state. 
  FETCH_ARTICLES: state => 
    state.set('title', title), 
}); 

// This reducer relies on the "typeMap" and the 
// "type" of action that was dispatched. If it's 
// not found, then the state is simply returned. 
export default (state, { type, payload }) => 
  typeMap.get(type, () => state)(state, payload); 

关于这个逻辑,我想说两点。首先,您现在可以看到,不可变的数据结构如何使代码简洁易懂。其次,许多状态处理都是为了响应简单的操作。以FETCHING_ARTICLEFETCHING_ARTICLES动作为例。我们希望在实际发出网络请求之前更改 UI。我认为这种明确性是 Flux 和 Redux 的真正价值。我们确切地知道为什么会有变化。这是明确的,但不是冗长的。

主组件

图中缺少的 Redux 架构的最后一个主要部分是 action creator 函数。这些由组件调用,以便将有效负载分派到 Redux 存储。分派任何操作的最终结果是状态的改变。但是,某些操作需要先转到并获取状态,然后才能作为有效负载发送到存储区。

让我们看看我们的Neckbeard News应用的Home组件。它将向您展示如何在将组件连接到 Redux 商店时传递 action creator 函数。代码如下:

import React, { Component, PropTypes } from 'react'; 
import { connect } from 'react-redux'; 
import { Link } from 'react-router'; 
import { Map } from 'immutable'; 

// What to render when the article list is empty 
// (true/false). When it's empty, a single ellipses 
// is displayed. 
const emptyMap = Map() 
  .set(true, (<li style={listItemStyle}>...</li>)) 
  .set(false, null); 

class Home extends Component { 
  static propTypes = { 
    articles: PropTypes.arrayOf( 
      PropTypes.object 
    ).isRequired, 
    fetchingArticles: PropTypes.func.isRequired, 
    fetchArticles: PropTypes.func.isRequired, 
    toggleArticle: PropTypes.func.isRequired, 
    filter: PropTypes.string.isRequired, 
  } 

  static defaultProps = { 
    filter: '', 
  } 

  // When the component is mounted, there's two actions 
  // to dispatch. First, we want to tell the world that 
  // we're fetching articles before they're actually 
  // fetched. Then, we call "fetchArticles()" to perform 
  // the API call. 
  componentWillMount() { 
    this.props.fetchingArticles(); 
    this.props.fetchArticles(this.props.filter); 
  } 

  // When an article title is clicked, toggle the state of 
  // the article by dispatching the toggle article action. 
  onTitleClick = id => () => 
    this.props.toggleArticle(id); 

  render() { 
    const { onTitleClick } = this; 
    const { articles } = this.props; 

    return ( 
      <ul style={listStyle}> 
        {emptyMap.get(articles.length === 0)} 
        {articles.map(a => ( 
          <li key={a.id} style={listItemStyle}> 
            <button 
              onClick={onTitleClick(a.id)} 
              style={titleStyle} 
            > 
              {a.title} 
            </button> 
            { /* The summary of the article is displayed 
                 based on the "display" property. This state 
                 is toggled when the user clicks the title. */ } 
            <p style={{ display: a.display }}> 
              <small> 
                <span>{a.summary} </span> 
                <Link to={`articles/${a.id}`}>More...</Link> 
              </small> 
            </p> 
          </li> 
        ))} 
      </ul> 
    ); 
  } 
} 

// The "connect()" function connects this component 
// to the Redux store. It accepts two functions as 
// arguments... 
export default connect( 
  // Maps the immutable "state" object to a JavaScript 
  // object. The "ownProps" are plain JSX props that 
  // are merged into Redux store data. 
  (state, ownProps) => Object.assign( 
    state.get('Home').toJS(), 
    ownProps 
  ), 

  // Sets the action creator functions as props. The 
  // "dispatch()" function is what actually invokes 
  // store reducer functions that change the state 
  // of the store, and cause new prop values to be passed 
  // to this component. 
  dispatch => ({ 
    fetchingArticles: () => dispatch({ 
      type: 'FETCHING_ARTICLES', 
    }), 

    fetchArticles: (filter) => { 
      const headers = new Headers(); 
      headers.append('Accept', 'application/json'); 

      fetch(`/api/articles/${filter}`, { headers }) 
        .then(resp => resp.json()) 
        .then(json => dispatch({ 
          type: 'FETCH_ARTICLES', 
          payload: json, 
        })); 
    }, 

    toggleArticle: payload => 
      dispatch({ 
        type: 'TOGGLE_ARTICLE', 
        payload, 
      }), 
  }) 
)(Home); 

让我们关注一下connect()函数,它用于将Home组件连接到存储。第一个参数是一个函数,它从存储中获取相关状态,并将其作为该组件的props返回。它使用ownProps以便我们可以直接将props传递给组件,并覆盖存储中的任何内容。filter属性是我们需要此功能的原因。

第二个参数是一个函数,它将操作创建者函数返回为propsdispatch()函数是这些 action creator 函数如何将有效负载传送到商店的。例如,toggleArticle()函数是直接对dispatch()的一个简单调用,在用户点击文章标题时被调用。然而,fetchingArticles()调用涉及异步行为。这意味着在fetch()承诺解决之前不会调用dispatch()。我们有责任确保其间不会发生意外。

让我们通过查看与Home组件一起使用的减速器功能来总结一下:

import { fromJS } from 'immutable'; 

const typeMap = fromJS({ 

  // Clear any old articles right before 
  // we fetch new articles. 
  FETCHING_ARTICLES: state => 
    state.update('articles', a => a.clear()), 

  // Articles have been fetched. Update the 
  // "articles" state, and make sure that the 
  // summary display is "none". 
  FETCH_ARTICLES: (state, payload) => 
    state.set( 
      'articles', 
      fromJS(payload) 
        .map(a => a.set('display', 'none')) 
    ), 

  // Toggles the state of the selected article 
  // "id". First we have to find the index of 
  // the article so that we can update it's 
  // "display" state. If it's already hidden, 
  // we show it, and vice-versa. 
  TOGGLE_ARTICLE: (state, id) => 
    state.updateIn([ 
      'articles', 
      state 
        .get('articles') 
        .findIndex(a => a.get('id') === id), 
      'display', 
    ], display => 
      display === 'none' ? 
        'block' : 'none' 
    ), 
}); 

export default (state, { type, payload }) => 
  typeMap.get(type, s => s)(state, payload); 

这里使用了基于动作类型使用类型映射更改状态的相同技术。同样,这段代码很容易推理,但系统中可能发生的所有变化都是明确的。

移动应用中的状态

在 React 本地移动应用中使用 Redux 怎么样?当然,如果您正在为 web 和本机平台开发相同的应用,您应该这样做。事实上,我已经在 iOS 和 Android 的 React Native 中实现了Neckbeard News。我鼓励您下载本书的代码,并让此应用同时在 web 和本机移动设备上运行。

在移动应用中实际使用 Redux 的方式没有什么区别。唯一的区别在于所使用的状态的形状。换句话说,不要认为你可以在你的应用的 web 版本和本机版本中使用完全相同的 Redux store 和 reducer 功能。考虑一下 React 本地组件。对于许多事情,没有一刀切的组件。有些组件针对 iOS 平台进行了优化,而有些组件针对 Android 平台进行了优化。这和 Redux 州的想法是一样的。以下是移动Neckbeard News的初始状态:

import { fromJS } from 'immutable'; 

export default fromJS({ 
  Main: { 
    title: 'All', 
    component: 'articles', 
  }, 
  Categories: { 
    items: [ 
      { 
        title: 'All', 
        filter: '', 
        selected: true, 
      }, 
      { 
        title: 'Local', 
        filter: 'local', 
        selected: false, 
    }, 
      { 
        title: 'Global', 
        filter: 'global', 
        selected: false, 
      }, 
      { 
        title: 'Tech', 
        filter: 'tech', 
        selected: false, 
      }, 
      { 
        title: 'Sports', 
        filter: 'sports', 
        selected: false, 
      }, 
    ], 
  }, 
  Articles: { 
    filter: '', 
    items: [], 
  }, 
  Article: { 
    full: '', 
  }, 
}); 

正如您所看到的,在 Web 环境中应用的原则同样适用于移动环境。只是状态本身不同,以支持我们正在使用的给定组件以及您使用它们实现应用的独特方式。

扩展架构

到目前为止,您可能已经很好地掌握了通量概念、Redux 机制以及如何使用它们为 React 应用实现良好的信息架构。接下来的问题是,这种方法的可持续性如何,它能否处理任意大型和复杂的应用?

我认为 Redux 是实现大规模 React 应用的一种很好的方法。您可以预测任何给定操作的结果,因为一切都是明确的。它是声明性的。它是单向的,没有副作用。但是,这并非没有挑战。

Redux 的限制因素也是它的面包和黄油;因为一切都是显式的,所以在特征数量和复杂性方面需要扩展的应用最终会有更多的运动部件。这没什么错;这只是游戏的本质。扩大规模不可避免的后果是放缓。你根本不可能掌握足够的全局,以便快速实施。

在本书的最后两章中,我们将介绍一种相关但不同的通量方法;Relay/图形 ql。我认为这项技术可以以 Redux 无法实现的方式进行扩展。

总结

在本章中,您学习了 Flux,这是一组体系结构模式,有助于为您的应用构建信息体系结构。Flux 的关键思想包括单向数据流、同步更新循环和可预测的状态转换。

接下来,我们详细介绍了 Redux/React 应用的实现。Redux 提供了 Flux 思想的简化实现。好处是在任何地方都可以预测。

然后,我们讨论了 Redux 是否具备为 React 应用构建可伸缩架构的能力。大多数情况下,答案是肯定的。然而,在本书的其余部分中,我们将探索 Relay 和 GraphQL,看看这些技术是否可以将我们的应用扩展到下一个级别。