跳至主要內容

Redux 基礎知識,第 7 部分:標準 Redux 模式

Redux 基礎知識,第 7 部分:標準 Redux 模式

您將學到什麼
  • 實際 Redux 應用程式中使用的標準模式,以及這些模式存在的原因
    • 用於封裝動作物件的動作建立器
    • 用於提升效能的記憶化選擇器
    • 透過載入列舉追蹤請求狀態
    • 標準化狀態以管理項目集合
    • 使用承諾和 thunk
先備條件
  • 了解所有先前章節的主題

第 6 部分:非同步邏輯和資料擷取中,我們瞭解如何使用 Redux 中介軟體撰寫非同步邏輯,讓它可以與儲存庫溝通。特別是,我們使用 Redux 「thunk」中介軟體撰寫函式,讓它可以包含可重複使用的非同步邏輯,而不需要事先知道它將與哪個 Redux 儲存庫溝通。

到目前為止,我們已經涵蓋了 Redux 實際運作的基本原理。然而,實際世界的 Redux 應用程式會在這些基本原理之上使用一些額外的模式。

請務必注意,這些模式並非使用 Redux 的必要條件但是,每個模式的存在都有很好的理由,而且你幾乎可以在每個 Redux 程式碼庫中看到其中一些或全部模式。

在本節中,我們將修改現有的待辦事項應用程式程式碼,使用其中一些模式,並說明它們在 Redux 應用程式中常用的原因。然後,在第 8 部分中,我們將討論「現代 Redux」,包括如何使用我們的官方Redux Toolkit套件簡化我們在應用程式中「手動」撰寫的所有 Redux 邏輯,以及為什麼我們建議將 Redux Toolkit 作為撰寫 Redux 應用程式的標準方法

注意

請注意,本教學範例刻意展示較舊的 Redux 邏輯模式,這些模式需要比「現代 Redux」模式更多程式碼,而我們將 Redux Toolkit 教導為使用 Redux 建立應用程式的正確方法,以便說明 Redux 背後的原則和概念。它並非旨在成為可供生產環境使用的專案。

請參閱這些頁面,以瞭解如何將「現代 Redux」與 Redux Toolkit 搭配使用

動作建立器

在我們的應用程式中,我們直接在程式碼中撰寫動作物件,而這些動作物件會在其中被傳送

dispatch({ type: 'todos/todoAdded', payload: trimmedText })

然而,在實務上,撰寫良好的 Redux 應用程式並不會在傳送動作物件時內嵌撰寫這些動作物件。相反地,我們使用「動作建立器」函式。

動作建立器是一個建立並傳回動作物件的函式。我們通常使用這些動作建立器,這樣我們就不必每次都手動撰寫動作物件

const todoAdded = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}

我們接著使用這些動作建立器,方法是呼叫動作建立器,然後將結果動作物件直接傳遞給 dispatch

store.dispatch(todoAdded('Buy milk'))

console.log(store.getState().todos)
// [ {id: 0, text: 'Buy milk', completed: false}]

詳細說明:為什麼要使用動作建立器?

在我們的小範例待辦事項應用程式中,每次手動撰寫動作物件並不容易。事實上,透過切換為使用動作建立器,我們增加了更多工作 - 現在我們必須撰寫一個函式動作物件。

但是,如果我們需要從應用程式的許多部分傳送相同的動作怎麼辦?或者,如果每次我們傳送動作時都有一些額外的邏輯必須執行,例如建立唯一 ID?我們最終必須在每次需要傳送該動作時複製貼上額外的設定邏輯。

動作建立器有兩個主要目的

  • 它們準備並格式化動作物件的內容
  • 它們封裝了在建立這些動作時所需的任何額外工作

這樣,我們就有了一致的方法來建立動作,無論是否需要執行任何額外的作業。Thunk 也是如此。

使用動作建立器

讓我們更新我們的 todos 片段檔案,使用動作建立器處理我們的幾個動作類型。

我們將從目前為止使用的兩個主要動作開始:從伺服器載入 todos 清單,以及在儲存至伺服器後新增一個新的 todo。

目前,todosSlice.js 直接傳送一個動作物件,如下所示

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

我們將建立一個函式,建立並傳回同類型的動作物件,但接受 todos 陣列作為其引數,並將其放入動作中,作為 action.payload。然後,我們可以使用新的動作建立器在我們的 fetchTodos thunk 中傳送動作

src/features/todos/todosSlice.js
export const todosLoaded = todos => {
return {
type: 'todos/todosLoaded',
payload: todos
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

我們也可以對「新增 todo」動作執行相同的操作

src/features/todos/todosSlice.js
export const todoAdded = todo => {
return {
type: 'todos/todoAdded',
payload: todo
}
}

export function saveNewTodo(text) {
return async function saveNewTodoThunk(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch(todoAdded(response.todo))
}
}

趁此機會,讓我們對「變更顏色篩選器」動作執行相同的操作

src/features/filters/filtersSlice.js
export const colorFilterChanged = (color, changeType) => {
return {
type: 'filters/colorFilterChanged',
payload: { color, changeType }
}
}

由於此動作是由 <Footer> 元件傳送的,因此我們需要匯入 colorFilterChanged 動作建立器並使用它

src/features/footer/Footer.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { availableColors, capitalize } from '../filters/colors'
import { StatusFilters, colorFilterChanged } from '../filters/filtersSlice'

// omit child components

const Footer = () => {
const dispatch = useDispatch()

const todosRemaining = useSelector(state => {
const uncompletedTodos = state.todos.filter(todo => !todo.completed)
return uncompletedTodos.length
})

const { status, colors } = useSelector(state => state.filters)

const onMarkCompletedClicked = () => dispatch({ type: 'todos/allCompleted' })
const onClearCompletedClicked = () =>
dispatch({ type: 'todos/completedCleared' })

const onColorChange = (color, changeType) =>
dispatch(colorFilterChanged(color, changeType))

const onStatusChange = status =>
dispatch({ type: 'filters/statusFilterChanged', payload: status })

// omit rendering output
}

export default Footer

請注意,colorFilterChanged 動作建立器實際上接受兩個不同的引數,然後將它們組合在一起以形成正確的 action.payload 欄位。

這不會改變應用程式的運作方式,或 Redux 資料流程的行為 - 我們仍然在建立動作物件,並傳送它們。但是,我們現在使用動作建立器在傳送動作物件之前準備這些動作物件,而不是在我們的程式碼中直接撰寫動作物件。

我們也可以將動作建立器與 thunk 函式搭配使用,事實上 我們在上一節中將 thunk 包裝在動作建立器中 。我們特別將 saveNewTodo 包裝在「thunk 動作建立器」函式中,以便我們可以傳入 text 參數。雖然 fetchTodos 沒有任何參數,但我們仍然可以將它包裝在動作建立器中

src/features/todos/todosSlice.js
export function fetchTodos() {
return async function fetchTodosThunk(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
}

這表示我們必須變更在 index.js 中傳送它的位置,以呼叫外部 thunk 動作建立器函式,並將傳回的內部 thunk 函式傳遞給 dispatch

src/index.js
import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos())

到目前為止,我們使用 function 關鍵字撰寫 thunk,以清楚說明它們在做什麼。但是,我們也可以使用箭頭函式語法來撰寫它們。使用隱式傳回可以縮短程式碼,但如果你不熟悉箭頭函式,它也可能會讓程式碼更難閱讀

src/features/todos/todosSlice.js
// Same thing as the above example!
export const fetchTodos = () => async dispatch => {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

同樣地,如果我們願意,可以 縮短純粹的動作建立器

src/features/todos/todosSlice.js
export const todoAdded = todo => ({ type: 'todos/todoAdded', payload: todo })

由你決定是否以這種方式使用箭頭函式會更好。

資訊

如需有關動作建立器為何有用的更多詳細資訊,請參閱

記憶化選擇器

我們已經看到,我們可以撰寫「選擇器」函數,它接受 Redux state 物件作為引數,並傳回一個值

const selectTodos = state => state.todos

如果我們需要衍生一些資料呢?例如,我們可能想要一個僅包含待辦事項 ID 的陣列

const selectTodoIds = state => state.todos.map(todo => todo.id)

然而,array.map() 永遠傳回一個新的陣列參考。我們知道 React-Redux useSelector 鉤子會在每個派送動作後重新執行其選擇器函數,如果選擇器結果改變,它會強制元件重新渲染。

在此範例中,呼叫 useSelector(selectTodoIds) 永遠會導致元件在每個動作後重新渲染,因為它傳回一個新的陣列參考!

在第 5 部分中,我們看到 我們可以將 shallowEqual 作為引數傳遞給 useSelector。不過,這裡還有另一個選項:我們可以使用「記憶化」選擇器。

記憶化是一種快取,特別是儲存昂貴計算的結果,如果我們稍後看到相同的輸入,就重新使用這些結果。

記憶化選擇器函數是儲存最近結果值的選擇器,如果你使用相同的輸入多次呼叫它們,它們會傳回相同的結果值。如果你使用與上次不同的輸入呼叫它們,它們會重新計算一個新的結果值,快取它,並傳回新的結果。

使用 createSelector 記憶化選擇器

Reselect 函式庫提供一個 createSelector API,它會產生記憶化選擇器函數createSelector 接受一個或多個「輸入選擇器」函數作為引數,加上一個「輸出選擇器」,並傳回新的選擇器函數。每次你呼叫選擇器

讓我們建立一個 selectTodoIds 的備忘版本,並在 <TodoList> 中使用它。

首先,我們需要安裝 Reselect

然後,我們可以匯入並呼叫 createSelector。我們最初的 selectTodoIds 函式定義在 TodoList.js 中,但通常選擇器函式會寫在相關的切片檔案中。因此,我們將它加入 todos 切片

然後,我們在 <TodoList> 中使用它

這實際上與 shallowEqual 比較函式的行為略有不同。任何時候 state.todos 陣列變更,我們都會因此建立一個新的 todo ID 陣列。這包括對 todo 項目進行任何不可變更新,例如切換其 completed 欄位,因為我們必須為不可變更新建立一個新的陣列。

具有多個參數的選擇器

我們的 todo 應用程式應該具備根據完成狀態篩選可見 todo 的功能。讓我們撰寫一個備忘選擇器,傳回已篩選的 todo 清單。

我們知道需要將整個 todos 陣列作為輸出選擇器的參數之一。我們也需要傳入目前的完成狀態篩選值。我們將新增一個單獨的「輸入選擇器」來擷取每個值,並將結果傳遞給「輸出選擇器」。

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'
import { StatusFilters } from '../filters/filtersSlice'

// omit other code

export const selectFilteredTodos = createSelector(
// First input selector: all todos
state => state.todos,
// Second input selector: current status filter
state => state.filters.status,
// Output selector: receives both values
(todos, status) => {
if (status === StatusFilters.All) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => todo.completed === completedStatus)
}
)
注意

請注意,我們現在已在兩個切片之間新增一個匯入相依性 - todosSlice 正在從 filtersSlice 匯入一個值。這是合法的,但要小心。如果兩個切片嘗試從彼此匯入某些內容,可能會導致「循環匯入相依性」問題,進而導致程式碼崩潰。如果發生這種情況,請嘗試將一些共用程式碼移到自己的檔案中,然後從該檔案匯入。

現在,我們可以使用這個新的「已篩選待辦事項」選擇器作為另一個選擇器的輸入,該選擇器會傳回這些待辦事項的 ID

src/features/todos/todosSlice.js
export const selectFilteredTodoIds = createSelector(
// Pass our other memoized selector as an input
selectFilteredTodos,
// And derive data in the output selector
filteredTodos => filteredTodos.map(todo => todo.id)
)

如果我們切換 <TodoList> 以使用 selectFilteredTodoIds,那麼我們應該能夠將幾個待辦事項標記為已完成

Todo app - todos marked completed

然後篩選清單以顯示已完成的待辦事項

Todo app - todos marked completed

然後,我們可以擴充我們的 selectFilteredTodos,以便在選擇中也包含顏色篩選

src/features/todos/todosSlice.js
export const selectFilteredTodos = createSelector(
// First input selector: all todos
selectTodos,
// Second input selector: all filter values
state => state.filters,
// Output selector: receives both values
(todos, filters) => {
const { status, colors } = filters
const showAllCompletions = status === StatusFilters.All
if (showAllCompletions && colors.length === 0) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => {
const statusMatches =
showAllCompletions || todo.completed === completedStatus
const colorMatches = colors.length === 0 || colors.includes(todo.color)
return statusMatches && colorMatches
})
}
)

請注意,透過將邏輯封裝在此選擇器中,我們的元件永遠不需要變更,即使我們變更了篩選行為。現在,我們可以同時依據狀態和顏色進行篩選

Todo app - status and color filters

最後,我們的程式碼有幾個地方正在查詢 state.todos。我們將對該狀態的設計方式進行一些變更,因為我們將完成本節的其餘部分,因此我們將擷取單一的 selectTodos 選擇器並在各處使用它。我們也可以將 selectTodoById 移到 todosSlice

src/features/todos/todosSlice.js
export const selectTodos = state => state.todos

export const selectTodoById = (state, todoId) => {
return selectTodos(state).find(todo => todo.id === todoId)
}
資訊

如需有關我們使用選擇器函式的更多詳細資訊,以及如何使用 Reselect 編寫備忘選擇器的資訊,請參閱

非同步要求狀態

我們正在使用非同步 thunk 從伺服器擷取待辦事項的初始清單。由於我們正在使用假的伺服器 API,因此回應會立即傳回。在實際的應用程式中,API 呼叫可能需要一段時間才能解析。在這種情況下,在我們等待回應完成時,通常會顯示某種載入指示器。

這通常在 Redux 應用程式中透過以下方式處理

  • 具備某種「載入狀態」值,以表示要求的目前狀態
  • 在進行 API 呼叫之前發送「要求已開始」動作,這會透過變更載入狀態值來處理
  • 請求完成時再次更新載入狀態值,以表示呼叫已完成

在請求進行時,UI 層會顯示載入指示器,請求完成時則會切換為顯示實際資料。

我們將更新 todos 片段以追蹤載入狀態值,並在 fetchTodos thunk 中發送額外的 'todos/todosLoading' 動作。

目前,我們的 todos reducer 的 state 僅為 todos 陣列本身。如果我們想要在 todos 片段中追蹤載入狀態,我們需要將 todos 狀態重新整理為包含 todos 陣列載入狀態值的物件。這也表示要改寫 reducer 邏輯以處理額外的巢狀結構

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
entities: [...state.entities, action.payload]
}
}
case 'todos/todoToggled': {
return {
...state,
entities: state.entities.map(todo => {
if (todo.id !== action.payload) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
}
// omit other cases
default:
return state
}
}

// omit action creators

export const selectTodos = state => state.todos.entities

這裡有幾點重要事項需要注意

  • todos 陣列現在在 todosReducer 狀態物件中巢狀為 state.entities。字詞「entities」表示「具有 ID 的唯一項目」,確實描述了我們的 todo 物件。
  • 這也表示陣列巢狀在整個 Redux 狀態物件中為 state.todos.entities
  • 我們現在必須在 reducer 中執行額外步驟,以複製額外層級的巢狀結構,以進行正確的不變更新,例如 state 物件 -> entities 陣列 -> todo 物件
  • 由於我們其他程式碼透過選擇器存取 todos 狀態,我們只需要更新 selectTodos 選擇器,即使我們大幅度地重新調整狀態,UI 的其他部分仍會繼續按預期運作。

載入狀態列舉值

您也會注意到我們已將載入狀態欄位定義為字串列舉

{
status: 'idle' // or: 'loading', 'succeeded', 'failed'
}

而不是 isLoading 布林值。

布林值將我們限制在兩種可能性:「載入中」或「未載入中」。實際上,請求實際上可能處於許多不同的狀態,例如

  • 尚未開始
  • 進行中
  • 成功
  • 失敗
  • 成功,但現在又回到我們可能想要重新擷取的情況

應用程式邏輯也可能僅根據特定動作在特定狀態之間進行轉換,而這使用布林值較難實作。

因此,我們建議將載入狀態儲存為字串枚舉值,而非布林旗標

資訊

如需載入狀態應為枚舉的詳細說明,請參閱

根據此指南,我們將新增一個「載入」動作,將我們的狀態設定為 'loading',並更新「已載入」動作,將狀態旗標重設為 'idle'

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
return {
...state,
status: 'idle',
entities: action.payload
}
}
default:
return state
}
}

// omit action creators

// Thunk function
export const fetchTodos = () => async dispatch => {
dispatch(todosLoading())
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

不過,在我們嘗試在 UI 中顯示此內容之前,我們需要修改假的伺服器 API,為我們的 API 呼叫新增人工延遲。開啟 src/api/server.js,並尋找第 63 行附近的這行註解

src/api/server.js
new Server({
routes() {
this.namespace = 'fakeApi'
// this.timing = 2000

// omit other code
}
})

如果您取消註解該行,假的伺服器將為我們的應用程式發出的每個 API 呼叫新增 2 秒延遲,這讓我們有足夠的時間實際看到載入指示器顯示。

現在,我們可以在 <TodoList> 元件中讀取載入狀態值,並根據該值顯示載入指示器。

src/features/todos/TodoList.js
// omit imports

const TodoList = () => {
const todoIds = useSelector(selectFilteredTodoIds)
const loadingStatus = useSelector(state => state.todos.status)

if (loadingStatus === 'loading') {
return (
<div className="todo-list">
<div className="loader" />
</div>
)
}

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

在實際的應用程式中,我們還需要處理 API 失敗錯誤和其他潛在案例。

以下是啟用載入狀態的應用程式外觀(若要再次看到指示器,請重新載入應用程式預覽或在新分頁中開啟它)

Flux 標準動作

Redux 儲存本身並不在乎您放入動作物件中的欄位為何。它只在乎 action.type 存在且為字串。這表示您可以將任何其他欄位放入您想要的動作中。對於「已新增待辦事項」動作,我們可能會使用 action.todo,或 action.color,依此類推。

不過,如果每個動作都為其資料欄位使用不同的欄位名稱,則很難預先知道您需要在每個 reducer 中處理哪些欄位。

這就是 Redux 社群提出 「Flux 標準動作」慣例,或「FSA」的原因。這是一種建議的方法,說明如何在動作物件內部組織欄位,以便開發人員始終知道哪些欄位包含哪種類型的資料。FSA 模式在 Redux 社群中廣泛使用,事實上您已在整個本教學課程中使用它。

FSA 慣例說明

  • 如果您的動作物件有任何實際資料,您的動作的「資料」值應始終放入 action.payload
  • 動作也可能有一個 action.meta 欄位,其中包含額外的描述性資料
  • 動作可能有一個 action.error 欄位,其中包含錯誤資訊

因此,所有 Redux 動作都必須

  • 是一個純 JavaScript 物件
  • 有一個 type 欄位

如果你使用 FSA 模式撰寫動作,動作則 MAY

  • 有一個 payload 欄位
  • 有一個 error 欄位
  • 有一個 meta 欄位

詳細說明:FSA 和錯誤

FSA 規範說明

如果動作代表一個錯誤,則可以將選用的 error 屬性設定為 trueerror 為 true 的動作類似於被拒絕的 Promise。根據慣例,payload 應為一個錯誤物件。如果 error 有任何其他值,包括 undefinednull,則動作不得被解釋為錯誤。

FSA 規範也反對針對「載入成功」和「載入失敗」等事項有特定的動作類型。

然而,在實務上,Redux 社群忽略了將 action.error 作為布林旗標使用的概念,而改為使用個別的動作類型,例如 'todos/todosLoadingSucceeded''todos/todosLoadingFailed'。這是因為檢查這些動作類型比先處理 'todos/todosLoaded' 然後 檢查 if (action.error) 容易得多。

你可以採用對你來說比較好的方法,但大多數應用程式會針對成功和失敗使用個別的動作類型。

正規化狀態

到目前為止,我們將待辦事項保留在陣列中。這是合理的,因為我們從伺服器接收資料時是以陣列形式,而且我們也需要迴圈處理待辦事項,才能在 UI 中以清單形式顯示它們。

然而,在較大的 Redux 應用程式中,通常會將資料儲存在正規化狀態結構中。「正規化」表示

  • 確保每一段資料只有一個副本
  • 以一種允許直接透過 ID 尋找項目方式儲存項目
  • 根據 ID 參照其他項目,而不是複製整個項目

例如,在部落格應用程式中,你可能會有指向 UserComment 物件的 Post 物件。同一個人可能有很多文章,因此如果每個 Post 物件都包含一個完整的 User,我們將會有許多相同的 User 物件副本。相反地,Post 物件將有一個使用者 ID 值作為 post.user,然後我們可以透過 ID 查詢 User 物件,例如 state.users[post.user]

這表示我們通常會將資料整理成物件而不是陣列,其中項目 ID 是鍵,項目本身是值,如下所示

const rootState = {
todos: {
status: 'idle',
entities: {
2: { id: 2, text: 'Buy milk', completed: false },
7: { id: 7, text: 'Clean room', completed: true }
}
}
}

讓我們將我們的 todos 片段轉換為以正規化的形式儲存 todos。這將需要對我們的 reducer 邏輯進行一些重大變更,以及更新選擇器

src/features/todos/todosSlice
const initialState = {
status: 'idle',
entities: {}
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
const todo = action.payload
return {
...state,
entities: {
...state.entities,
[todo.id]: todo
}
}
}
case 'todos/todoToggled': {
const todoId = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
completed: !todo.completed
}
}
}
}
case 'todos/colorSelected': {
const { color, todoId } = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
color
}
}
}
}
case 'todos/todoDeleted': {
const newEntities = { ...state.entities }
delete newEntities[action.payload]
return {
...state,
entities: newEntities
}
}
case 'todos/allCompleted': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
newEntities[todo.id] = {
...todo,
completed: true
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/completedCleared': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
if (todo.completed) {
delete newEntities[todo.id]
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
return {
...state,
status: 'idle',
entities: newEntities
}
}
default:
return state
}
}

// omit action creators

const selectTodoEntities = state => state.todos.entities

export const selectTodos = createSelector(selectTodoEntities, entities =>
Object.values(entities)
)

export const selectTodoById = (state, todoId) => {
return selectTodoEntities(state)[todoId]
}

由於我們的 state.entities 欄位現在是一個物件而不是陣列,我們必須使用巢狀物件擴充運算子來更新資料,而不是陣列運算。此外,我們無法像迴圈陣列那樣迴圈物件,因此在幾個地方我們必須使用 Object.values(entities) 來取得 todo 項目陣列,以便我們可以迴圈它們。

好消息是,由於我們使用選擇器來封裝狀態查詢,我們的 UI 仍然不必變更。壞消息是,reducer 程式碼實際上更長且更複雜。

這裡的問題之一是,這個 todo 應用程式範例並不是一個大型的真實世界應用程式。因此,在這個特定應用程式中,正規化狀態並不如想像中那麼有用,而且較難看出潛在的好處。

幸運的是,在 第 8 部分:使用 Redux Toolkit 的現代 Redux 中,我們將看到一些方法,可以大幅縮短管理我們正規化狀態的 reducer 邏輯。

目前,需要了解的重要事項是

  • 正規化通常用於 Redux 應用程式
  • 主要好處是可以透過 ID 查詢個別項目,並確保狀態中只存在項目的單一副本
資訊

有關正規化為何對 Redux 有用的更多詳細資訊,請參閱

Thunk 和 Promise

我們還有一個最後的模式要檢視本節。我們已經看過如何根據已發送的動作來處理 Redux 儲存中的載入狀態。如果我們需要在元件中查看 thunk 的結果怎麼辦?

每當你呼叫 store.dispatch(action) 時,dispatch 實際上會將 action 作為其結果傳回。然後,中介軟體可以修改該行為並傳回其他值。

我們已經看過 Redux Thunk 中介軟體讓我們可以將函式傳遞給 dispatch,呼叫函式,然後傳回結果

reduxThunkMiddleware.js
const reduxThunkMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
// Also, return whatever the thunk function returns
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

這表示我們可以撰寫會傳回承諾的 thunk 函式,並在我們的元件中等待該承諾

我們的 <Header> 元件已經會派送 thunk 以將新的待辦事項項目儲存到伺服器。讓我們在 <Header> 元件中新增一些載入狀態,然後在我們等待伺服器時停用文字輸入並顯示另一個載入旋轉器

src/features/header/Header.js
const Header = () => {
const [text, setText] = useState('')
const [status, setStatus] = useState('idle')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = async e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create and dispatch the thunk function itself
setStatus('loading')
// Wait for the promise returned by saveNewTodo
await dispatch(saveNewTodo(trimmedText))
// And clear out the text input
setText('')
setStatus('idle')
}
}

let isLoading = status === 'loading'
let placeholder = isLoading ? '' : 'What needs to be done?'
let loader = isLoading ? <div className="loader" /> : null

return (
<header className="header">
<input
className="new-todo"
placeholder={placeholder}
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{loader}
</header>
)
}

export default Header

現在,如果我們新增一個待辦事項,我們會在標題中看到一個旋轉器

Todo app - component loading spinner

你學到了什麼

正如你所見,Redux 應用程式中還有許多廣泛使用的模式。這些模式並非必要,而且一開始可能會需要撰寫更多程式碼,但它們提供了一些好處,例如讓邏輯可重複使用、封裝實作細節、改善應用程式效能,以及讓資料查詢變得更容易。

資訊

有關這些模式為何存在以及 Redux 的預期使用方式的更多詳細資訊,請參閱

以下是我們的應用程式在完全轉換為使用這些模式後的樣貌

摘要
  • 動作建立器函式封裝準備動作物件和 thunk
    • 動作建立器可以接受引數並包含設定邏輯,並傳回最終動作物件或 thunk 函式
  • 記憶化選擇器有助於改善 Redux 應用程式效能
    • Reselect 有個 createSelector API,用於產生記憶化選擇器
    • 記憶化選擇器如果給定相同的輸入,就會傳回相同的結果參考
  • 請求狀態應該儲存為列舉,而不是布林值
    • 使用 'idle''loading' 等枚舉有助於一致地追蹤狀態
  • 「Flux 標準動作」是組織動作物件的常見慣例
    • 動作使用 payload 表示資料、meta 表示額外描述,以及 error 表示錯誤
  • 正規化狀態可以更容易地透過 ID 找到項目
    • 正規化資料儲存在物件中,而不是陣列中,且項目 ID 作為金鑰
  • Thunk 可以從 dispatch 傳回承諾
    • 元件可以等待非同步 thunk 完成,然後執行更多工作

下一步?

「手動」撰寫所有這些程式碼會很耗時且困難。這就是我們建議您使用我們的官方 Redux Toolkit 套件來撰寫 Redux 邏輯的原因

Redux Toolkit 包含 API,可以協助您撰寫所有典型的 Redux 使用模式,但程式碼更少。它也有助於防止常見的錯誤,例如意外變異狀態。

第 8 部分:現代 Redux 中,我們將介紹如何使用 Redux Toolkit 來簡化我們到目前為止撰寫的所有程式碼。