React 狀態管理 - 你可能不需要 Redux,但你需要了解它!

👨‍🎓:使用了 React,你可能不需要 Redux,但你需要了解它!
👩‍🎓:既然不需要,爲什麼需要了解?
👨‍🎓:不瞭解你怎麼知道不需要呢?
👩‍🎓:這話沒毛病,學它!

Redux 是一個可預測的狀態管理容器,也是 react 中最流行的一個狀態管理工具,無論是工作或面試只要你使用了 react 都需要掌握它。核心理念是在全局維護了一個狀態,稱爲 store,爲應用系統提供了全局狀態管理的能力,使得跨組件通信變得更簡單。

Redux 抽象程度很高,關注的是 “哲學設計”,開發者最關心的是 “如何實現”,做爲初學者儘管看了官網 API 介紹但面對實際項目時還是發現無從入手,特別是面對一些新名詞 store、state、action、dispatch、reducer、middlwware 時,有些小夥伴表示我就認識 state...

本篇在介紹一些 Redux 的概念後會重構上一節 useReducer + useContext 實現的 Todos,介紹如何在 React 中應用 Redux,從實踐中學習。

Redux 數據流轉過程

Redux 通過一系列規範約定來約束應用程序如何根據 action 來更新 store 中的狀態,下圖展示了 Redux 數據流轉過程,也是 Redux 的主要組成部分

圖片來源:redux application data flow

Immutable

在 reducer 純函數中不允許直接修改 state 對象,每次都應返回一個新的 state。原生 JavaScript 中我們要時刻記得使用 ES6 的擴展符 ... 或 Object.assign() 函數創建一個新 state,但是仍然是一個淺 copy,遇到複雜的數據結構我們還需要做深拷貝返回一個新的狀態,總之你要保證每次都返回一個新對象,一方面深拷貝會造成性能損耗、另一方面難免會忘記從而直接修改原來的 state。

Immutable 數據一旦創建,對該數據的增、刪、改操作都會返回一個新的 immutable 對象,保證了舊數據可用同時不可變

Immutable 實現的原理是 Persistent Data Structure(持久化數據結構),也就是使用舊數據創建新數據時,要保證舊數據同時可用且不變。同時爲了避免 deepCopy 把所有節點都複製一遍帶來的性能損耗,Immutable 使用了 Structural Sharing(結構共享),即如果對象樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其它節點則進行共享。參考 Immutable 詳解及 React 中實踐

請看下面動畫:

immutable.gif

在本文中整個 redux store 狀態樹都採用的是 Immutable 數據對象,同時使用時也應避免與普通的 JavaScript 對象混合使用,從下面例子中可以學習到一些常用的 API 使用,更詳細的介紹參考官網文檔 immutable-js.com/docs。

項目結構

React + Redux 項目的組織結構,在第一次寫項目時也犯了困惑,你如果在社區中搜索會發現很多種聲音,例如,按類型劃分(類似於 MVC 這樣按不同的角色劃分)頁面功能劃分、Ducks(將 actionTypes、actionCreators、reducer 放在一個文件裏),這裏每一種的區別也可以單獨寫篇文章討論了,本節採用的方式是按頁面功能劃分,也是筆者剛開始寫 React + Redux 時的一種目錄組織方式。沒有最佳的方式,選擇適合於你的方式

按照一個頁面功能對應一個文件夾劃分,pages/todos 文件夾負責待辦事項功能,如果頁面複雜可在頁面組件內創建一個 pages/todos/components 文件夾,redux 相關的 action、reducer 等放在 page/todos/store 文件夾中。

src/
├── App.css
├── App.js
├── index.css
├── index.js
├── components
├── pages
│   └── todos
│       ├── components
│       │   ├── Todo.jsx
│       │   └── TodoAdd.jsx
│       ├── index.jsx
│       └── store
│           ├── actionCreators.js
│           ├── constants.js
│           ├── index.js
│           └── reducer.js
├── reducers
│   └── todos-reducer.js
├── routes
│   └── index.js
└── store
    ├── index.js
    └── reducer.js

Todos View 層展示組件

View 層我們定義爲 “展示組件”,負責 UI 渲染,至於渲染時用到的數據如何獲取交由後面的容器組件負責。以下 Todo、TodoAdd、Todos 三個組件中使用到的數據都是從 props 屬性獲取,後面容器組件鏈接 React 與 Redux 時會再講。

Todo 組件

組件位置src/pages/todos/components/Todo.jsx

import { useState } from "react";

/**
 * Todo component
 * @param {Number} props.todo.id
 * @param {String} props.todo.content
 * @param {Function} props.editTodo
 * @param {Function} props.removeTodo
 * @returns 
 */
const Todo = ({ todo, editTodo, removeTodo }) ={
  console.log('Todo render');
  const [isEdit, setIsEdit] = useState(false);
  const [content, setContent] = useState(todo.get('content'));

  return <div class>
    {
      !isEdit ? <>
        <div class>{todo.get('content')}</div>
        <button class onClick={() => setIsEdit(true)}> 編輯 </button>
        <button class onClick={() => removeTodo(todo.get('id'))}> 刪除 </button>
      </> : <>
        <div class>
          <input class value={content} type="text" onChange={ e => setContent(e.target.value) } />
        </div>
        <button class onClick={() ={
          setIsEdit(false);
          editTodo(todo.get('id'), content);
        }}> 更新 </button>
        <button class onClick={() => setIsEdit(false)}> 取消 </button>
      </>
    }
  </div>
}

export default Todo;

TodoAdd 組件

組件位置src/pages/todos/components/TodoAdd.jsx

import { useState } from "react";

import { actionCreators } from '../store';

/**
 * Add todo component
 * @param {Function} props.addTodo 
 * @returns 
 */
const TodoAdd = ({ addTodo }) ={
  console.log('TodoAdd render');
  const [content, setContent] = useState('');

  return <div class>
    <input class onChange={e => setContent(e.target.value)} />
    <button class onClick={() => addTodo(content)}>
      添加
    </button>
  </div>
};

export default TodoAdd;

Todos 組件

組件位置src/pages/todos/index.jsx

import { useEffect } from "react";

import { actionCreators } from './store';
import Todo from './components/Todo';
import TodoAdd from './components/TodoAdd';

/**
 * Todos component
 * @param {Number} todos[].id 
 * @param {String} todos[].content 
 * @param {Function} getTodos
 * @returns 
 */
const Todos = ({ todos, getTodos }) ={
  console.log('Todos render');

  useEffect(() ={
    getTodos();
  }[]); // eslint-disable-line react-hooks/exhaustive-deps

  return <div class>
    <h2 class> Todos App </h2>
    <p class> React + Redux 實現 todos </p>
    <TodoAdd />
    <div class>
      {
        todos.map(todo => <Todo key={todo.get('id')} todo={todo} />)
      }
    </div>
  </div>
}

export default Todos;

唯一數據源 Store

一個 React + Redux 的應用程序中只有一個 store,是應用程序的唯一數據源,類似於在我們應用中抽象出一個狀態樹,與組件一一關聯。這也是一種集中式管理應用狀態的方式,也是和 React hooks 提供的 useState/useReducer 一個重大區別之處。

創建 store

通過 redux 的 createStore() 方法創建 store,支持預設一些初始化狀態。

代碼位置src/store/index.js

import { createStore, compose } from 'redux';
import reducer from './reducer';

const store = createStore(reducer, /* preloadedState, */);

export default store;

reducer 拆分與組裝

當應用複雜時我們通常會拆分出多個子 reducer 函數,每個 reducer 處理自己負責的 state 數據。例如,按頁面功能劃分項目結構,每個頁面 / 公共組件都可以維護自己的 reducer。

有了拆分,對應還有組合,redux 爲我們提供了 combineReducers 函數用於合併多個 reducer。因爲我們的 state 是一個 Immutable 對象,而 redux 提供的 combineReducers 只支持原生 JavaScript 對象,不能操作 Immutable 對象,我們還需要藉助另外一箇中間件 **redux-immutable** 從 state 取出 Immutable 對象

可以爲 reducer 函數指定不同的 key 值,這個 key 值在組件從 store 獲取 state 時會用到,下文 “容器組件鏈接 React 與 Redux” 中會使用到。

代碼位置src/store/reducer.js

import { combineReducers } from 'redux-immutable';
import { reducer as todosReducer } from '../pages/todos/store';
import { reducer as otherComponentReducer } from '../pages/other-component/store';

const reducer = combineReducers({
  todosPage: todosReducer,
  otherComonpent: otherComponentReducer, // 其它組件的 reducer 函數,在這裏依次寫
});

export default reducer;

爲 todos 組件創建 store 文件

代碼位置:src/pages/todos/store/index.js

import * as constants from './constants';
import * as actionCreators from './actionCreators';
import reducer from './reducer';

export {
  reducer,
  constants,
  actionCreators,
};

constants

代碼位置:src/pages/todos/store/constants.js

export const TODO_LIST = 'todos/TODO_LIST';
export const TODO_LIST_ADD = 'todos/TODO_LIST_ADD';
export const TODO_LIST_EDIT = 'todos/TODO_LIST_EDIT';
export const TODO_LIST_REMOVE = 'todos/TODO_LIST_REMOVE';

創建 action creator 與引入中間件

action 是 store 唯一的信息來源,action 的數據結構要能清晰描述實際業務場景,通常 type 屬性是必須的,描述類型。我的習慣是放一個 payload 對象,描述類型對應的數據內容。

一般會通過 action creator 創建一個 action。例如,以下爲一個獲取待辦事項列表的 action creator,這種寫法是同步的。

function getTodos() {
  return {
    type: 'TODO_LIST',
    payload: {}
  }
}

在實際的業務中,異步操作是必不可少的,而 store.dispatch 方法只能處理普通的 JavaScript 對象,如果返回一個異步 function 代碼就會報錯。通常需要結合 redux-thunk 中間件使用,實現思路是 ** action creator 返回的異步函數先經過 redux-thunk 處理,當真正的請求響應後,在發送一個 dispatch(action) 此時的 action 就是一個普通的 JavaScript 對象了 **。

Redux 的中間件概念與 Node.js 的 Web 框架 Express 類似,通用的邏輯可以抽象出來做爲一箇中間件,一個請求先經過中間件處理後 -> 到達業務處理邏輯 -> 業務邏輯響應之後 -> 響應再到中間件。redux 裏的 action 好比 Web 框架收到的請求

代碼位置:src/store/index.js。修改 store 文件,引入中間件使得 action 支持異步操作。

import { createStore, compose, applyMiddleware } from 'redux'; // 導入 compose、applyMiddleware
import chunk from 'redux-thunk'; // 導入 redux-thunk 包
import reducer from './reducer';

const store = createStore(reducer, /* preloadedState, */ compose(
  applyMiddleware(chunk),
));

export default store;

創建本次 todos 需要的 action creator,實際業務中增、刪、改、查我們會調用服務端的接口查詢或修改數據,爲了模擬異步,我們簡單點使用 Promise 模擬異步操作。

代碼位置:src/pages/todos/store/actionCreators.js

import { TODO_LIST, TODO_LIST_ADD, TODO_LIST_REMOVE, TODO_LIST_EDIT } from './constants';

const randomID = () => Math.floor(Math.random() * 10000);

// 獲取待辦事項列表
export const getTodos = () => async dispatch ={
  // 模擬 API 異步獲取數據
  const todos = await Promise.resolve([
    {
      id: randomID(),
      content: '學習 React',
    },
    {
      id: randomID(),
      content: '學習 Node.js',
    }
  ]);

  const action = {
    type: TODO_LIST,
    payload: {
      todos
    }
  };
  dispatch(action);
}

// 添加待辦事項
export const addTodo = (content) => async dispatch ={
  const result = await Promise.resolve({
    id: randomID(),
    content,
  });

  const action = {
    type: TODO_LIST_ADD,
    payload: result
  };
  dispatch(action);
}

// 編輯待辦事項
export const editTodo = (id, content) => async dispatch ={
  const result = await Promise.resolve({ id, content });
  const action = {
    type: TODO_LIST_EDIT,
    payload: result,
  };
  dispatch(action);
}

// 移除待辦事項
export const removeTodo = id => async dispatch ={
  const result = await Promise.resolve({ id });
  const action = {
    type: TODO_LIST_REMOVE,
    payload: result,
  };
  dispatch(action);
}

reducer 純函數

reducer 根據 action 的響應決定怎麼去修改 store 中的 state。編寫 reducer 函數沒那麼複雜,倒要切記該函數始終爲一個純函數,應避免直接修改 state。reducer 純函數要保證以下兩點:

需要注意一點是在第一次調用時 state 爲 undefined,這時需使用 initialState 初始化 state。

代碼位置:src/pages/todos/store/reducer.js

import { fromJS } from 'immutable';
import { TODO_LIST, TODO_LIST_ADD, TODO_LIST_REMOVE, TODO_LIST_EDIT } from './constants';

export const initialState = fromJS({
  todos: [],
});

const reducer = (state = initialState, action = {}) ={
  switch (action.type) {
    case TODO_LIST: {
      return state.merge({
        todos: state.get('todos').concat(fromJS(action.payload.todos)),
      });
    }
    case TODO_LIST_ADD: {
      return state.set('todos', state.get('todos').push(fromJS({
        id: action.payload.id,
        content: action.payload.content,
      })));
    }
    case TODO_LIST_EDIT: {
      return state.merge({
        todos: state.get('todos').map(item ={
          if (item.get('id') === action.payload.id) {
            const newItem = { ...item.toJS(), content: action.payload.content };
            return fromJS(newItem);
          }
          return item;
        })
      })
    }
    case TODO_LIST_REMOVE: {
      return state.merge({
        todos: state.get('todos').filter(item => item.get('id') !== action.payload.id),
      })
    }
    default: return state;
  }
};

export default reducer;

容器組件鏈接 React 與 Redux

Redux 做爲一個狀態管理容器,本身並沒有與任何 View 層框架綁定,當在 React 框架中使用 Redux 時需安裝 react-redux npm i react-redux -S 庫。

容器組件

react-redux 提供的 connect 函數,可以把 React 組件和 Redux 的 store 鏈接起來生成一個新的容器組件(這裏有個經典的設計模式 “高階組件”),數據如何獲取就是容器組件需要負責的事情,在獲取到數據後通過 props 屬性傳遞到展示組件,當展示組件需要變更狀態時調用容器組件提供的方法同步這些狀態變化。

總結下來,容器組件需要做兩件事:

// 創建容器組件代碼示例
import { connect } from 'react-redux';
import ExampleComponent from './ExampleComponent'

const mapStateToProps = (state) =({ // 從全局狀態取出數據映射到展示組件的 props
  todos: state.getIn(['todosComponent''todos']),
});

const mapDispatchToProps = (dispatch) =({ // 把展示組件變更狀態需要用到的方法映射到展示組件的 props 上。
  getTodos() {
    dispatch(actionCreators.getTodos());
  },
});

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

上例,當 redux store 中的 state 變化時,對應的 mapStateToProps 函數會被執行,如果 mapStateToProps 函數新返回的對象與之前對象淺比較相等(此時,如果是類組件可以理解爲 shouldComponentUpdate 方法返回 false),展示組件就不會重新渲染,否則重新渲染展示組件

展示組件與容器組件之間的關係可以自由組合,可以單獨創建一個 container 文件,來包含多個展示組件,同樣也可以在展示組件裏包含容器組件。在我們的示例中,也比較簡單是在展示組件裏返回一個容器組件,下面開始修改我們展示組件。

修改 Todo 組件

組件位置src/pages/todos/components/Todo.jsx

在我們的 Todo 組件中,參數 todo 是由上層的 Todos 組件傳遞的這裏並不需要從 Redux 的 store 中獲取 state,只需要修改狀態的函數就可以了,connect() 函數第一個參數 state 可以省略,這樣 state 的更新也就不會引起該組件的重新渲染了

import { connect } from 'react-redux';

const Todo = ({ todo, editTodo, removeTodo }) ={...} // 中間代碼省略

const mapDispatchToProps = (dispatch) =({
  editTodo(id, content) {
    dispatch(actionCreators.editTodo(id, content));
  },
  removeTodo(id) {
    dispatch(actionCreators.removeTodo(id));
  }
});

export default connect(null, mapDispatchToProps)(Todo);

修改 TodoAdd 組件

組件位置src/pages/todos/components/TodoAdd.jsx

import { connect } from 'react-redux';

const TodoAdd = ({ addTodo }) ={...}; // 中間代碼省略

const mapDispatchToProps = (dispatch) =({
  addTodo(content) {
    dispatch(actionCreators.addTodo(content));
  },
});

export default connect(null, mapDispatchToProps)(TodoAdd);

修改 Todos 組件

組件位置src/pages/todos/components/Todos.jsx

import { connect } from 'react-redux';

const Todos = ({ todos, getTodos }) ={ ... } // 中間代碼省略

const mapStateToProps = (state) =({
  todos: state.getIn(['todosPage''todos']),
});

const mapDispatchToProps = (dispatch) =({
  getTodos() {
    dispatch(actionCreators.getTodos());
  },
});

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

創建 MyRoutes 組件

有了 Page 相應的也有路由,創建 MyRoutes 組件,代碼位置 src/routes/index.js

import {
  BrowserRouter, Routes, Route
} from 'react-router-dom';
import Todos from '../pages/todos';

const MyRoutes = () ={
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/todos" element={<Todos />} />
      </Routes>
    </BrowserRouter>
  );
};

export default MyRoutes;

Provider 組件傳遞 store

通過 react-redux 的 connect 函數創建的容器組件可以獲取 redux  store,那麼有沒有想過容器組件又是如何獲取的 redux store?

在 React 狀態管理 - Context 一篇中介紹過,使用 React.createContext() 方法創建一個上下文(MyContext),之後通過 MyContext 提供的 Provider 組件可以傳遞 value 屬性供子組件使用。react-redux 也提供了一個 Provider 組件,正是通過 context 傳遞 store 供子組件使用,所以我們使用 redux 時,一般會把 Provider 組件做爲根組件,這樣被 Provider 根組件包裹的所有子組件都可以獲取到 store 中的存儲的狀態

創建 App.js 組件,組件位置:src/app.js

import { Provider } from 'react-redux';

import store from './store';
import Routers from './routes';

const App = () =(
  <Provider store={store}>
    <Routers />
  </Provider>
);

export default App;

Redux 調試工具

介紹一個在開發過程中調試 Redux 應用的一款瀏覽器插件 Redux DevTools extension,可以實時顯示當前應用的 action 觸發、state 變更記錄。

該插件目前支持 Chrome 瀏覽器、Firefox 瀏覽器、Electron,安裝方法參考 redux-devtools-extension installation。

代碼位置src/store/index.js。 修改 store 文件,在非生產環境優先使用調試插件提供的 compose 函數

const composeEnhancers = (
  process.env.NODE_ENV !== 'production' &&
  typeof window !== 'undefined' &&
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
) || compose;

const store = createStore(reducer, /* preloadedState, */ composeEnhancers(
  applyMiddleware(chunk),
));

如下所示,每次 action 觸發及修改的狀態都可以記錄到。

演示

上一節 React 狀態管理 - useState/useReducer + useContext 實現全局狀態管理 提出了 Context 一旦某個屬性發生變化,依賴於該上下文的組件同樣也會重新渲染。Redux 內部對應用性能是做了優化的,當組件的數據沒有發生變化時是不會重新渲染的。

總結

在寫 Redux 過程中,你會體會到它關注的 “哲學問題”,有一套自己的流程,你需要理解什麼是唯一數據源、不可變、函數式編程思想。

爲了保持保持應用的狀態爲只讀原則,無論何時我們都不能直接修改應用狀態,必須先發送一個 action 描述修改行爲,由 store 交給 reducer 純函數修改應用的狀態

有時一個簡單的修改行爲,就需要編寫 actionCreator、reducer 等 “樣板代碼”,站在開發者 “如何簡單使用” 角度,會感覺過於繁瑣。但是,這一看似繁瑣的修改流程也正是 Redux  狀態管理流程中的核心概念。在大型複雜項目的應用狀態管理中,一個流程清晰、職責範圍明確的數據層框架會使應用代碼變的思路清晰、易於測試、團隊協作。每個狀態管理框架都有其優缺點,利於弊,看你怎麼看待了。

在 Redux 基礎之上又衍生了 redux-toolbox 來優化項目的狀態管理,解決了一些 Redux 的配置複雜、樣板代碼太多、需要添加多個依賴包等問題,具體怎麼樣呢?關注 “編程界” 下一講介紹。

編程界 編程的世界浩瀚無際,編程界願做爲一顆指南針,爲您的編程之路指引前進方向!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LyOjYQgkrsq0ql3-Y3xUlg