將狀態邏輯提取到 Reducer

在許多事件處理器中分散許多狀態更新的元件可能會變得難以管理。在這些情況下,您可以將所有狀態更新邏輯整合到元件外部的單個函式中,稱為 *reducer*。

您將學習到

  • 什麼是 reducer 函式
  • 如何將 useState 重構為 useReducer
  • 何時使用 reducer
  • 如何良好地撰寫 reducer

使用 reducer 整合狀態邏輯

隨著元件複雜性的增加,一眼看出元件狀態所有不同的更新方式可能會變得越來越困難。例如,下面的 TaskApp 元件在狀態中保存一個 tasks 陣列,並使用三個不同的事件處理器來新增、移除和編輯任務。

import { useState } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, setTasks] = useState(initialTasks);

  function handleAddTask(text) {
    setTasks([
      ...tasks,
      {
        id: nextId++,
        text: text,
        done: false,
      },
    ]);
  }

  function handleChangeTask(task) {
    setTasks(
      tasks.map((t) => {
        if (t.id === task.id) {
          return task;
        } else {
          return t;
        }
      })
    );
  }

  function handleDeleteTask(taskId) {
    setTasks(tasks.filter((t) => t.id !== taskId));
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

每個事件處理器都會呼叫 setTasks 來更新狀態。隨著這個元件的增長,分散在其中的狀態邏輯數量也會增加。為了降低這種複雜性,並將所有邏輯保存在一個易於存取的位置,您可以將狀態邏輯移到元件外部的單個函式中,**稱為「reducer」。**

Reducer 是一種處理狀態的不同方式。您可以透過三個步驟從 useState 遷移到 useReducer

  1. **將**設定狀態改為發送動作。
  2. **撰寫** reducer 函式。
  3. **在**您的元件中使用 reducer。

步驟 1:將設定狀態改為發送動作

您的事件處理器目前透過設定狀態來指定 *要做什麼*

function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}

function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}

function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}

移除所有設定狀態的邏輯。您剩下的將是三個事件處理器:

  • 當使用者按下「新增」時,會呼叫 handleAddTask(text)
  • 當使用者切換任務或按下「儲存」時,會呼叫 handleChangeTask(task)
  • 當使用者按下「刪除」時,會呼叫 handleDeleteTask(taskId)

使用 reducer 管理狀態與直接設定狀態略有不同。您不是透過設定狀態來告訴 React「要做什麼」,而是透過從事件處理器發送「動作」來指定「使用者剛剛做了什麼」。(狀態更新邏輯將位於其他位置!) 因此,您不是透過事件處理器「設定 tasks」,而是發送「已新增/已變更/已刪除任務」動作。這更能描述使用者的意圖。

function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}

function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}

function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}

您傳遞給 dispatch 的物件稱為「動作」

function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}

它是一個普通的 JavaScript 物件。您可以決定在其中放置什麼內容,但通常它應該包含關於 *發生了什麼* 的最少資訊。(您將在稍後的步驟中新增 dispatch 函式本身。)

備註

動作物件可以有任何形狀。

按照慣例,通常會給它一個字串 type 來描述發生了什麼,並在其他欄位中傳遞任何額外資訊。 type 是特定於元件的,因此在本例中, 'added''added_task' 都可以。選擇一個說明發生了什麼的名稱!

dispatch({
// specific to component
type: 'what_happened',
// other fields go here
});

步驟 2:撰寫 reducer 函式

reducer 函式是放置狀態邏輯的地方。它接受兩個參數:目前的狀態和動作物件,並返回下一個狀態。

function yourReducer(state, action) {
// return next state for React to set
}

React 會將狀態設定為您從 reducer 返回的值。

在此範例中,要將狀態設定邏輯從事件處理程式移至 reducer 函式,您需要:

  1. 將目前的狀態(tasks)宣告為第一個參數。
  2. action 物件宣告為第二個參數。
  3. 從 reducer 返回*下一個*狀態(React 將會把狀態設定為此值)。

以下是所有已遷移到 reducer 函式的狀態設定邏輯。

function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Unknown action: ' + action.type);
}
}

因為 reducer 函式將狀態(tasks)作為參數,您可以將其**宣告在元件外部**。這可以減少縮排層級,並使您的程式碼更易於閱讀。

備註

上面的程式碼使用 if/else 陳述式,但在 reducer 中使用 switch 陳述式 是一個慣例。結果相同,但 switch 陳述式通常更容易閱讀。

我們將在本文檔的其餘部分中使用它們,如下所示:

function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}

我們建議將每個 case 區塊用 {} 大括號括起來,這樣在不同 case 中宣告的變數才不會互相衝突。此外,case 通常應該以 return 結束。如果您忘記 return,程式碼會「穿透」到下一個 case,這可能會導致錯誤!

如果您還不熟悉 switch 陳述式,使用 if/else 完全沒問題。

深入探討

為什麼 reducer 會被這樣命名?

雖然 reducer 可以「減少」元件內的程式碼量,但它們實際上是以您可以對陣列執行的 reduce() 操作命名的。

reduce() 操作允許您使用一個陣列,並從許多值中「累積」出一個單一值。

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5

您傳遞給 reduce 的函式稱為「reducer」。它接受*到目前為止的結果*和*目前的項目*,然後返回*下一個結果*。React reducer 是相同概念的一個例子:它們接受*到目前為止的狀態*和*動作*,並返回*下一個狀態*。透過這種方式,它們隨著時間推移累積動作到狀態中。

您甚至可以使用 reduce() 方法,搭配 initialStateactions 陣列,透過將 reducer 函式傳遞給它來計算最終狀態。

import tasksReducer from './tasksReducer.js';

let initialState = [];
let actions = [
  {type: 'added', id: 1, text: 'Visit Kafka Museum'},
  {type: 'added', id: 2, text: 'Watch a puppet show'},
  {type: 'deleted', id: 1},
  {type: 'added', id: 3, text: 'Lennon Wall pic'},
];

let finalState = actions.reduce(tasksReducer, initialState);

const output = document.getElementById('output');
output.textContent = JSON.stringify(finalState, null, 2);

您可能不需要自己這樣做,但這與 React 的做法類似!

步驟 3:在您的元件中使用 reducer

最後,您需要將 tasksReducer 連接到您的元件。從 React 匯入 useReducer Hook。

import { useReducer } from 'react';

然後您可以將 useState

const [tasks, setTasks] = useState(initialTasks);

替換為 useReducer,如下所示:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

useReducer Hook 與 useState 類似——您必須傳遞給它一個初始狀態,它會返回一個狀態值和一種設定狀態的方法(在本例中為 dispatch 函式)。但它有點不同。

useReducer Hook 接受兩個參數:

  1. 一個 reducer 函式
  2. 一個初始狀態

它會返回:

  1. 一個狀態值
  2. 一個 dispatch 函式(將使用者動作「派送」到 reducer)

現在它已完全連接!這裡,reducer 宣告在元件檔案的底部。

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  function handleAddTask(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text: text,
    });
  }

  function handleChangeTask(task) {
    dispatch({
      type: 'changed',
      task: task,
    });
  }

  function handleDeleteTask(taskId) {
    dispatch({
      type: 'deleted',
      id: taskId,
    });
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    }
    case 'changed': {
      return tasks.map((t) => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

如果您願意,您甚至可以將 reducer 移到另一個檔案中。

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import tasksReducer from './tasksReducer.js';

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  function handleAddTask(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text: text,
    });
  }

  function handleChangeTask(task) {
    dispatch({
      type: 'changed',
      task: task,
    });
  }

  function handleDeleteTask(taskId) {
    dispatch({
      type: 'deleted',
      id: taskId,
    });
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

當您像這樣區分關注點時,元件邏輯會更易於閱讀。現在,事件處理程式只透過派送動作來指定*發生了什麼*,而 reducer 函式則決定*狀態如何更新*以響應它們。

比較 useStateuseReducer

Reducer 並非沒有缺點!以下是一些比較它們的方法

  • 程式碼大小:通常,使用 useState 您必須預先編寫較少的程式碼。使用 useReducer,您必須編寫 reducer 函式*以及* dispatch actions。但是,如果許多事件處理程式以類似的方式修改狀態,則 useReducer 可以幫助減少程式碼。
  • 可讀性:當狀態更新很簡單時,useState 非常易於閱讀。當它們變得更複雜時,它們可能會使您的組件程式碼變得臃腫且難以掃描。在這種情況下,useReducer 讓您可以將更新邏輯的*方式*與事件處理程式的*發生了什麼* cleanly 分開。
  • 除錯:當您使用 useState 遇到錯誤時,可能很難判斷狀態在*哪裡*設置不正確,以及*為什麼*。使用 useReducer,您可以在 reducer 中添加一個 console.log 來查看每個狀態更新,以及它*為什麼*發生(由於哪個 action)。如果每個 action 都是正確的,您就會知道錯誤出在 reducer 邏輯本身。但是,您必須比使用 useState 逐步執行更多程式碼。
  • 測試:Reducer 是一個不依賴於您的組件的純函式。這表示您可以單獨將其導出並單獨測試。雖然通常最好在更真實的環境中測試組件,但對於複雜的狀態更新邏輯,斷言您的 reducer 針對特定初始狀態和操作返回特定狀態可能很有用。
  • 個人喜好:有些人喜歡 reducer,有些人則不喜歡。沒關係。這是一個偏好問題。您可以隨時在 useStateuseReducer 之間來回轉換:它們是等效的!

如果您經常由於某些組件中的狀態更新不正確而遇到錯誤,並且希望在其程式碼中引入更多結構,我們建議您使用 reducer。您不必將 reducer 用於所有事情:您可以隨意混合搭配!您甚至可以在同一個組件中 useStateuseReducer

良好地編寫 reducer

編寫 reducer 時請記住以下兩個技巧

  • Reducer 必須是純的。狀態更新器函式 類似,reducer 在渲染期間運行!(動作會排隊直到下一次渲染。)這表示 reducer 必須是純的 —相同的輸入始終產生相同的輸出。它們不應發送請求、安排逾時或執行任何副作用(影響組件外部事物的操作)。它們應更新 物件陣列 而不發生突變。
  • 每個動作都描述單個使用者互動,即使這會導致資料發生多項更改。例如,如果使用者按下由 reducer 管理的具有五個欄位的表單上的「重設」,則分派一個 reset_form 動作比五個單獨的 set_field 動作更有意義。如果您記錄 reducer 中的每個動作,則該日誌應該足夠清晰,以便您重建發生的互動或響應的順序。這有助於除錯!

使用 Immer 編寫簡潔的 reducer

就像在一般狀態下更新 物件陣列 一樣,您可以使用 Immer 函式庫使 reducer 更簡潔。在這裡,useImmerReducer 允許您使用 pusharr[i] = 分配來改變狀態

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

Reducer 必須是純的,因此它們不應改變狀態。但 Immer 為您提供了一個特殊的 draft 物件,可以安全地進行變更。在底層,Immer 將使用您對 draft 所做的更改創建狀態的副本。這就是為什麼由 useImmerReducer 管理的 reducer 可以改變其第一個參數並且不需要返回狀態的原因。

摘要

  • useState 轉換為 useReducer
    1. 從事件處理器發送動作(Dispatch actions)。
    2. 編寫一個 reducer 函式,根據給定的狀態和動作返回下一個狀態。
    3. useState 替換為 useReducer
  • Reducer 需要您編寫更多程式碼,但它們有助於除錯和測試。
  • Reducer 必須是純函式(pure function)。
  • 每個動作描述單一使用者互動。
  • 如果您想以可變動(mutating)的風格編寫 reducer,請使用 Immer。

挑戰 1 4:
從事件處理器發送動作

目前,`ContactList.js` 和 `Chat.js` 中的事件處理器有 `// TODO` 註解。這就是為什麼在輸入框中輸入文字沒有作用,點擊按鈕也不會改變所選收件人的原因。

將這兩個 `// TODO` 替換為 `dispatch` 相應動作的程式碼。要查看預期的形狀和動作的類型,請檢查 `messengerReducer.js` 中的 reducer。reducer 已經寫好了,所以您不需要更改它。您只需要在 `ContactList.js` 和 `Chat.js` 中發送動作。

import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];