Redux 包教包會(二):引入 combineReducers 拆分和組合狀態邏輯

tuture發表於2020-04-16

我們研發開源了一款基於 Git 進行技術實戰教程寫作的工具,我們圖雀社群的所有教程都是用這款工具寫作而成,歡迎 Star

如果你想快速瞭解如何使用,歡迎閱讀我們的 教程文件哦

在這一部分中,我們將趁熱打鐵,運用上篇教程學到的 Redux 三大核心概念來將待辦事項的剩下部分重構完成,它涉及到將 TodoList 和 Footer 部分的相關程式碼重構到 Redux,並使用 Redux combineReducers API 進行邏輯拆分和組合,使得我們可以在使用 Redux 便利的同時,又不至於讓應用的邏輯看起來臃腫不堪,複用 React 元件化的便利,我們可以讓狀態的處理也 “元件化”。

重構程式碼:將 TodoList 部分遷移到 Redux

歡迎閱讀 Redux 包教包會系列:

此教程屬於React 前端工程師學習路線的一部分,點選可檢視全部內容。

在之前的幾個小節中,我們已經把 Redux 的核心概念講完了,並且運用這些概念重構了一部分待辦事項應用,在這一小節中,我們將趁熱打鐵,完整地運用之前學到的知識,繼續用 Redux 重構我們的應用。

此時如果你在瀏覽器裡面嘗試這個待辦事項小應用,你會發現它還只可以新增新的待辦事項,對於 “完成和重做待辦事項” 以及 “過濾檢視待辦事項” 這兩個功能,目前我們還沒有使用 Redux 實現。所以當你點選單個待辦事項時,瀏覽器會報錯;當你點選底部的三個過濾器按鈕時,瀏覽器不會有任何反應。

在這一小節中,我們將使用 Redux 重構 “完成和重做待辦事項” 功能,即你可以通過點選某個待辦事項來完成它。

我們將運用 Redux 最佳實踐的開發方式來重構這一功能:

  • 定義 Action Creators
  • 定義 Reducers
  • connect 元件以及在元件中 dispatch Action

以後在開發 Redux 應用的時候,都可以使用這三步流程來周而復始地開發新的功能,或改進現有的功能。

定義 Action Creators

首先我們要定義 “完成待辦事項” 這一功能所涉及的 Action,開啟 src/actions/index.js,修改內容內容如下:

let nextTodoId = 0;

export const addTodo = text => ({
  type: "ADD_TODO",
  id: nextTodoId++,
  text
});

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

可以看到,我們定義並匯出了一個 toggleTodo 箭頭函式,它接收 id 並返回一個型別為 "TOGGLE_TODO" 的 Action。

定義 Reducers

接著我們來定義響應 dispatch(action) 的 Reducers,開啟 src/index.js,修改 rootReducer 函式如下:

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

import { createStore } from "redux";
import { Provider } from "react-redux";

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

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;
  }
};

const store = createStore(rootReducer, initialState);

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

可以看到,我們在 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 React from "react";
import PropTypes from "prop-types";
import Todo from "./Todo";

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 修改內容如下:

import React from "react";
import AddTodo from "./AddTodo";
import TodoList from "./TodoList";
import Footer from "./Footer";

import { connect } from "react-redux";

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

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);
  }
};

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>
    );
  }
}

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

export default connect(mapStateToProps)(App);

可以看到,我們刪除了 toggleTodo 方法,並對應刪除了定義在 constructor 中的 toggleTodo 定義以及在 render 方法中,傳給 TodoListtoggleTodo 屬性。

儲存上述修改的程式碼,開啟瀏覽器,你應該又可以點選單個待辦事項來完成和重做它了:

小結

在本節中,我們介紹了開發 Redux 應用的最佳實踐,並通過重構 “完成和重做待辦事項“ 這一功能來詳細實踐了這一最佳實踐。

重構程式碼:將 Footer 部分遷移到 Redux

這一節中,我們將繼續重構剩下的部分。我們將繼續遵循上一節提到的 Redux 開發的最佳實踐:

  • 定義 Action Creators
  • 定義 Reducers
  • connect 元件以及在元件中 dispatch Action

定義 Action Creators

開啟 src/actions/index.js 檔案,修改內容如下:

let nextTodoId = 0;

export const addTodo = text => ({
  type: "ADD_TODO",
  id: nextTodoId++,
  text
});

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

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

可以看到我們建立了一個名為 setVisibilityFilter 的 Action Creators,它接收 filter 引數,然後返回一個型別為 "SET_VISIBILITY_FILTER" 的 Action。

定義 Reducers

開啟 src/index.js 檔案,修改程式碼如下:

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

import { createStore } from "redux";
import { Provider } from "react-redux";

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

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
        )
      };
    }

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

    default:
      return state;
  }
};

const store = createStore(rootReducer, initialState);

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

可以看到,我們增加了一條 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 修改內容如下:

import React from "react";
import AddTodo from "./AddTodo";
import TodoList from "./TodoList";
import Footer from "./Footer";

import { connect } from "react-redux";

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

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);
  }
};

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

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

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

export default connect(mapStateToProps)(App);

可以看到,我們刪除了 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 元件的組合思想類似,可以想見,元件式程式設計的威力是多麼巨大!

此教程屬於React 前端工程師學習路線的一部分,點選可檢視全部內容。

想要學習更多精彩的實戰技術教程?來圖雀社群逛逛吧。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

圖雀社群

相關文章