在許多事件處理器中分散許多狀態更新的元件可能會變得難以管理。在這些情況下,您可以將所有狀態更新邏輯整合到元件外部的單個函式中,稱為 *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
:
- **將**設定狀態改為發送動作。
- **撰寫** reducer 函式。
- **在**您的元件中使用 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
函式本身。)
步驟 2:撰寫 reducer 函式
reducer 函式是放置狀態邏輯的地方。它接受兩個參數:目前的狀態和動作物件,並返回下一個狀態。
function yourReducer(state, action) {
// return next state for React to set
}
React 會將狀態設定為您從 reducer 返回的值。
在此範例中,要將狀態設定邏輯從事件處理程式移至 reducer 函式,您需要:
- 將目前的狀態(
tasks
)宣告為第一個參數。 - 將
action
物件宣告為第二個參數。 - 從 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
)作為參數,您可以將其**宣告在元件外部**。這可以減少縮排層級,並使您的程式碼更易於閱讀。
深入探討
雖然 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()
方法,搭配 initialState
和 actions
陣列,透過將 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 接受兩個參數:
- 一個 reducer 函式
- 一個初始狀態
它會返回:
- 一個狀態值
- 一個 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 函式則決定*狀態如何更新*以響應它們。
比較 useState
和 useReducer
Reducer 並非沒有缺點!以下是一些比較它們的方法
- 程式碼大小:通常,使用
useState
您必須預先編寫較少的程式碼。使用useReducer
,您必須編寫 reducer 函式*以及* dispatch actions。但是,如果許多事件處理程式以類似的方式修改狀態,則useReducer
可以幫助減少程式碼。 - 可讀性:當狀態更新很簡單時,
useState
非常易於閱讀。當它們變得更複雜時,它們可能會使您的組件程式碼變得臃腫且難以掃描。在這種情況下,useReducer
讓您可以將更新邏輯的*方式*與事件處理程式的*發生了什麼* cleanly 分開。 - 除錯:當您使用
useState
遇到錯誤時,可能很難判斷狀態在*哪裡*設置不正確,以及*為什麼*。使用useReducer
,您可以在 reducer 中添加一個 console.log 來查看每個狀態更新,以及它*為什麼*發生(由於哪個action
)。如果每個action
都是正確的,您就會知道錯誤出在 reducer 邏輯本身。但是,您必須比使用useState
逐步執行更多程式碼。 - 測試:Reducer 是一個不依賴於您的組件的純函式。這表示您可以單獨將其導出並單獨測試。雖然通常最好在更真實的環境中測試組件,但對於複雜的狀態更新邏輯,斷言您的 reducer 針對特定初始狀態和操作返回特定狀態可能很有用。
- 個人喜好:有些人喜歡 reducer,有些人則不喜歡。沒關係。這是一個偏好問題。您可以隨時在
useState
和useReducer
之間來回轉換:它們是等效的!
如果您經常由於某些組件中的狀態更新不正確而遇到錯誤,並且希望在其程式碼中引入更多結構,我們建議您使用 reducer。您不必將 reducer 用於所有事情:您可以隨意混合搭配!您甚至可以在同一個組件中 useState
和 useReducer
。
良好地編寫 reducer
編寫 reducer 時請記住以下兩個技巧
- Reducer 必須是純的。與 狀態更新器函式 類似,reducer 在渲染期間運行!(動作會排隊直到下一次渲染。)這表示 reducer 必須是純的 —相同的輸入始終產生相同的輸出。它們不應發送請求、安排逾時或執行任何副作用(影響組件外部事物的操作)。它們應更新 物件 和 陣列 而不發生突變。
- 每個動作都描述單個使用者互動,即使這會導致資料發生多項更改。例如,如果使用者按下由 reducer 管理的具有五個欄位的表單上的「重設」,則分派一個
reset_form
動作比五個單獨的set_field
動作更有意義。如果您記錄 reducer 中的每個動作,則該日誌應該足夠清晰,以便您重建發生的互動或響應的順序。這有助於除錯!
使用 Immer 編寫簡潔的 reducer
就像在一般狀態下更新 物件 和 陣列 一樣,您可以使用 Immer 函式庫使 reducer 更簡潔。在這裡,useImmerReducer
允許您使用 push
或 arr[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
- 從事件處理器發送動作(Dispatch actions)。
- 編寫一個 reducer 函式,根據給定的狀態和動作返回下一個狀態。
- 將
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'}, ];