w3ctech

React 进阶第三部分: Redux

本文是 Brad Westfall 的 React 三篇系列教程的最后一篇。我们将学习如何以高效而并非超级复杂的方式管理整个应用程序的状态。在 React 的旅程中,我们已经走了这么远,该在这里穿过终点线,并从这种开发方法中获得最大的回报了。

系列文章

Redux 是一种在 JavaScript 应用程序中管理数据状态和 UI 状态的工具。对于某些单页应用程序(SPA)来说,要管理随着时间推移而改变的状态,是很复杂的。这时候,Redux 就是很理想的解决方案。它也是无框架无关的,所以虽然它用 React 写的,但是也可以用于 Angular 或者 jQuery 应用程序。

再加上,它是从“时空旅行”的实验中孕育出来的 - 真相:你为什么开发 Redux,我们后面会接触到!

如在前面的教程所见,React 在组件之间“流动”数据。更特殊的是,这种流动是单向的(即单向数据流) - 数据是从父组件到子组件,沿着一个方向流动。然而,在 React 中,如果两个组件之间并非父子关系,那么它们该如何进行通讯呢?

React 不推荐上图所示的这种组件到组件直接进行通讯的方式。即使它有功能来支持这种方式,很多人也认为这是不佳的实践。因为组件到组件的直接通讯是很容易出错的,会导致意大利面条式的代码

React 确实也提供了一个建议,但是他们指望你自己去实现它。如下是从 React 文档中摘取的一段话:

对于没有父子关系的两个组件之间的通讯,你可以设置自己的全局事件系统. ... Flux 模式是实现它的可能方式之一。

这正是 Redux 可以大显身手的地方。Redux 提供了一种将应用程序的所有状态保存到一个地方的解决方案,这个地方称为 "store"。然后,组件将状态改变分发给 store,而不是直接传给其它组件。需要知道状态改变的组件可以订阅 store:

store 可以被认为是应用程序中所有状态改变的"中间人"。在 Redux 介入下,组件之间不会直接通讯,而是所有的状态改变必须经过单一数据源 - store。

这与其它策略 有很大不同。在这些其它策略中,应用程序的组件之间是直接进行相互通讯。有时,这些策略被认为是会导致出错并且难以推理的:

在使用 Redux 时,所有组件都从 store 中获取状态,并且,组件应该把它们的状态改变也发送给 store。发起改变的组件只需要关心将改变分发到 store,不需要操心需要状态改变的其它组件列表。这就是 Redux 让数据流变得更容易推理的原因。

使用 store 来协调应用程序的状态,这个通用的概念是一种称为 Flux的模式。它是一种单向数据流架构的设计模式。Redux 类似于 Flux,但是它们有多接近呢?

Redux "像 Flux"

Flux 是一种模式,而不是像 Redux 这样的工具,所以它不是你可以下载的东西。但是 Redux 是一种工具,其灵感来自于 Flux 模式以及Elm。有很多指南都会比较 Redux 和 Flux,很多会得出结论:Redux 是 Flux 或者像 Flux。当然这都不算什么,关键是 Facebook 很喜欢并支持 Redux,所以雇佣了 Redux 的主要开发者 Dan Abramov

本文假定你完全不熟悉 Flux 模式。但是如果是熟悉的话,你会注意到一些小的不同,特别是考虑 Redux 的三个指南原则

1. 单一数据源

Redux 只使用一个 store 存储应用程序的所有状态。因为所有状态只在一个地方,所以 Redux 称之为单一数据源

store 的数据结构最终取决于你,但是对于真实应用程序,它通常是一个深度嵌套的对象。

Redux 使用一个 store,Flux 使用多个 store,这是二者之间的主要区别之一。

2. 状态是只读的

根据 Redux 文档:修改状态的唯一方法是触发一个 action,即一个描述发生了什么的对象

这意味着应用程序不能直接修改状态,而是通过分发 action,来表达要改变 store 中的状态的意图。

store 对象本身的 API 很少,只有四个方法:

  • store.dispatch(action)
  • store.subscribe(listener)
  • store.getState()
  • replaceReducer(nextReducer)

正如你所见,没有设置状态的方法。因此,分发一个 action 是应用程序代码表达状态改变的唯一方法

var action = {
  type: 'ADD_USER',
  user: {name: 'Dan'}
};

// 假设已经创建了 store 对象
store.dispatch(action);

dispatch() 方法发送一个称为 action 的对象给 Redux。action 可以被描述为一个"负载(payload)",它带有一个 type 以及可以用来更新状态的所有其它数据 - 在本例中是 user。记住,在 type 属性后,action 对象的设计是取决于你的。

3. 使用纯函数来执行修改

如刚才所述,Redux 是不允许应用程序直接改修状态的,而是通过分发的 action 来描述状态改变以及修改状态的意图。Reducer 是你编写的函数,用来处理分发的 action,并且可以直接修改状态。

一个 reducer 会把当前状态作为参数,并且只能通过返回新的状态来修改状态:

// Reducer 函数
var someReducer = function(state, action) {
  ...
  return state;
}

reducer 必须被写为纯函数。纯函数有如下特征:

  • 它不会发起对外部网络或者数据库的调用。
  • 其返回值完全依赖于其参数值。
  • 其参数应该被当作是不可修改的,就是说不能被修改。
  • 调用参数相同的纯函数总是返回相同的值。

之所以叫做"纯",是因为它们除了基于参数返回一个值外,什么也不做。对于系统的其它部分没有任何副作用。

第一个 Redux Store

首先,用 Redux.createStore() 创建一个 store,并把所有 reduce 传递进来作为参数。我们来看一个只有一个 reducer 的小示例:

// 注意:在这种方式中用 .push() 并不是最好的方法。它只是展示这个示例的最简单的方法。我们会在下一节解释。

// Reducer 函数
var userReducer = function(state, action) {
  if (state === undefined) {
    state = [];
  }
  if (action.type === 'ADD_USER') {
    state.push(action.user);
  }
  return state;
}

// 通过传递 reducer 来创建一个 store
var store = Redux.createStore(userReducer);

// 分发第一个 action 来表达修改状态的意图
store.dispatch({
  type: 'ADD_USER',
  user: {name: 'Dan'}
});

这里对发生的事情做一个概括:

  1. 创建带有一个 reducer 的 store。
  2. reducer 设置应用程序的初始状态为一个空数组。
  3. 分发一个 action,这个 action 是用来添加一个新用户。
  4. reducer 将新用户添加到状态上,并返回新的状态,这会更新 store。

注意:本例中 reduce 实际上是被调用了两次 — 一次是 store 被创建时,然后在分发后再次被调用。

当 store 被创建时,Redux 会立即调用 reducer,并使用其返回值作为初始状态。这个第一次对 reducer 的调用,会发送 undefined 给 state。reducer 代码预料到这,会返回一个空数组来启动 store 的初始状态。

reducer 还在每次分发 action 时被调用。因为从一个 reducer 返回的状态会成为 store 中的新状态,所以 Redux 总是期望 reducer 返回状态

在本例中,第二次对 reducer 的调用在分发之后出现。记住,一个分发了的 action 描述修改状态的意图,并且经常会带有新状态的数据。此时,Redux 传递当前状态(依然是空数组)以及 action 对象给 reducer。action对象现在有一个值为 ADD_USER 的 type 属性,让 reducer 知道如何修改状态。

很容易把 reducer 当作允许状态通过的漏斗。这是因为 reducer 总是接收和返回状态,以更新 store:

根据这个示例,我们的 store 现在将是有一个 user 对象的数组:

store.getState();   // => [{name: 'Dan'}]

不要修改状态,复制它

虽然我们的示例中的 reducer 从技术上是可以工作的,但是它修改状态的方式是不佳的实践。即使 reducer 负责修改状态,它们永远不应该直接修改"当前状态"参数。这就是为什么我们不应该在 reducer 的 state 参数上使用 .push() 方法的原因,因为该方法是一个会修改自身的方法

传给 reducer 的参数应该被当作是不可变的。换句话说,它们不应该被直接修改。应该用不会改变自身的方法 ,比如 .concat() ,给数组做一个副本,然后修改和返回这个副本:

var userReducer = function(state = [], action) {
  if (action.type === 'ADD_USER') {
    var newState = state.concat([action.user]);
    return newState;
  }
  return state;
}

有了这个对 reducer 的更新,添加一个新用户会导致 state 参数的一个副本被修改和返回。当没有添加一个新用户时,返回的是原始 state,而不是副本。

下面的不可变的数据结构一节,我们会用整个小节解释最佳实践的那些类型。

你可能还会注意到初始状态现在是以ES2015 默认参数的形式出现。在本教程中,迄今为止,都在避免使用 ES2015,这样可以让我们把注意力放在主要的主题上。但是,Redux 用 ES2015 会更舒服。因此,我们将从本文开始使用 ES2015。不过不要担心,每次使用 ES2015 新功能时,都会指出并解释。

多个 Reducer

上一个示例很初级,但是大多数应用程序会需要对于整个应用程序的更复杂的状态。因为 Redux 只使用一个 store,我们会需要使用嵌套的对象,将状态组织到不同的部分。现在假设我们想让我们的 store 像如下这种对象:

{
  userState: { ... },
  widgetState: { ... }
}

这里对于整个应用程序依然是“一个 store 等于一个对象“,但是它有嵌套的对象 userStatewidgetState,这两个对象可以包含各种数据。这貌似有点过于简化,但是它实际上离真实的 Redux store 已经不那么远了。

为了创建嵌套对象的 store,我们需要给每个部分定义一个 reducer:

import { createStore, combineReducers } from 'redux';

//  User Reducer
const userReducer = function(state = {}, action) {
  return state;
}

// Widget Reducer
const widgetReducer = function(state = {}, action) {
  return state;
}

// 组合 Reducers
const reducers = combineReducers({
  userState: userReducer,
  widgetState: widgetReducer
});

const store = createStore(reducers);

ES2015 警报!本例中的四个主要的“变量”将不会修改,所以我们将它们定义为 constant。我们还用到了ES2015 模块和解构

combineReducers() 的使用让我们可以根据不同的逻辑部分来描述我们的 store,并且将 reducer 指派给每个部分。现在,当每个 reducer 返回初始状态时,该状态会进入 store 中各自的 useStatewidgetState部分。

这里需要重点指出的是,现在每个 reducer 得到的是传过来的整体状态中自己的那一部分,而不是像单个 reducer 示例中那样的整个 store。然后,从每个 reducer 返回的状态应用到它的那一部分上。

分发之后哪个 Reducer 被调用?

答案是所有的 Reducer。把 reducer 比做漏斗更明显,当我们考虑每次分发一个 action 时,所有 reducer 都会被调用,并且有机会更新它们各自的 state:

我小心翼翼地说“它们的”状态,是因为 reducer 的“当前状态”参数以及它返回的“更新了的”状态只影响该 reducer 在 store 中的部分。记住,正如前面小节中所述:每个 reducer 只得到传递过来的各自的状态,而不是整个状态。

Action 策略

创建和管理 action 以及 action 类型实际上有相当多的策略。虽然很高兴知道这些,但是这些策略与本文中一些其它信息相比,并没有那么重要。为让本文更短点,我们已经把你应该知道的基础 action 策略放在本文配套的GitHub 代码库中。

不可变的数据结构

state 的结构取决于你:可以是原始类型、数组、对象、甚至是一个 Immutable.js 数据结构。唯一重要的部分是:你不应该修改状态对象,而是在状态改变时返回一个新对象。 - Redux docs

这句话说了很多,我们也已经在本教程中提到了这个观点。如果我们要开始讨论什么是不可变的和可变的这两种方式的详情,以及优点和缺点,可能需要一整篇博文的信息量才搞得定。所以这里我只打算强调几个主要点。

首先:

  • JavaScript 的基础数据类型(NumberStringBooleanUndefinedNull)已经是不可变的
  • 对象数组函数可变的

有人说数据结构上的可变性容易导致出错。因为 store 将是由 state 对象和数组组成,所以我们需要实现一种让状态保持不可变的策略。

假设有一个 state 对象,我们需要改变该对象的一个属性。这里有三种方式:

// 示例1
state.foo = '123';

// 示例2
Object.assign(state, { foo: 123 });

// 示例3
var newState = Object.assign({}, state, { foo: 123 });

第一个和第二个示例都修改了 state 对象。第二个示例修改该对象,是因为 Object.assign() 会把它所有参数合并为第一个参数。但是这也是为什么第三个例子不能修改状态的原因。

第三个示例将 state{foo: 123} 的内容合并到一个全新的空对象中。这是一个常见的技巧,本质上是让我们创建状态的一个副本,对副本进行修改,而不影响原始的 state

对象的“扩展运算符”是另一种让状态保持不可变的方式:

const newState = { ...state, foo: 123 };

如果要知道这到底是怎么回事,以及为什么这样做对 Redux 是很好的,请查看在这个主题上的很详细的解释文档

Object.assign() 和扩展运算符都是 ES2015 的功能。

总之,有很多方法可以显式地让对象和数组保持不可变。很多开发者使用像 seamless-immutableMori, 或者 Facebook 自己的 Immutable.js库来实现。

我很仔细挑选了有关不可变性的一些博客和文章。如果你不理解不可变性,请阅读上面引用的链接。这个概念对于成功使用 Redux 很重要。

初始状态和时空旅行

如果阅读 Redux API 文档,你可能会注意到 createStore() 的第二个参数是用于"初始状态"的。这也许看起来像用 reducer 创建初始状态的一个可选方案。但是,这个初始状态应该只被用于"状态合成(state hydration)".

设想一下,假如一个用户在你的单页应用程序上刷新时,store 的状态就被重置为 reducer 的初始状态,这可能就不是我们想要的。

相反,想像一下假如我们已经有一种策略来保存 store,那么我们就可以在用户刷新时,重新将它合成到 Redux。这就是将初始状态发送到 createStore() 的原因。

但是这带来一个有趣的概念。如果如此轻易就可以重新合成旧的状态,有人可能会想到在他们的应用程序中实现状态“时空旅行”的等价物。这对调试,甚至undo/redo 功能很有用。出于这些以及很多其他原因,把所有状态放在一个 store 中就很有意义!这只是为什么不可变的状态对我们有帮助的一个原因。

在一次采访中,Dan Abramov 被问到"你为什么要开发 Redux"时,他说:

我不是故意要创建一个 Flux 框架。当 React Europe 会议第一次宣布时,我提议讲讲“热替换和时空旅行”,但是老实讲,那时我也不知道如何实现时空旅行。

在 React 中使用 Redux

正如我们已经讨论过的,Redux 是与框架无关的。在你还在考虑如何在 React 中使用 Redux 之前,先理解 Redux 的核心概念是很重要的。但是现在,我们准备采用上一篇文章的一个容器组件,将 Redux 应用到它上面。

首先,这里是一个没有 Redux 的原始组件:

import React from 'react';
import axios from 'axios';
import UserList from '../views/list-user';

const UserListContainer = React.createClass({
  getInitialState: function() {
    return {
      users: []
    };
  },

  componentDidMount: function() {
    axios.get('/path/to/user-api').then(response => {
      this.setState({users: response.data});
    });
  },

  render: function() {
    return <UserList users={this.state.users} />;
  }
});

export default UserListContainer;

注意,这里又用到了 ES2015!本例已经对原来的代码略做转换,其中用到了 ES2015 模块和箭头函数

当然,这个组件发起 AJAX 请求,并更新它自己的本地状态。但是如果应用程序中的其它区域需要基于新获取的用户列表而改变,那么这种策略就满足不了。

如果使用 Redux 策略,我们可以在 AJAX 请求返回时,分发一个 action,而不是执行 this.setState()。然后,这个组件和其它组件可以订阅状态改变。但是这实际上又给我们带来一个问题:该如何设置 store.subscribe() 来更新组件的状态呢?

我想我可以提供几个手动将组件接通到 Redux store 的示例。你也可以想像一下用你自己的方式会是什么样子。但是最终,在这些示例的末尾,我会解释有一个更好的方式,忘了手动的示例吧。然后我会介绍官方的 React/Redux 绑定模块 react-redux。所以我就不折腾了,直接开讲 React-redux 好了。

react-redux 连接

说白了吧,reactreduxreact-redux 是 npm 中的三个不同模块。react-redux 模块能让我们用更方便的方式将 React 组件连接到 Redux。

下面是一个示例:

import React from 'react';
import { connect } from 'react-redux';
import store from '../path/to/store';
import axios from 'axios';
import UserList from '../views/list-user';

const UserListContainer = React.createClass({
  componentDidMount: function() {
    axios.get('/path/to/user-api').then(response => {
      store.dispatch({
        type: 'USER_LIST_SUCCESS',
        users: response.data
      });
    });
  },

  render: function() {
    return <UserList users={this.props.users} />;
  }
});

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

export default connect(mapStateToProps)(UserListContainer);

这里有很多新事情发生:

  1. 我们已经从 react-redux 导入了 connect 函数。
  2. 这段代码如果从连接开始,自底向上看,更容易看懂。connect() 函数实际上带有两个参数,但是这里我们只展示一个 mapStateToProps()

    这里看到 connect()() 带有两对括号可能有点怪异。这实际上是两次函数调用。第一对括号是为了让 connect() 返回另一个函数。当然,我们可以把该函数赋值给一个名称,然后调用它。但是既然我们可以用第二对括号立即调用它,那么这么做有何必要呢?而且,反正在第二个函数被调用之后,我们没有理由需要第二个函数名的存在。当然,第二个函数需要你传递一个 React 组件。在本例中这个组件是我们的容器组件。 我理解你会想“为什么要搞的这么复杂”,但是这实际上是一个常见的函数式编程范式,所以你就学它好了。

  3. connect() 的第一个参数是一个函数,该函数必须返回一个对象。这个对象的属性必须成为组件上的 "props"。你可以看到它们的值来自于状态。现在,你知道了函数名 "mapStateToProps" 的意思吧。你还要注意 mapStateToProps() 接收的参数是整个 Redux store。mapStateToProps() 的主要想法是把这个组件所需的整个状态的哪些部分隔离为它的 props。

  4. 根据第三条提到的理由,我们不再需要 getInitialState() 的存在。还注意到我们用的是 this.props.users,而不是 this.state.users,因为 users 数组现在是一个 prop,而不是本地组件状态。
  5. 现在AJAX 返回的是分发一个 action,而不是更新本地组件状态。为简洁起见,我们现在不用action 生成器 或者 action 类型常量

这段代码示例对 user reducer 是如何工作做了假设,当然这也许不明显。我们可以看到 store 有一个 userState 属性。但是这个属性名来自哪里呢?

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

这个名字来自于我们组合 reducers 时:

const reducers = combineReducers({
  userState: userReducer,
  widgetState: widgetReducer
});

那么 userState.users 属性是咋回事呢?它又是来自于哪里呢?

虽然我们不能展示该示例的实际 reducer(因为它可能在另一个文件中),但是就是这个 reducer 来判断它各自状态的子属性。要确保 .usersuserState 的一个属性,这些示例的 reducer 可能看起来像这样:

const initialUserState = {
  users: []
}

const userReducer = function(state = initialUserState, action) {
  switch(action.type) {
  case 'USER_LIST_SUCCESS':
    return Object.assign({}, state, { users: action.users });
  }
  return state;
}

AJAX 生命周期分发

在 AJAX 示例中,我们只分发了一个 action。这个 action 故意被称为'USER_LIST_SUCCESS',是因为我们也想在AJAX 启动前分发'USER_LIST_REQUEST',在AJAX 失败时候分发 'USER_LIST_FAILED'。请确保阅读 Redux 文档中的异步 Action

从事件分发

在前一文章中,我们看到事件应该从容器组件向下传递到展示性组件。当在一个事件只需要分发一个 action 的情况下,react-redux 被证明是对此也有帮助:

...

const mapDispatchToProps = function(dispatch, ownProps) {
  return {
    toggleActive: function() {
      dispatch({ ... });
    }
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(UserListContainer);

在展示性组件中,我们可以像之前一样,执行onClick={this.props.toggleActive},但是此时我们不必写事件本身。

容器组件遗漏

有时,容器组件只需要订阅 store,不需要像 componentDidMount() 这样的任何方法来发起 AJAX 请求。它可能只需要一个 render() 方法,将状态向下传递到展示性组件。在这种情况下,我们可以用如下方法创建容器组件:

import React from 'react';
import { connect } from 'react-redux';
import UserList from '../views/list-user';

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

export default connect(mapStateToProps)(UserList);

是的,兄弟们,这就是新的容器组件的全部文件。但是等等,容器组件在哪里?为什么这里我们就没用到 React.createClass() 呢?

其实,是 connect() 为我们创建了一个容器组件。注意,这次我们是直接将展示性组件传递进来,而不是创建要传递进来的我们自己的容器组件。如果你确实要思考容器组件做什么,记住:它们的存在是为了让展示性组件只关注于视图,而不是状态。它们还把状态传进子视图为 props。这刚好就是 connect() 所做的事情 - 通过 props 传递状态给我们的展示性组件,并且实际上返回一个包含了展示性组件的 React 组件。本质上,这个包含体就是一个容器组件。

所以,是否这意味着之前的示例实际上是包含了那个展示性组件的两个容器组件呢?是的,你可以这样认为。但是这不是一个问题,它只是在我们的容器组件需要更多除 render() 以外的 React 方法时所必需的。

把这两个容器组件当作是作用不同,但是相关的两个角色:

嗯,也许这是为什么 React 的商标看起来像一个原子的原因!

Provider

为了让这段 react-redux 代码起作用,你需要让应用程序知道如何用一个 <provider /> 组件使用 react-redux。这个组件包含了整个 React 应用程序。如果你正在用 React Router,那么它看起来就像这样:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import router from './router';

ReactDOM.render(
  <Provider store={store}>{router}</Provider>,
  document.getElementById('root')
);

正是绑定给 Provider 的 store 通过 react-redux 将 React 和 Redux 连接在一起。这个文件是what you're main entry-point might look like这篇文章的一个示例.

在 React Router 中使用 Redux

虽然这并非必需的,但是有另一个 npm 项目称为 react-router-redux。因为路由在技术上是 UI 状态的一部分,而 React Router 是不知道 Redux 的,所以这个项目可以用来将二者链接在一起。

你看到我在这里做了什么吗?我们转了一圈,又回到第一篇文章:学习 React Router!

最终项目

本系列教程的最终项目指南让你可以创建一个小型的 "Users 和 Widgets" 单页应用程序:

Final Preview

对于本系列的其它文章,每个都带有一个指南来说明指南在 Github 上如何操作。

总结

衷心希望你像我写这个系列文章时一样享受它。我知道在 React 上还有很多主题没有涉及到(表单就是一个),但是我试着忠于一个前提:我想给 React 新手一种如何闯过基础的感觉,以及创建一个单页应用程序是一种什么体验。

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复