跳至主要內容

Redux 基礎知識,第 8 部分:使用 Redux Toolkit 的現代 Redux

您將學到什麼
  • 如何使用 Redux Toolkit 簡化您的 Redux 邏輯
  • 學習和使用 Redux 的後續步驟

恭喜您,您已完成本教學的最後一部分!在我們完成之前,我們還有一個主題要介紹。

如果您想回顧我們到目前為止所涵蓋的內容,請查看此摘要

資訊

回顧:您已學習的內容

  • 第 1 部分:概觀:
    • Redux 是什麼、何時/為何使用它,以及 Redux 應用程式的基本組成部分
  • 第 2 部分:概念和資料流程:
    • Redux 如何使用「單向資料流程」模式
  • 第 3 部分:狀態、動作和 Reducer:
    • Redux 狀態由純粹的 JS 資料組成
    • 動作是描述應用程式中「發生什麼事」事件的物件
    • Reducer 會採用目前的狀態和一個動作,並計算出一個新的狀態
    • Reducer 必須遵循「不可變更新」和「無副作用」等規則
  • 第 4 部分:儲存:
    • createStore API 會使用根部 Reducer 函式建立一個 Redux 儲存
    • 儲存可以使用「增強器」和「中間件」進行自訂
    • Redux DevTools 擴充功能讓您可以隨著時間查看您的狀態如何變更
  • 第 5 部分:UI 和 React:
    • Redux 與任何 UI 分開,但經常與 React 搭配使用
    • React-Redux 提供 API,讓 React 組件可以與 Redux 儲存進行通訊
    • useSelector 會從 Redux 狀態讀取值,並訂閱更新
    • useDispatch 讓組件可以發送動作
    • <Provider> 會封裝您的應用程式,並讓組件可以存取儲存
  • 第 6 部分:非同步邏輯和資料擷取:
    • Redux 中間件允許撰寫具有副作用的邏輯
    • 中間件會在 Redux 資料流程中新增一個步驟,啟用非同步邏輯
    • Redux「thunk」函式是撰寫基本非同步邏輯的標準方式
  • 第 7 部分:標準 Redux 模式:
    • 動作建立器會封裝準備動作物件和 thunk
    • 記憶化選擇器會最佳化計算轉換後的資料
    • 要求狀態應追蹤載入狀態列舉值
    • 正規化狀態讓根據 ID 查詢項目變得更容易

正如您所見,Redux 的許多面向都涉及撰寫一些可能會很冗長的程式碼,例如不可變更新、動作類型和動作建立器,以及正規化狀態。這些模式存在都有其充分的理由,但「手動」撰寫該程式碼可能會很困難。此外,設定 Redux 儲存的程序需要幾個步驟,而且我們必須針對某些事情提出自己的邏輯,例如在 thunk 中發送「載入中」動作或處理正規化資料。最後,許多時候使用者不確定撰寫 Redux 邏輯的「正確方式」是什麼。

這就是 Redux 團隊建立 Redux Toolkit 的原因:我們官方、主觀、包含「電池」的工具組,可有效率地進行 Redux 開發

Redux Toolkit 包含我們認為建置 Redux 應用程式不可或缺的套件和函式。Redux Toolkit 建置在我們建議的最佳實務中,簡化大部分 Redux 任務,避免常見錯誤,並讓撰寫 Redux 應用程式變得更簡單。

基於這個原因,Redux Toolkit 是撰寫 Redux 應用程式邏輯的標準方式。您到目前為止在本教學課程中撰寫的「手寫」Redux 邏輯是實際運作的程式碼,但您不應該手寫 Redux 邏輯 - 我們在本教學課程中涵蓋這些方法,以便您了解 Redux 如何運作。然而,對於實際應用程式,您應該使用 Redux Toolkit 撰寫 Redux 邏輯。

當您使用 Redux Toolkit 時,我們到目前為止涵蓋的所有概念(動作、簡化器、儲存設定、動作建立器、thunk 等)仍然存在,但Redux Toolkit 提供更簡單的方式來撰寫該程式碼

提示

Redux Toolkit 涵蓋 Redux 邏輯 - 我們仍然使用 React-Redux 讓我們的 React 元件與 Redux 儲存體通訊,包括 useSelectoruseDispatch

因此,讓我們看看如何使用 Redux Toolkit 簡化我們已在範例待辦事項應用程式中撰寫的程式碼。我們主要會改寫我們的「區塊」檔案,但我們應該能夠保留所有 UI 程式碼不變。

在我們繼續之前,將 Redux Toolkit 套件新增到您的應用程式

npm install @reduxjs/toolkit

儲存設定

我們已經經歷了 Redux 儲存體設定邏輯的幾個反覆運算。目前,它看起來像這樣

src/rootReducer.js
import { combineReducers } from 'redux'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const rootReducer = combineReducers({
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
})

export default rootReducer
src/store.js
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'

const composedEnhancer = composeWithDevTools(applyMiddleware(thunkMiddleware))

const store = createStore(rootReducer, composedEnhancer)
export default store

請注意,設定程序需要幾個步驟。我們必須

  • 將切片 reducer 結合在一起以形成根 reducer
  • 將根 reducer 匯入 store 檔案
  • 匯入 thunk 中介軟體、applyMiddlewarecomposeWithDevTools API
  • 使用中介軟體和 devtools 建立 store 增強器
  • 使用根 reducer 建立 store

如果我們可以減少此處的步驟數,那就太好了。

使用 configureStore

Redux Toolkit 有 configureStore API,可簡化 store 設定程序configureStore 包裹在 Redux 核心 createStore API 周圍,並自動為我們處理大部分 store 設定。事實上,我們可以有效地將其縮減為一個步驟

src/store.js
import { configureStore } from '@reduxjs/toolkit'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const store = configureStore({
reducer: {
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
}
})

export default store

configureStore 的一次呼叫為我們完成了所有工作

  • 它將 todosReducerfiltersReducer 結合到根 reducer 函式中,該函式將處理看起來像 {todos, filters} 的根狀態
  • 它使用該根 reducer 建立 Redux store
  • 它自動新增 thunk 中介軟體
  • 它自動新增更多中介軟體來檢查常見錯誤,例如意外變異狀態
  • 它自動設定 Redux DevTools Extension 連線

我們可以透過開啟我們的範例待辦事項應用程式並使用它來確認這是否有效。我們所有現有的功能程式碼都能正常運作!由於我們正在發送動作、發送 thunk、在 UI 中讀取狀態,並在 DevTools 中查看動作記錄,因此所有這些部分都必須正確運作。我們所做的只是切換 store 設定程式碼。

讓我們看看如果我們意外變異一些狀態,現在會發生什麼事。如果我們變更「待辦事項載入」reducer,使其直接變更狀態欄位,而不是不可變地製作一份副本,會怎樣?

src/features/todos/todosSlice
export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
// ❌ WARNING: example only - don't do this in a normal reducer!
state.status = 'loading'
return state
}
default:
return state
}
}

糟糕。我們的整個應用程式剛剛崩潰!發生什麼事了?

Immutability check middleware error

這個錯誤訊息是一件好事 - 我們在我們的應用程式中發現了一個錯誤! configureStore 特別新增了一個額外的中介軟體,只要它看到我們狀態的意外變異(僅在開發模式下),就會自動擲回錯誤。這有助於在編寫程式碼時發現我們可能犯下的錯誤。

套件清理

Redux Toolkit 已包含我們正在使用的幾個套件,例如 reduxredux-thunkreselect,並重新匯出這些 API。因此,我們可以稍微清理我們的專案。

首先,我們可以將我們的 createSelector 匯入改為來自 '@reduxjs/toolkit' 而不是 'reselect'。然後,我們可以移除在 package.json 中列出的個別套件

npm uninstall redux redux-thunk reselect

說清楚一點,我們仍然使用這些套件,並且需要安裝它們。但是,由於 Redux Toolkit 依賴於它們,因此當我們安裝 @reduxjs/toolkit 時,它們會自動安裝,所以我們不需要在 package.json 檔案中特別列出其他套件。

撰寫 Slice

當我們為應用程式新增新功能時,Slice 檔案變得更大且更複雜。特別是,todosReducer 變得更難閱讀,因為所有巢狀物件散佈用於不可變更新,而且我們已經撰寫了多個動作建立器函式。

Redux Toolkit 有 createSlice API,它將有助於我們簡化 Redux reducer 邏輯和動作createSlice 為我們做了幾件重要的事情

  • 我們可以將案例 reducer 寫成物件內的函式,而不是必須寫 switch/case 陳述式
  • reducer 將能夠撰寫較短的不可變更新邏輯
  • 所有動作建立器都將根據我們提供的 reducer 函式自動產生

使用 createSlice

createSlice 接收一個物件,其中包含三個主要選項欄位

  • name:將用作產生動作類型的字首的字串
  • initialState:reducer 的初始狀態
  • reducers:物件,其中鍵是字串,值是將處理特定動作的「案例 reducer」函式

讓我們先看一個小型獨立範例。

createSlice 範例
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
entities: [],
status: null
}

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
// ✅ This "mutating" code is okay inside of createSlice!
state.entities.push(action.payload)
},
todoToggled(state, action) {
const todo = state.entities.find(todo => todo.id === action.payload)
todo.completed = !todo.completed
},
todosLoading(state, action) {
return {
...state,
status: 'loading'
}
}
}
})

export const { todoAdded, todoToggled, todosLoading } = todosSlice.actions

export default todosSlice.reducer

在這個範例中可以看到幾件事

  • 我們在 reducers 物件內撰寫案例 reducer 函式,並給予它們可讀的名稱
  • createSlice 將自動產生動作建立器,它們對應於我們提供的每個案例 reducer 函式
  • createSlice 在預設案例中自動傳回現有狀態
  • createSlice 允許我們安全地「變異」我們的狀態!
  • 但是,如果我們願意,我們也可以像以前一樣建立不可變副本

產生的動作建立器將可用作 slice.actions.todoAdded,我們通常會解構並個別匯出它們,就像我們之前使用動作建立器所做的那樣。完整的 reducer 函式可用作 slice.reducer,我們通常會 export default slice.reducer,這也和之前一樣。

那麼這些自動產生的動作物件是什麼樣子的?讓我們嘗試呼叫其中一個並記錄動作來看看

console.log(todoToggled(42))
// {type: 'todos/todoToggled', payload: 42}

createSlice 為我們產生動作類型字串,方法是將 Slice 的 name 欄位與我們撰寫的 reducer 函式的 todoToggled 名稱結合。預設情況下,動作建立器接受一個引數,它會將該引數放入動作物件中作為 action.payload

在產生的 reducer 函式內,createSlice 會檢查已發送動作的 action.type 是否與它產生的其中一個名稱相符。如果是,它會執行那個 case reducer 函式。這與我們使用 switch/case 陳述式自己撰寫的模式完全相同,但 createSlice 會自動為我們執行。

也值得更詳細地討論「變異」面向。

使用 Immer 進行不變更新

稍早,我們討論了「變異」(修改現有的物件/陣列值)和「不變性」(將值視為無法變更的事物)。

危險

在 Redux 中,我們的 reducer 絕不 允許變異原始/目前的狀態值!

// ❌ Illegal - by default, this will mutate the state!
state.value = 123

因此,如果我們無法變更原始值,我們要如何傳回更新的狀態?

提示

Reducer 只可以製作原始值的副本,然後它們可以變異副本。

// This is safe, because we made a copy
return {
...state,
value: 123
}

正如您在本教學課程中所見,我們可以使用 JavaScript 的陣列/物件擴充運算子和其他傳回原始值副本的函式,手動撰寫不變更新。但是,手動撰寫不變更新邏輯困難,而意外在 reducer 中變異狀態是 Redux 使用者最常犯的錯誤。

這就是 Redux Toolkit 的 createSlice 函式讓您可以更輕鬆地撰寫不變更新的原因!

createSlice 在內部使用一個稱為 Immer 的函式庫。Immer 使用一個稱為 Proxy 的特殊 JS 工具來包裝您提供的資料,並讓您可以撰寫「變異」包裝資料的程式碼。但是,Immer 會追蹤您嘗試進行的所有變更,然後使用變更清單傳回安全的不變更新值,就像您手動撰寫所有不變更新邏輯一樣。

所以,取代這個

function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}

你可以撰寫看起來像這樣的程式碼

function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}

這好讀多了!

但是,這裡有些非常重要的東西要記住

危險

只能在 Redux Toolkit 的 createSlicecreateReducer 中撰寫「變異」邏輯,因為它們在內部使用 Immer!如果你在沒有 Immer 的情況下在 reducer 中撰寫變異邏輯,它變異狀態並導致錯誤!

Immer 仍然允許我們手動撰寫不可變更新,並在我們想要時自行傳回新值。你甚至可以混合搭配。例如,從陣列中移除項目通常使用 array.filter() 比較容易,因此你可以呼叫它,然後將結果指定給 state 以「變異」它

// can mix "mutating" and "immutable" code inside of Immer:
state.todos = state.todos.filter(todo => todo.id !== action.payload)

轉換 Todos Reducer

讓我們開始將我們的 todos 片段檔案轉換為使用 createSlice。我們將先從我們的 switch 陳述式中挑選幾個特定案例,以顯示這個程序如何運作。

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
status: 'idle',
entities: {}
}

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
}
}
})

export const { todoAdded, todoToggled } = todosSlice.actions

export default todosSlice.reducer

我們範例應用程式中的 todos reducer 仍然使用巢狀在父物件中的正規化狀態,因此這裡的程式碼與我們剛才看過的微型 createSlice 範例有點不同。還記得我們必須撰寫許多巢狀展開運算子才能在前面切換那個 todo 嗎?現在,相同的程式碼了很多,而且好讀多了。

讓我們為這個 reducer 再新增幾個案例。

src/features/todos/todosSlice.js
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted(state, action) {
delete state.entities[action.payload]
}
}
})

export const { todoAdded, todoToggled, todoColorSelected, todoDeleted } =
todosSlice.actions

export default todosSlice.reducer

todoAddedtodoToggled 的動作建立器只需要一個參數,例如整個 todo 物件或 todo ID。但是,如果我們需要傳入多個參數,或執行我們討論過的某些「準備」邏輯,例如產生唯一 ID,該怎麼辦?

createSlice 允許我們透過新增「準備回呼」到 reducer 來處理這些情況。我們可以傳遞一個物件,其中包含名為 reducerprepare 的函式。當我們呼叫產生的動作建立器時,prepare 函式將會使用傳入的任何參數被呼叫。然後,它應該建立並傳回一個具有 payload 欄位(或選擇性地具有 metaerror 欄位)的物件,符合Flux 標準動作慣例

在此,我們使用準備回呼讓我們的 todoColorSelected 動作建立器接受分開的 todoIdcolor 參數,並將它們組合成 action.payload 中的物件。

同時,在 todoDeleted 減少器中,我們可以使用 JS delete 算子從我們的正規化狀態中移除項目。

我們可以使用這些相同的模式重寫 todosSlice.jsfiltersSlice.js 中的其餘減少器。

以下是我們將所有區塊轉換後的程式碼樣貌

撰寫 Thunk

我們已經看過如何 撰寫會派送「載入中」、「請求成功」和「請求失敗」動作的 Thunk。我們必須撰寫動作建立器、動作類型減少器來處理這些案例。

由於這個模式非常常見,Redux Toolkit 有個 createAsyncThunk API,會為我們產生這些 Thunk。它也會為這些不同的請求狀態動作產生動作類型和動作建立器,並根據結果的 Promise 自動派送這些動作。

提示

Redux Toolkit 有個新的 RTK Query 資料擷取 API。RTK Query 是專為 Redux 應用程式打造的資料擷取和快取解決方案,可以消除撰寫任何 Thunk 或減少器來管理資料擷取的需求。我們鼓勵您嘗試看看,了解它是否可以簡化您自己的應用程式中的資料擷取程式碼!

我們會在不久後更新 Redux 教學,加入使用 RTK Query 的章節。在此之前,請參閱 Redux Toolkit 文件中的 RTK Query 章節

使用 createAsyncThunk

讓我們透過使用 createAsyncThunk 產生 Thunk,來取代我們的 fetchTodos Thunk。

createAsyncThunk 接受兩個參數

  • 一個字串,將用作產生的動作類型的字首
  • 一個「酬載建立器」回呼函式,應該傳回一個 Promise。這通常使用 async/await 語法撰寫,因為 async 函式會自動傳回一個 Promise。
src/features/todos/todosSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// omit imports and state

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})

// omit exports

我們傳入 'todos/fetchTodos' 作為字串前綴,以及一個呼叫我們的 API 並傳回包含已擷取資料的 Promise 的「酬載建立器」函式。在內部,createAsyncThunk 會產生三個動作建立器和動作類型,以及一個在呼叫時會自動派送這些動作的 thunk 函式。在此情況下,動作建立器及其類型為

  • fetchTodos.pendingtodos/fetchTodos/pending
  • fetchTodos.fulfilledtodos/fetchTodos/fulfilled
  • fetchTodos.rejectedtodos/fetchTodos/rejected

但是,這些動作建立器和類型是在 createSlice 呼叫外部定義的。我們無法在 createSlice.reducers 欄位中處理它們,因為它們也會產生新的動作類型。我們需要一種方法讓我們的 createSlice 呼叫可以偵聽在其他地方定義的其他動作類型。

createSlice 也接受 extraReducers 選項,我們可以在其中讓同一個切片還原器偵聽其他動作類型。此欄位應為具有 builder 參數的回呼函式,我們可以呼叫 builder.addCase(actionCreator, caseReducer) 來偵聽其他動作。

因此,我們在此處呼叫了 builder.addCase(fetchTodos.pending, caseReducer)。當該動作被派送時,我們將執行還原器,將 state.status = 'loading' 設為與先前在 switch 陳述式中撰寫該邏輯時相同。我們可以對 fetchTodos.fulfilled 執行相同操作,並處理我們從 API 收到的資料。

再舉一個範例,我們來轉換 saveNewTodo。此 thunk 將新 todo 物件的 text 作為其參數,並將其儲存到伺服器。我們如何處理它?

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

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit case reducers
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
.addCase(saveNewTodo.fulfilled, (state, action) => {
const todo = action.payload
state.entities[todo.id] = todo
})
}
})

// omit exports and selectors

saveNewTodo 的處理程序與我們在 fetchTodos 中看到的相同。我們呼叫 createAsyncThunk,並傳入動作前綴和酬載建立器。在酬載建立器內,我們執行非同步 API 呼叫,並傳回結果值。

在此情況下,當我們呼叫 dispatch(saveNewTodo(text)) 時,text 值會作為其第一個引數傳遞到酬載建立器中。

雖然我們不會在此處更詳細地介紹 createAsyncThunk,但提供幾個其他快速注意事項供參考

  • 當你派送 thunk 時,你只能傳遞一個引數給 thunk。如果你需要傳遞多個值,請將它們傳遞到一個單一物件中
  • 酬載建立器會接收一個物件作為其第二個引數,其中包含 {getState, dispatch},以及一些其他有用的值
  • thunk 會在執行酬載建立器之前傳送 pending 動作,然後根據傳回的 Promise 成功或失敗,傳送 fulfilledrejected

正規化狀態

我們先前看過如何「正規化」狀態,方法是將項目儲存在物件中,並以項目 ID 作為鍵值。這讓我們可以透過 ID 查詢任何項目,而不用迴圈遍歷整個陣列。但是,手動撰寫邏輯來更新正規化狀態既冗長又乏味。使用 Immer 撰寫「變異」更新程式碼可以簡化這個過程,但仍然可能有很多重複的部份 - 我們的應用程式中可能會載入許多不同類型的項目,而且我們必須每次都重複相同的簡化器邏輯。

Redux Toolkit 包含一個 createEntityAdapter API,其中有針對正規化狀態的典型資料更新作業預先建置的簡化器。這包括新增、更新和移除區段中的項目。createEntityAdapter 也會產生一些暫存的選取器,用於從儲存區中讀取值

使用 createEntityAdapter

讓我們用 createEntityAdapter 取代我們的正規化實體簡化器邏輯。

呼叫 createEntityAdapter 會提供一個「適配器」物件,其中包含多個預先製作的簡化器函式,包括

  • addOne / addMany:新增項目至狀態
  • upsertOne / upsertMany:新增項目或更新現有項目
  • updateOne / updateMany:透過提供部分值來更新現有項目
  • removeOne / removeMany:根據 ID 移除項目
  • setAll:取代所有現有項目

我們可以使用這些函式作為區段簡化器,或在 createSlice 中作為「變異輔助函式」。

適配器還包含

  • getInitialState:傳回一個看起來像 { ids: [], entities: {} } 的物件,用於儲存項目的正規化狀態以及所有項目 ID 的陣列
  • getSelectors:產生標準的選取器函式集

讓我們看看如何在我們的待辦事項區段中使用這些函式

src/features/todos/todosSlice.js
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
// omit some imports

const todosAdapter = createEntityAdapter()

const initialState = todosAdapter.getInitialState({
status: 'idle'
})

// omit thunks

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit some reducers
// Use an adapter reducer function to remove a todo by ID
todoDeleted: todosAdapter.removeOne,
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
// Use an adapter function as a "mutating" update helper
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
// Use another adapter function as a reducer to add a todo
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})

// omit selectors

不同的轉接器簡化函式會根據函式採用不同的值,這些值都位於 action.payload 中。「新增」和「更新」函式會採用單一項目或項目陣列,「移除」函式會採用單一 ID 或 ID 陣列,依此類推。

getInitialState 允許我們傳入將包含的其他狀態欄位。在本例中,我們傳入 status 欄位,讓我們得到最終的待辦事項區段狀態 {ids, entities, status},這與我們之前擁有的狀態非常類似。

我們也可以取代一些待辦事項選取器函式。getSelectors 轉接器函式會產生選取器,例如 selectAll,它會傳回所有項目的陣列,以及 selectById,它會傳回一個項目。然而,由於 getSelectors 不知道我們的資料位於整個 Redux 狀態樹的哪個位置,因此我們需要傳入一個小型選取器,讓它從整個狀態樹中傳回這個區段。讓我們改用這些選取器。由於這是我們程式碼的最後一個重大變更,因此這次我們將包含整個待辦事項區段檔案,看看使用 Redux Toolkit 之後,程式碼的最終版本會是什麼樣子

src/features/todos/todosSlice.js
import {
createSlice,
createSelector,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '../../api/client'
import { StatusFilters } from '../filters/filtersSlice'

const todosAdapter = createEntityAdapter()

const initialState = todosAdapter.getInitialState({
status: 'idle'
})

// Thunk functions
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted: todosAdapter.removeOne,
allTodosCompleted(state, action) {
Object.values(state.entities).forEach(todo => {
todo.completed = true
})
},
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})

export const {
allTodosCompleted,
completedTodosCleared,
todoAdded,
todoColorSelected,
todoDeleted,
todoToggled
} = todosSlice.actions

export default todosSlice.reducer

export const { selectAll: selectTodos, selectById: selectTodoById } =
todosAdapter.getSelectors(state => state.todos)

export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
selectTodos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)

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

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

我們呼叫 todosAdapter.getSelectors,並傳入 state => state.todos 選取器,讓它傳回這個狀態區段。從這裡開始,轉接器會產生一個 selectAll 選取器,它會採用整個 Redux 狀態樹,就像平常一樣,並在 state.todos.entitiesstate.todos.ids 上進行迴圈,讓我們得到完整的待辦事項物件陣列。由於 selectAll 沒有告訴我們正在選取什麼,因此我們可以使用解構語法將函式重新命名為 selectTodos。類似地,我們可以將 selectById 重新命名為 selectTodoById

請注意,我們的其他選取器仍然使用 selectTodos 作為輸入。這是因為它始終傳回待辦事項物件陣列,無論我們是將陣列保留為整個 state.todos、將它保留為巢狀陣列,或是將它儲存為正規化物件並轉換為陣列。即使我們對資料儲存方式進行了所有這些變更,使用選取器讓我們可以讓其餘程式碼保持不變,而使用記憶選取器則有助於 UI 透過避免不必要的重新渲染來提升效能。

你學到了什麼

恭喜!你已經完成「Redux 基礎」教學課程!

現在你應該對 Redux 是什麼、它的運作方式以及如何正確使用它有扎實的了解

  • 管理全域應用程式狀態
  • 將應用程式的狀態維持為純粹的 JS 資料
  • 撰寫描述應用程式中「發生什麼事」的動作物件
  • 使用查看目前狀態和動作的簡化函式,並建立一個新的狀態,作為回應
  • 使用 useSelector 在 React 元件中讀取 Redux 狀態
  • 使用 useDispatch 從 React 元件傳送動作

此外,您已看到 Redux Toolkit 如何簡化 Redux 邏輯的撰寫,以及為什麼 Redux Toolkit 是撰寫實際 Redux 應用程式的標準方法。透過先了解如何「手動」撰寫 Redux 程式碼,您應該會清楚 createSlice 等 Redux Toolkit API 為您執行了哪些工作,讓您不必自己撰寫該程式碼。

資訊

如需有關 Redux Toolkit 的更多資訊,包括使用指南和 API 參考,請參閱

讓我們最後再看一次已完成的待辦事項應用程式,包括已轉換為使用 Redux Toolkit 的所有程式碼

我們將最後回顧一下您在本節中學到的重點

摘要
  • Redux Toolkit (RTK) 是撰寫 Redux 邏輯的標準方法
    • RTK 包含簡化大部分 Redux 程式碼的 API
    • RTK 包覆 Redux 核心,並包含其他有用的套件
  • configureStore 設定一個具有良好預設值的 Redux 儲存
    • 自動結合區塊簡化函式以建立根簡化函式
    • 自動設定 Redux DevTools Extension 和除錯中介軟體
  • createSlice 簡化 Redux 動作和簡化函式的撰寫
    • 根據區段/簡化器名稱自動產生動作建立器
    • 簡化器可以使用 Immer 在 createSlice 內部「變異」狀態
  • createAsyncThunk 為非同步呼叫產生 thunk
    • 自動產生 thunk + pending/fulfilled/rejected 動作建立器
    • 傳送 thunk 會執行您的 payload 建立器並傳送動作
    • Thunk 動作可以在 createSlice.extraReducers 中處理
  • createEntityAdapter 提供簡化器 + 選擇器,用於正規化的狀態
    • 包含用於常見任務的簡化器函數,例如新增/更新/移除項目
    • selectAllselectById 產生記憶化選擇器

學習和使用 Redux 的下一步

現在您已完成本教學課程,我們有幾個建議,供您嘗試進一步瞭解 Redux。

本「基礎」教學課程著重於 Redux 的低階層面:手動撰寫動作類型和不可變更新、Redux 儲存體和中間件如何運作,以及我們為何使用動作建立器和正規化狀態等模式。此外,我們的待辦事項範例應用程式相當小,並非用於建立完整應用程式的實際範例。

然而,我們的 「Redux Essentials」教學課程 特別教導您如何建立「真實世界」類型的應用程式。它著重於「如何正確使用 Redux」以及使用 Redux Toolkit 的方式,並討論您在較大型應用程式中會看到的更實際模式。它涵蓋許多與本「基礎」教學課程相同的主題,例如簡化器為何需要使用不可變更新,但著重於建立實際運作的應用程式。我們強烈建議您將「Redux Essentials」教學課程作為您的下一步。

同時,本教學課程中涵蓋的概念應足以讓您開始使用 React 和 Redux 建立自己的應用程式。現在是嘗試自己執行專案以強化這些概念並瞭解它們在實務中如何運作的絕佳時機。如果您不確定要建立哪種類型的專案,請參閱 此應用程式專案構想清單 以獲得一些靈感。

使用 Redux 區段包含許多重要概念的資訊,例如 如何建構您的簡化器,而 我們的風格指南頁面 則包含有關我們建議模式和最佳實務的重要資訊。

如果您想進一步瞭解 Redux 為何 存在、它試圖解決哪些問題以及它應如何使用,請參閱 Redux 維護者 Mark Erikson 在 Redux 的道,第 1 部分:實作和意圖Redux 的道,第 2 部分:實務和哲學 中的文章。

如果您需要 Redux 問題的協助,請加入 Discord 上 Reactiflux 伺服器的 #redux 頻道

感謝您閱讀本教學,我們希望您享受使用 Redux 建置應用程式!