Redux 包教包会(二):趁热打铁,重拾初心

在这一部分中,我们将趁热打铁,运用上篇教程学到的 Redux 三大核心概念来将待办事项的剩下部分重构完成,它涉及到将 TodoList 和 Footer 部分的相关代码重构到 Redux,并使用 Redux combineReducers API 进行逻辑拆分和组合,使得我们可以在使用 Redux 便利的同时,又不至于让应用的逻辑看起来臃肿不堪,复用 React 组件化的便利,我们可以让状态的处理也 “组件化”。最后,我们将让 React 回归初心——专注于展现用户界面,通过“容器组件”和“展示组件”将逻辑和状态进一步分离。

欢迎阅读 Redux 包教包会系列:

此教程属于React 前端工程师学习路线的一部分,欢迎来 Star 一波,鼓励我们继续创作出更好的教程,持续更新中~。

重构代码:将 TodoList 部分迁移到 Redux

上一篇教程中,我们已经把 Redux 的核心概念讲完了,并且运用这些概念重构了一部分待办事项应用,在这一小节中,我们将完整地运用之前学到的知识,继续用 Redux 重构我们的应用。如果你没有读过上篇教程,想直接从这一步开始,那么请运行以下命令:

git clone https://github.com/pftom/redux-quickstart-tutorial.git
cd redux-quickstart-tutorial
git checkout second-part
npm install && npm start

此时如果你在浏览器里面尝试查看这个待办事项小应用,你会发现它还只可以添加新的待办事项,对于 “完成和重做待办事项” 以及 “过滤查看待办事项” 这两个功能,目前我们还没有使用 Redux 实现。所以当你点击单个待办事项时,浏览器会报错;当你点击底部的三个过滤器按钮时,浏览器不会有任何反应。

在这一小节中,我们将使用 Redux 重构 “完成和重做待办事项” 功能,即你可以通过点击某个待办事项来完成它。

我们将运用 Redux 最佳实践的开发方式来重构这一功能:

  • 定义 Action Creators
  • 定义 Reducers
  • connect 组件以及在组件中 dispatch Action

以后在开发 Redux 应用的时候,都可以使用这三步流程来周而复始地开发新的功能,或改进现有的功能。

定义 Action Creators

首先我们要定义 “完成待办事项” 这一功能所涉及的 Action,打开 src/actions/index.js,在最后面添加 toggleTodo

// 省略 nextTodoId 和 addTodo ...

export const toggleTodo = id => ({
  type: "TOGGLE_TODO",
  id
});

可以看到,我们定义并导出了一个 toggleTodo 箭头函数,它接收 id 并返回一个类型为 "TOGGLE_TODO" 的 Action。

定义 Reducers

接着我们来定义响应 dispatch(action) 的 Reducers,打开 src/index.js,修改 rootReducer 函数如下:

// ...

const rootReducer = (state, action) => {
  switch (action.type) {
    case "ADD_TODO": {
      const { todos } = state;

      return {
        ...state,
        todos: [
          ...todos,
          {
            id: action.id,
            text: action.text,
            completed: false
          }
        ]
      };
    }

    case "TOGGLE_TODO": {
      const { todos } = state;

      return {
        ...state,
        todos: todos.map(todo =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        )
      };
    }
    default:
      return state;
  }
};

// ...

可以看到,我们在 switch 语句里面添加了一个 "TOGGLE_TODO" 的判断,并根据 action.id 来判断对应操作的 todo,取反它目前的 completed 属性,用来表示从完成到未完成,或从未完成到完成的操作。

connect 和 dispatch(action)

当定义了 Action,声明了响应 Action 的 Reducers 之后,我们开始定义 React 和 Redux 交流的接口:connectdispatch,前者负责将 Redux Store 的内容整合进 React,后者负责从 React 中发出操作 Redux Store 的指令。

我们打开 src/components/TodoList.js 文件,对文件内容作出如下的修改:

// 省略没有变化的 import 语句 ...

import { connect } from "react-redux";
import { toggleTodo } from "../actions";

const TodoList = ({ todos, dispatch }) => (
  <ul>
    {todos.map(todo => (
      <Todo
        key={todo.id}
        {...todo}
        onClick={() => dispatch(toggleTodo(todo.id))}
      />
    ))}
  </ul>
);

TodoList.propTypes = {
  todos: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      completed: PropTypes.bool.isRequired,
      text: PropTypes.string.isRequired
    }).isRequired
  ).isRequired
};

export default connect()(TodoList);

可以看到,我们对文件做出了以下几步修改:

  • 首先从 react-redux 中导出 connect 函数,它负责给 TodoList 传入 dispatch 函数,使得我们可以在 TodoList 组件中 dispatch Action。
  • 然后我们导出了 toggleTodo Action Creators,并将之前从父组件接收 toggleTodo 方法并调用的方式改成了当 Todo 被点击之后,我们 dispatch(toggle(todo.id))
  • 我们删除 propsTypes 中不再需要的 toggleTodo

删除无用代码

当我们通过以上三步整合了 Redux 的内容之后,我们就可以删除原 App.js 中不必要的代码了,修改后的 src/components/App.js 内容如下:

// ...

class App extends React.Component {
  constructor(props) {
    super(props);

    this.setVisibilityFilter = this.setVisibilityFilter.bind(this);
  }

  setVisibilityFilter(filter) {
    this.setState({
      filter: filter
    });
  }

  render() {
    const { todos, filter } = this.props;

    return (
      <div>
        <AddTodo />
        <TodoList todos={getVisibleTodos(todos, filter)} />
        <Footer
          filter={filter}
          setVisibilityFilter={this.setVisibilityFilter}
        />
      </div>
    );
  }
}

// ...

可以看到,我们删除了 toggleTodo 方法,并对应删除了定义在 constructor 中的 toggleTodo 定义以及在 render 方法中,传给 TodoListtoggleTodo 属性。

保存上述修改的代码,打开浏览器,你应该又可以点击单个待办事项来完成和重做它了:

小结

在本节中,我们介绍了开发 Redux 应用的最佳实践,并通过重构 "完成和重做待办事项“ 这一功能来详细实践了这一最佳实践。

重构代码:将 Footer 部分迁移到 Redux

这一节中,我们将继续重构剩下的部分。我们将继续遵循上一节提到的 Redux 开发的最佳实践:

  • 定义 Action Creators
  • 定义 Reducers
  • connect 组件以及在组件中 dispatch Action

定义 Action Creators

打开 src/actions/index.js 文件,在最后面添加 setVisibilityFilter

// ...

export const setVisibilityFilter = filter => ({
  type: "SET_VISIBILITY_FILTER",
  filter
});

可以看到我们创建了一个名为 setVisibilityFilter 的 Action Creators,它接收 filter 参数,然后返回一个类型为 "SET_VISIBILITY_FILTER" 的 Action。

定义 Reducers

打开 src/index.js 文件,修改 rootReducer 如下:

// ...

const rootReducer = (state, action) => {
  switch (action.type) {
    // 省略处理 ADD_TODO 和 TOGGLE_TODO 的 reducers ...

    case "SET_VISIBILITY_FILTER": {
      return {
        ...state,
        filter: action.filter
      };
    }

    default:
      return state;
  }
};

// ...

可以看到,我们增加了一条 case 语句,来响应 "SET_VISIBILITY_FILTER" Action,通过接收新的 filter 来更新 Store 中的状态。

connect 和 dispatch(action)

打开 src/components/Footer.js 文件,修改代码如下:

import React from "react";
import Link from "./Link";
import { VisibilityFilters } from "./App";

import { connect } from "react-redux";
import { setVisibilityFilter } from "../actions";

const Footer = ({ filter, dispatch }) => (
  <div>
    <span>Show: </span>
    <Link
      active={VisibilityFilters.SHOW_ALL === filter}
      onClick={() => dispatch(setVisibilityFilter(VisibilityFilters.SHOW_ALL))}
    >
      All
    </Link>
    <Link
      active={VisibilityFilters.SHOW_ACTIVE === filter}
      onClick={() =>
        dispatch(setVisibilityFilter(VisibilityFilters.SHOW_ACTIVE))
      }
    >
      Active
    </Link>
    <Link
      active={VisibilityFilters.SHOW_COMPLETED === filter}
      onClick={() =>
        dispatch(setVisibilityFilter(VisibilityFilters.SHOW_COMPLETED))
      }
    >
      Completed
    </Link>
  </div>
);

export default connect()(Footer);

可以看到,上面的文件主要做了这几件事:

  • 首先从 react-redux 中导出 connect 函数,它负责给 Footer 传入 dispatch 函数,使得我们可以在 Footer 组件中 dispatch Action。
  • 然后我们导出了 setVisibilityFilter Action Creators,并将之前从父组件接收 setVisibilityFilter 方法并调用的方式改成了当 Link 被点击之后,我们 dispatch 对应的 Action 。

删除无用代码

当我们通过以上三步整合了 Redux 的内容之后,我们就可以删除原 App.js 中不必要的代码了,打开 src/components/App.js 修改内容如下:

// ...

class App extends React.Component {
  render() {
    const { todos, filter } = this.props;

    return (
      <div>
        <AddTodo />
        <TodoList todos={getVisibleTodos(todos, filter)} />
        <Footer filter={filter} />
      </div>
    );
  }
}

// ...

可以看到,我们删除了 setVisibilityFilter 方法,并对应删除了定义在 constructor 中的 setVisibilityFilter 定义以及在 render 方法中,传给 FootersetVisibilityFilter 属性。

因为 constructor 方法中已经不需要再定义内容了,所以我们删掉了它。

保存上述修改的代码,打开浏览器,你应该又可以继续点击底部的按钮来过滤完成和未完成的待办事项了:

小结

在本节中,我们介绍了开发 Redux 应用的最佳实践,并通过重构 "过滤查看待办事项“ 这一功能来详细实践了这一最佳实践。

自此,我们已经使用 Redux 重构了整个待办事项小应用,但是重构完的这份代码还显得有点乱,不同类型的组件状态混在一起。当我们的应用逐渐变得复杂时,我们的 rootReducer 就会变得非常冗长,所以是时候考虑拆分不同组件的状态了。

我们将在下一节中讲解如何将不同组件的状态进行拆分,以确保我们在编写大型应用时也可以显得很从容。

combineReducers:组合拆分状态的 Reducers

当应用逻辑逐渐复杂的时候,我们就要考虑将巨大的 Reducer 函数拆分成一个个独立的单元,这在算法中被称为 ”分而治之“。

Reducers 在 Redux 中实际上是用来处理 Store 中存储的 State 中的某个部分,一个 Reducer 和 State 对象树中的某个属性一一对应,一个 Reducer 负责处理 State 中对应的那个属性。比如我们来看一下现在我们的 State 的结构:

const initialState = {
  todos: [
    {
      id: 1,
      text: "你好, 图雀",
      completed: false
    },
    {
      id: 2,
      text: "我是一只小小小小图雀",
      completed: false
    },
    {
      id: 3,
      text: "小若燕雀,亦可一展宏图!",
      completed: false
    }
  ],
  filter: VisibilityFilters.SHOW_ALL
};

因为 Reducer 对应着 State 相关的部分,这里我们的 State 有两个部分:todosfilter,所以我们可以编写两个对应的 Reducer。

编写 Reducer:todos

在 Redux 最佳实践中,因为 Reducer 对应修改 State 中的相关部分,当 State 对象树很大时,我们的 Reducer 也会有很多,所以我们一般会单独建一个 reducers 文件夹来存放这些 "reducers“。

我们在 src 目录下新建 reducers 文件夹,然后在里面新建一个 todos.js 文件,表示处理 State 中对应 todos 属性的 Reducer,代码如下:

const initialTodoState = [
  {
    id: 1,
    text: "你好, 图雀",
    completed: false
  },
  {
    id: 2,
    text: "我是一只小小小小图雀",
    completed: false
  },
  {
    id: 3,
    text: "小若燕雀,亦可一展宏图!",
    completed: false
  }
];

const todos = (state = initialTodoState, action) => {
  switch (action.type) {
    case "ADD_TODO": {
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ];
    }

    case "TOGGLE_TODO": {
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    }

    default:
      return state;
  }
};

export default todos;

可以看到,上面的代码做了这几件事:

  • 首先我们将原 initialState 里面的 todos 部分拆分到了 src/reducers/todos.js 文件里,我们定义了一个 initialTodoState 代表之前的 initialStatetodos 部分,它是一个数组,并把它赋值给 todos 函数中 state 参数的默认值,即当调用此函数时,如果传进来的 state 参数为 undefined 或者 null 时,这个 state 就是 initialState
  • 接着我们定义了一个 todos 箭头函数,它的结构和 rootReducer 类似,都是接收两个参数:stateaction,然后进入一个 switch 判断语句,根据 action.type 判断要相应的 Action 类型,然后对 state 执行对应的操作。

注意

我们的 todos reducers 只负责处理原 initialStatetodos 部分,所以这里它的 state 就是原 todos 属性,它是一个数组,所以我们在 switch 语句里,进行数据改变时,要对数组进行操作,并最后返回一个新的数组。

编写 Reducer:filter

我们前面使用 todos reducer 解决了原 initialStatetodos 属性操作问题,现在我们马上来讲解剩下的 filter 属性的操作问题。

src/reducers 文件夹下创建 filter.js 文件,在其中加入如下的内容:

import { VisibilityFilters } from "../components/App";

const filter = (state = VisibilityFilters.SHOW_ALL, action) => {
  switch (action.type) {
    case "SET_VISIBILITY_FILTER":
      return action.filter;
    default:
      return state;
  }
};

export default filter;

可以看到我们定义了一个 filter 箭头函数,它接收两个参数:stateaction,因为这个 filter reducer 只负责处理原 initialStatefilter 属性部分,所以这里这个 state 参数就是原 filter 属性,这里我们给了它一个默认值。

注意

filter 函数的剩余部分和 rootReducer 类似,但是注意这里它的 state 是对 filter 属性进行操作,所以当判断 "SET_VISIBILITY_FILTER" action 类型时,它只是单纯的返回 action.filter

组合多个 Reducer

当我们将 rootReducer 的逻辑拆分,并对应处理 Store 中保存的 State 中的属性之后,我们可以确保每个 reducer 都很小,这个时候我们就要考虑如何将这些小的 reducer 组合起来,构成最终的 rootReducer,这种组合就像我们组合 React 组件一样,最终只有一个根级组件,在我们的待办事项小应用里面,这个组件就是 App.js 组件。

Redux 为我们提供了 combineReducers API,用来组合多个小的 reducer,我们在 src/reducers 文件夹下创建 index.js 文件,并在里面添加如下内容:

import { combineReducers } from "redux";

import todos from "./todos";
import filter from "./filter";

export default combineReducers({
  todos,
  filter
});

可以看到,我们从 redux 模块中导出了 combineReducers 函数,然后导出了之前定义的 todosfilter reducer。

接着我们通过对象简洁表示法,将 todosfilter 作为对象属性合在一起,然后传递给 combineReducers 函数,这里 combineReducers 内部就会对 todosfilter 进行操作,然后生成类似我们之前的 rootReducer 形式。最后我们导出生成的 rootReducer

combineReducers 主要有两个作用:

1)组合所有 reducer 的 state,最后组合成类似我们之前定义的 initialState 对象状态树。

即这里 todos reducer 的 state 为:

state = [
  {
    id: 1,
    text: "你好, 图雀",
    completed: false
  },
  {
    id: 2,
    text: "我是一只小小小小图雀",
    completed: false
  },
  {
    id: 3,
    text: "小若燕雀,亦可一展宏图!",
    completed: false
  }
];

filter reducer 的 state 为:

state = VisibilityFilters.SHOW_ALL

那么通过 combineReducers 组合这两个 reducerstate 得到的最终结果为:

state = {
  todos: [
    {
      id: 1,
      text: "你好, 图雀",
      completed: false
    },
    {
      id: 2,
      text: "我是一只小小小小图雀",
      completed: false
    },
    {
      id: 3,
      text: "小若燕雀,亦可一展宏图!",
      completed: false
    }
  ],
  filter: VisibilityFilters.SHOW_ALL
};

这个通过 combineReducers 组合后的最终 state 就是存储在 Store 里面的那棵 State JavaScript 对象状态树。

2)分发 dispatch 的 Action。

通过 combineReducers 组合 todosfilter reducer 之后,从 React 组件中 dispatch Action会遍历检查 todosfilter reducer,判断是否存在响应对应 action.typecase 语句,如果存在,所有的这些 case 语句都会响应。

删除不必要的代码

当我们将原 rootReducer 拆分成了 todosfilter 两个 reducer ,并通过 redux 提供的 combineReducers API 进行组合后,我们之前在 src/index.js 定义的 initialStaterootReducer 就不再需要了,删除后整个文件的代码如下:

import React from "react";
import ReactDOM from "react-dom";
import App, { VisibilityFilters } from "./components/App";

import { createStore } from "redux";
import { Provider } from "react-redux";
import rootReducer from "./reducers";

const store = createStore(rootReducer);

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

可以看到,我们从删除了之前在 src/index.js 定义的 rootReducer,转而使用了从 src/reducers/index.js 导出的 rootReducer

并且我们我们之前讲到,combineReducers 的第一个功能就是组合多个 reducer 的 state,最终合并成一个大的 JavaScript 对象状态树,然后自动存储在 Redux Store 里面,所以我们不再需要给 createStore 显式的传递第二个 initialState 参数了。

保存修改的内容,打开浏览器,可以照样可以操作所有的功能,你可以加点待办事项,点击某个待办事项以完成它,通过底部的三个过滤按钮查看不同状态下的待办事项:

小结

在这一小节中,我们讲解了 redux 提供的 combineReducers API,它主要解决两个问题:

  • 当应用逐渐复杂的时候,我们需要对 Reducer 进行拆分,那么我们就需要把拆分后的 Reducer 进行组合,并合并所有的 State。
  • 对于每个 React 组件 dispatch 的 Action,将其分发给对应的 Reducer。

当有了 combineReducers 之后,不管我们的应用如何复杂,我们都可以将处理应用状态的逻辑拆分都一个一个很简洁、易懂的小文件,然后组合这些小文件来完成复杂的应用逻辑,这和 React 组件的组合思想类似,可以想见,组件式编程的威力是多么巨大!

重构代码:将 TodoList 的状态和渲染分离

展示组件和容器组件

Redux 的出现,通过将 State 从 React 组件剥离,并将其保存在 Store 里面,来确保状态来源的可预测性,你可能觉得这样就已经很好了,但是 Redux 的动作还没完,它又进一步提出了展示组件(Presentational Components)和容器组件(Container Components)的概念,将纯展示性的 React 组件和状态进一步抽离。

当我们把 Redux 状态循环图中的 View 层进一步拆分时,它看起来是这样的:

即我们在最终渲染界面的组件和 Store 中存储的 State 之间又加了一层,我们称这一层为它专门负责接收来自 Store 的 State,并把组件中想要发起的状态改变组装成 Action,然后通过 dispatch 函数发出。

将状态彻底剥离之后剩下的那层称之为展示组件,它专门接收来自容器组件的数据,然后将其渲染成 UI 界面,并在需要改变状态时,告知容器组件,让其代为 dispatch Action。

首先,我们将 App.js 中的 VisibilityFilters 移到了 src/actions/index.js 的最后。因为 VisibilityFilters 定义了过滤展示 TodoList 的三种操作,和 Action 的含义更相近一点,所以我们将相似的东西放在了一起。修改 src/actions/index.js 如下:

// 省略了 nextTodoId 和之前定义的三个 Action

export const VisibilityFilters = {
  SHOW_ALL: "SHOW_ALL",
  SHOW_COMPLETED: "SHOW_COMPLETED",
  SHOW_ACTIVE: "SHOW_ACTIVE"
};

编写容器组件

容器组件其实也是一个 React 组件,它只是将原来从 Store 到 View 的状态和从组件中 dispatch Action 这两个逻辑从原组件中抽离出来。

根据 Redux 的最佳实践,容器组件一般保存在 containers 文件夹中,我们在 src 文件夹下建立一个 containers 文件夹,然后在里面新建 VisibleTodoList.js 文件,用来表示原 TodoList.js 的容器组件,并在文件中加入如下代码:

import { connect } from "react-redux";
import { toggleTodo } from "../actions";
import TodoList from "../components/TodoList";
import { VisibilityFilters } from "../actions";

const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case VisibilityFilters.SHOW_ALL:
      return todos;
    case VisibilityFilters.SHOW_COMPLETED:
      return todos.filter(t => t.completed);
    case VisibilityFilters.SHOW_ACTIVE:
      return todos.filter(t => !t.completed);
    default:
      throw new Error("Unknown filter: " + filter);
  }
};

const mapStateToProps = state => ({
  todos: getVisibleTodos(state.todos, state.filter)
});

const mapDispatchToProps = dispatch => ({
  toggleTodo: id => dispatch(toggleTodo(id))
});

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

可以看到,上面的代码主要做了这几件事情:

  • 我们定义了一个 mapStateToProps ,这是我们之前详细讲解过,它主要是可以获取到来自 Redux Store 的 State 以及组件自身的原 Props,然后组合这两者成新的 Props,然后传给组件,这个函数是 Store 到组件的唯一接口。这里我们将之前定义在 App.js 中的 getVisibleTodos 函数移过来,并根据 state.filter 过滤条件返回相应需要展示的 todos
  • 接着我们定义了一个没见过的 mapDispatchToProps 函数,这个函数接收两个参数:dispatchownProps,前者我们很熟悉了就是用来发出更新动作的函数,后者就是原组件的 Props,它是一个可选参数,这里我们没有声明它。我们主要在这个函数声明式的定义所有需要 dispatch 的 Action 函数,并将其作为 Props 传给组件。这里我们定义了一个 toggleTodo 函数,使得在组件中通过调用 toggleTodo(id) 就可以 dispatch(toggleTodo(id))
  • 最后我们通过熟悉的 connect 函数接收 mapStateToPropsmapDispatchToProps并调用,然后再接收 TodoList 组件并调用,返回最终的导出的容器组件。

编写展示组件

当我们编写了 TodoList 的容器组件之后,接着我们要考虑就是抽离了 State 和 dispatch 的关于 TodoList 的展示组件了。

修改 src/components/TodoList.js,代码如下:

import React from "react";
import PropTypes from "prop-types";
import Todo from "./Todo";

const TodoList = ({ todos, toggleTodo }) => (
  <ul>
    {todos.map(todo => (
      <Todo key={todo.id} {...todo} onClick={() => toggleTodo(todo.id)} />
    ))}
  </ul>
);

TodoList.propTypes = {
  todos: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      completed: PropTypes.bool.isRequired,
      text: PropTypes.string.isRequired
    }).isRequired
  ).isRequired,
  toggleTodo: PropTypes.func.isRequired
};

export default TodoList;

在上面的代码中,我们删除了 connecttoggleTodo Action,并将 TodoList 接收的 dispatch 属性删除,转而改成通过 mapDispatchToProps 传进来的 toggleTodo 函数,并在 Todo 被点击时调用 toggleTodo 函数。

当然我们的 toggleTodo 属性又回来了,所以我们在 propTypes 中恢复之前删除的 toggleTodo 。:)

最后,我们不再需要 connect()(TodoList),因为 VisibleTodoList.js 中定义的 TodoList 的对应容器组件会取到 Redux Store 中的 State,然后传给 TodoList。

可以看到,TodoList 不用再考虑状态相关的操作,只需要专心地做好界面的展示和动作的响应。我们进一步将状态与渲染分离,让合适的人做 TA 最擅长的事。

一些琐碎的收尾工作

因为我们将原来的 TodoList 剥离成了容器组件和 展示组件,所以我们要将 App.js 里面对应的 TodoList 换成我们的 VisibleTodoList,由容器组件来提供原 TodoList 对外的接口。

我们打开 src/components/App.js 对相应的内容作出如下修改:

import React from "react";
import AddTodo from "./AddTodo";
import VisibleTodoList from "../containers/VisibleTodoList";
import Footer from "./Footer";

import { connect } from "react-redux";

class App extends React.Component {
  render() {
    const { filter } = this.props;

    return (
      <div>
        <AddTodo />
        <VisibleTodoList />
        <Footer filter={filter} />
      </div>
    );
  }
}

const mapStateToProps = (state, props) => ({
  filter: state.filter
});

export default connect(mapStateToProps)(App);

可以看到我们做了这么几件事:

  • 将之前的 TodoList 更换成 VisibleTodoList。
  • 删除 VisibilityFilters,因为它已经被放到了 src/actions/index.js
  • 删除 getVisibleTodos,因为它已经被放到了 VisibleTodoList 中。
  • 删除 mapStateToProps 中获取 todos 的操作,因为我们已经在 VisibleTodoList 中获取了。
  • 删除对应在 App 组件中的 todos

接着我们处理一下因 VisibilityFilters 变动而引起的其他几个文件的导包问题。

打开 src/components/Footer.js 修改 VisibilityFilters 的导包路径:

import React from "react";
import Link from "./Link";
import { VisibilityFilters } from "../actions";

// ...

打开 src/reducers/filter.js 修改 VisibilityFilters 的导包路径:

import { VisibilityFilters } from "../actions";

// ...

因为我们在 src/actions/index.js 中的 nextTodoId 是从 0 开始自增的,所以之前我们定义的 initialTodoState 会出现一些问题,比如新添加的 todo 的 id 会与初始的重叠,导致出现问题,所以我们删除 src/reducers/todos.js 中对应的 initialTodoState,然后给 todos reducer 的 state 赋予一个 [] 的默认值。

const todos = (state = [], action) => {
  switch (action.type) {
    // ...
  }
};

export default todos;

小结

保存修改的内容,你会发现我们的待办事项小应用依然可以完整的运行,但是我们已经成功的将原来的 TodoList 分离成了容器组件的 VisibleTodoList 以及展示组件的 TodoList 了。

重构代码:将 Footer 的状态和渲染分离

我们趁热打铁,用上一节学到的知识来马上将 Footer 组件的状态和渲染抽离。

编写容器组件

我们在 src/containers 文件夹下创建一个 FilterLink.js 文件,添加对应的内容如下:

import { connect } from "react-redux";
import { setVisibilityFilter } from "../actions";
import Link from "../components/Link";

const mapStateToProps = (state, ownProps) => ({
  active: ownProps.filter === state.filter
});

const mapDispatchToProps = (dispatch, ownProps) => ({
  onClick: () => dispatch(setVisibilityFilter(ownProps.filter))
});

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

可以看到我们做了以下几件工作:

  • 定义 mapStateToProps,它负责比较 Redux Store 中保存的 State 的 state.filter 属性和组件接收父级传下来的 ownProps.filter 属性是否相同,如果相同,则把 active 设置为 true
  • 定义 mapDispatchToProps,它通过返回一个 onClick 函数,当组件点击时,调用生成一个 dispatch Action,将此时组件接收父级传下来的 ownProps.filter 参数传进 setVisibilityFilter ,生成 action.type"SET_VISIBILITY_FILTER" 的 Action,并 dispatch 这个 Action。
  • 最后我们通过 connect 组合这两者,将对应的属性合并进 Link 组件并导出。我们现在应该可以在 Link 组件中取到我们在上面两个函数中定义的 activeonClick 属性了。

编写展示组件

接着我们来编写原 Footer 的展示组件部分,打开 src/components/Footer.js 文件,对相应的内容作出如下的修改:

import React from "react";
import FilterLink from "../containers/FilterLink";
import { VisibilityFilters } from "../actions";

const Footer = () => (
  <div>
    <span>Show: </span>
    <FilterLink filter={VisibilityFilters.SHOW_ALL}>All</FilterLink>
    <FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>Active</FilterLink>
    <FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>Completed</FilterLink>
  </div>
);

export default Footer;

可以看到上面的代码修改做了这么几件工作:

  • 我们将之前的导出 Link 换成了 FilterLink 。请注意当组件的状态和渲染分离之后,我们将使用容器组件为导出给其他组件使用的组件。
  • 我们使用 FilterLink 组件,并传递对应的三个 FilterLink 过滤器类型。
  • 接着我们删除不再不需要的 connectsetVisibilityFilter 导出。
  • 最后删除不再需要的filterdispatch 属性,因为它们已经在 FilterLink 中定义并传给了 Link 组件了。

删除不必要的内容

当我们将 Footer 中的状态和渲染拆分之后,src/components/App.js 对应的 Footer 相关的内容就不再需要了,我们对文件中对应的内容作出如下修改:

import React from "react";
import AddTodo from "./AddTodo";
import VisibleTodoList from "../containers/VisibleTodoList";
import Footer from "./Footer";

class App extends React.Component {
  render() {
    return (
      <div>
        <AddTodo />
        <VisibleTodoList />
        <Footer />
      </div>
    );
  }
}

export default App;

可以看到我们做了如下工作:

  • 删除 App 组件中对应的 filter 属性和 mapStateToProps 函数,因为我们已经在 FilterLink 中获取了对应的属性,所以我们不再需要直接从 App 组件传给 Footer 组件了。
  • 删除对应的 connect 函数。
  • 删除对应 connect(mapStateToProps)(),因为 App 不再需要直接从 Redux Store 中获取内容了。

小结

保存修改的内容,你会发现我们的待办事项小应用依然可以完整的运行,但是我们已经成功的将原来的 Footer 分离成了容器组件的 FilterLink 以及展示组件的 Footer 了。

重构代码: 将 AddTodo 的状态和渲染分离

让我们来完成最后一点收尾工作,将 AddTodo 组件的状态和渲染分离。

编写容器组件

我们在 src/containers 文件夹中创建 AddTodoContainer.js 文件,在其中添加如下内容:

import { connect } from "react-redux";
import { addTodo } from "../actions";
import AddTodo from "../components/AddTodo";

const mapStateToProps = (state, ownProps) => {
  return ownProps;
};

const mapDispatchToProps = dispatch => ({
  addTodo: text => dispatch(addTodo(text))
});

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

可以看到我们做了几件熟悉的工作:

  • 定义 mapStateToProps,因为 AddTodo 不需要从 Redux Store 中取内容,所以 mapStateToProps 只是单纯地填充 connect 的第一个参数,然后简单地返回组件的原 props,不起其它作用。
  • 定义 mapDispatchToProps,我们定义了一个 addTodo 函数,它接收 text ,然后 dispatch 一个 action.type"ADD_TODO" 的 Action。
  • 最后我们通过 connect 组合这两者,将对应的属性合并进 AddTodo 组件并导出。我们现在应该可以在 AddTodo 组件中取到我们在上面两个函数中定义的 addTodo 属性了。

编写展示组件

接着我们来编写 AddTodo 的展示组件部分,打开 src/components/AddTodo.js 文件,对相应的内容作出如下的修改:

import React from "react";

const AddTodo = ({ addTodo }) => {
  let input;

  return (
    <div>
      <form
        onSubmit={e => {
          e.preventDefault();
          if (!input.value.trim()) {
            return;
          }
          addTodo(input.value);
          input.value = "";
        }}
      >
        <input ref={node => (input = node)} />
        <button type="submit">Add Todo</button>
      </form>
    </div>
  );
};

export default AddTodo;

可以看到,上面的代码做了这么几件工作:

  • 我们删除了导出的 connect 函数,并且去掉了其对 AddTodo 的包裹。
  • 我们将 AddTodo 接收的属性从 dispatch 替换成从 AddTodoContainer 传过来的 addTodo 函数,当表单提交时,它将被调用,dispatch 一个 action.type"ADD_TODO"textinput.value 的 Action。

修改对应的内容

因为我们将原 TodoList 分离成了容器组件 AddTodoContainer 和展示组件 TodoList,所以我们需要对 src/components/App.js 做出如下的修改:

import React from "react";
import AddTodoContainer from "../containers/AddTodoContainer";
import VisibleTodoList from "../containers/VisibleTodoList";
import Footer from "./Footer";

class App extends React.Component {
  render() {
    return (
      <div>
        <AddTodoContainer />
        <VisibleTodoList />
        <Footer />
      </div>
    );
  }
}

export default App;

可以看到我们使用 AddTodoContainer 替换了原来的 AddTodo 导出,并在 render 方法中渲染 AddTodoContainer 组件。

小结

保存修改的内容,你会发现我们的待办事项小应用依然可以完整的运行,但是我们已经成功的将原来的 AddTodo 分离成了容器组件的 AddTodoContainer 以及展示组件的 AddTodo 了。

总结

到目前为止,我们就已经学习完了 Redux 的所有基础概念,并且运用这些基础概念将一个纯 React 版的待办事项一步一步重构到了 Redux。

让我们最后一次祭出 Redux 状态循环图,回顾我们在这篇教程中学到的知识:

我们在这一系列教程中首先提出了 Redux 的三大概念:Store,Action,Reducers:

  • Store 用来保存整个应用的状态,这个状态是一个被称之为 State 的 JavaScript 对象。所有应用的状态都是从 Store 中获取,所以状态的改变都是改变 Store 中的状态,所以 Store 也有着 “数据的唯一真相来源” 的称号。
  • Action 是 Redux 中用来改变 Store 状态的唯一手段,所有状态的改变都是以类似 { type: 'ACTION_TYPE', data1, data2 } 这样的形式声明式的定义一个 Action,然后通过 dispatch 这个 Action 来发生的。
  • Reducers 是用来响应 Action 发出的改变动作,通过 switch 语句匹配 action.type ,通过对 State 的属性进行增删改查,然后返回一个新 State 的操作。同时它也是一个纯函数,即不会直接修改 State 本身。

具体反映到我们重构的待办事项项目里,我们使用 Store 保存的状态来替换之前 React 中的 this.state,使用 Action 来代替之前 React 发起修改 this.state 的动作,通过 dispatch Action 来发起修改 Store 中状态的操作,使用 Reducers 代替之前 React 中更新状态的 this.setState 操作,纯化的更新 Store 里面保存的 State。

接着我们趁热打铁,使用之前学到的三大概念,将整个待办事情的剩下部分重构到了 Redux。

但是重构完我们发现,我们现在的 rootReducer 函数已经有点臃肿了,它包含了 todosfilter 两类不同的状态属性,并且如果我们想要继续扩展这个待办事项应用,那么还会继续添加不同的状态属性,到时候各种状态属性的操作夹杂在一起很容易造成混乱和降低代码的可读性,不利于维护,因此我们提出了 combineReducers 方法,用于切分 rootReducer 到多个分散在不同文件的保存着单一状态属性的 Reducer,,然后通过 combineReducers 来组合这些拆分的 Reducers。

详细讲解 combineReducers 的概念之后,我们接着将之前的不完全重构的 Redux 代码进行了又一次重构,将 rootReducer 拆分成了 todosfilter 两个 Reducer。

最后我们更进一步,让 React 重拾初心—— 专注于用户界面的展示,让应用的状态和渲染分离,我们提出了展示组件和容器组件的概念,前者是完完全全的 React,接收来自后者的数据,然后负责将数据高效正确的渲染;前者负责响应用户的操作,然后交给后者发出具体的指令,可以看到,当我们使用 Redux 之后,我们在 React 上盖了一层逻辑,这层逻辑完全负责状态方面的工作,这就是 Redux 的精妙之处啊!

希望看到这里的同学能对 Redux 有个很好的了解,并能灵活的结合 React 和 Redux 的使用,感谢你的阅读!

One More Thing!

细心的读者可能发现了,我们画的 Redux 状态循环图都是单向的,它有一个明确的箭头指向,这其实也是 Redux 的哲学,即 ”单向数据流“,也是 React 社区推崇的设计模式,再加上 Reducer 的纯函数约定,这使得我们整个应用的每一次状态更改都是可以被记录下来,并且可以重现出来,或者说状态是可预测的,它可以追根溯源的找到某一次状态的改变时由某一个 Action 发起的,所以 Redux 也被冠名为 ”可预测的状态管理容器“。

想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349