Redux 包教包會(三):各司其職,重拾初心

daxuesheng發表於2021-09-09

在這一部分中,我們將會講解如何用 combineReducers 重構完剩下的內容,接著我們會提出 “容器元件” 和 “展示元件” 的概念,“容器元件” 用於接管 “狀態”,“展示元件” 用於渲染介面,其中 “展示元件” 也是 React 誕生的初心,專注於高效的編寫使用者介面。

重構程式碼:將 TodoList 的狀態和渲染分離

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

  • Redux 包教包會(三):各司其職,重拾初心(也就是這篇)

此教程屬於的一部分,點選可檢視全部內容。

展示元件和容器元件

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 如下:

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

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 修改導包路徑:

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

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

開啟 src/reducers/filter.js 修改導包路徑:

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

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

export default filter;

因為我們在 src/actions/index.js 中的 nextTodoId 是從 0 開始自增的,所以之前我們定義的 initialTodoState 會出現一些問題,比如新新增的 todo 的 id 會與初始的重疊,導致出現問題,所以我們刪除 src/reducers/todos.js 中對應的 initialTodoState,然後給 todos reducer 的 state 賦予一個 [] 的預設值。

const todos = (state = [], 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;

小結

儲存修改的內容,你會發現我們的待辦事項小應用依然可以完整的執行,但是我們已經成功的將原來的 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 也被冠名為 ”可預測的狀態管理容器“。

此教程屬於的一部分,點選可檢視全部內容。

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

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4692/viewspace-2824671/,如需轉載,請註明出處,否則將追究法律責任。

相關文章