Redux 精華,第 5 部分:非同步邏輯和資料擷取
- 如何使用 Redux 「thunk」中間件進行非同步邏輯
- 處理非同步請求狀態的模式
- 如何使用 Redux Toolkit
createAsyncThunk
API 簡化非同步呼叫
- 熟悉使用 AJAX 請求從伺服器擷取和更新資料
簡介
在 第 4 部分:使用 Redux 資料 中,我們了解如何使用 Redux 儲存庫中的多個資料片段在 React 元件內部,自訂動作物件的內容,然後再將它們傳送出去,以及在我們的 reducer 中處理更複雜的更新邏輯。
到目前為止,我們處理的所有資料都直接在我們的 React 用戶端應用程式內部。然而,大多數實際應用程式都需要透過建立 HTTP API 呼叫來擷取和儲存項目,才能處理來自伺服器的資料。
在本節中,我們將轉換我們的社群媒體應用程式,從 API 擷取貼文和使用者資料,並透過將它們儲存到 API 中來新增新的貼文。
Redux Toolkit 包含 RTK Query 資料擷取和快取 API。RTK Query 是專為 Redux 應用程式打造的資料擷取和快取解決方案,可以消除編寫任何 thunk 或 reducer 來管理資料擷取的需要。我們特別將 RTK Query 教導為資料擷取的預設方式,而 RTK Query 建立於此頁面中顯示的相同模式上。
我們將在 第 7 部分:RTK Query 基礎 中介紹如何使用 RTK Query。
範例 REST API 和用戶端
為了讓範例專案保持孤立但寫實,初始專案設定已包含一個假的記憶體中 REST API,用於我們的資料(使用 Mock Service Worker 模擬 API 工具 設定)。API 使用 /fakeApi
作為端點的基礎 URL,並支援 /fakeApi/posts
、/fakeApi/users
和 fakeApi/notifications
的典型 GET/POST/PUT/DELETE
HTTP 方法。它定義在 src/api/server.js
中。
專案還包含一個小型 HTTP API 用戶端物件,它公開 client.get()
和 client.post()
方法,類似於 axios
等熱門 HTTP 函式庫。它定義在 src/api/client.js
中。
我們將使用 client
物件對此區段的記憶體中假 REST API 進行 HTTP 呼叫。
此外,模擬伺服器已設定為每次載入頁面時重複使用相同的隨機種子,以便產生相同的假使用者和假貼文清單。如果您想重設它,請刪除瀏覽器本地儲存空間中的 'randomTimestampSeed'
值並重新載入頁面,或者您可以透過編輯 src/api/server.js
並將 useSeededRNG
設定為 false
來關閉它。
提醒您,程式碼範例著重於每個區段的關鍵概念和變更。請參閱 CodeSandbox 專案和 專案儲存庫中的 tutorial-steps
分支,以取得應用程式中的完整變更。
Thunk 和非同步邏輯
使用中介軟體啟用非同步邏輯
Redux 儲存本身對非同步邏輯一無所知。它只知道如何同步發送動作、透過呼叫根 reducer 函式更新狀態,並通知 UI 有所變更。任何非同步性都必須發生在儲存之外。
但是,如果你想讓非同步邏輯透過分派或檢查目前的儲存狀態與儲存互動,該怎麼辦?這就是 Redux 中介軟體 的用武之地。它們擴充了儲存,並允許你
- 在分派任何動作時執行額外的邏輯(例如記錄動作和狀態)
- 暫停、修改、延遲、取代或停止分派的動作
- 撰寫有權存取
dispatch
和getState
的額外程式碼 - 教導
dispatch
如何接受純粹動作物件以外的其他值,例如函式和承諾,方法是攔截它們並改為分派實際的動作物件
使用中介軟體最常見的原因是允許不同類型的非同步邏輯與儲存互動。這允許你撰寫可以分派動作並檢查儲存狀態的程式碼,同時將該邏輯與你的使用者介面分開。
Redux 有許多種類的非同步中介軟體,每種中介軟體都讓你使用不同的語法來撰寫邏輯。最常見的非同步中介軟體是 redux-thunk
,它讓你撰寫可能直接包含非同步邏輯的純粹函式。Redux Toolkit 的 configureStore
函式 預設自動設定 thunk 中介軟體,而 我們建議使用 thunk 作為使用 Redux 撰寫非同步邏輯的標準方法。
稍早,我們看過 Redux 的同步資料流程是什麼樣子。當我們引入非同步邏輯時,我們會新增一個額外步驟,讓中介軟體可以執行像 AJAX 要求的邏輯,然後分派動作。這讓非同步資料流程看起來像這樣
Thunk 函式
一旦 thunk 中介軟體已新增到 Redux 儲存,它會允許你將 thunk 函式 直接傳遞給 store.dispatch
。thunk 函式會總是使用 (dispatch, getState)
作為其引數被呼叫,而你可以根據需要在 thunk 內部使用它們。
Thunk 通常使用動作建立器分派純粹動作,例如 dispatch(increment())
const store = configureStore({ reducer: counterReducer })
const exampleThunkFunction = (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment())
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
store.dispatch(exampleThunkFunction)
為了與分派一般動作物件保持一致,我們通常將這些寫成 thunk 動作建立器,它會傳回 thunk 函式。這些動作建立器可以採用可以在 thunk 內部使用的引數。
const logAndAdd = amount => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}
store.dispatch(logAndAdd(5))
Thunk 通常寫在「區塊」檔案中。createSlice
本身沒有任何特別支援來定義 thunk,所以你應該將它們寫成同一個區塊檔案中的個別函式。這樣一來,它們就可以存取該區塊的純粹動作建立器,而且很容易找到 thunk 的位置。
「thunk」這個字是程式設計術語,意思是 「一段執行延遲工作的程式碼」。有關如何使用 thunk 的詳細資訊,請參閱 thunk 使用指南頁面
以及這些文章
撰寫非同步 thunk
thunk 可能包含非同步邏輯,例如 setTimeout
、Promise
和 async/await
。這使得它們成為放置 AJAX 呼叫到伺服器 API 的好地方。
Redux 的資料擷取邏輯通常遵循可預測的模式
- 在請求之前會發送「開始」動作,以表示請求正在進行中。這可以用於追蹤載入狀態,以允許跳過重複的請求或在 UI 中顯示載入指示器。
- 執行非同步請求
- 根據請求結果,非同步邏輯會發送包含結果資料的「成功」動作,或包含錯誤詳細資料的「失敗」動作。在兩種情況下,reducer 邏輯都會清除載入狀態,並處理成功案例的結果資料,或儲存錯誤值以供潛在顯示。
這些步驟並非必需,但通常會使用。(如果您只關心成功的結果,則可以在請求完成時只發送一個「成功」動作,並略過「開始」和「失敗」動作。)
Redux Toolkit 提供了 createAsyncThunk
API 來實作這些動作的建立和發送,我們將很快了解如何使用它。
詳細說明:在 thunk 中發送請求狀態動作
如果我們要手動撰寫典型非同步 thunk 的程式碼,它可能看起來像這樣
const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = repoDetails => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = error => ({
type: 'repoDetails/fetchFailed',
error
})
const fetchIssuesCount = (org, repo) => async dispatch => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}
但是,使用這種方法撰寫程式碼很繁瑣。每種類型的請求都需要重複類似的實作
- 需要為三種不同的情況定義唯一的動作類型
- 這些動作類型中的每個動作類型通常都有對應的動作建立函式
- 必須撰寫一個 thunk,以正確的順序發送正確的動作
createAsyncThunk
透過產生動作類型和動作建立函式,並產生自動發送這些動作的 thunk,來抽象化此模式。您提供一個呼叫非同步呼叫並傳回包含結果的 Promise 的回呼函式。
載入文章
到目前為止,我們的 postsSlice
已使用一些硬編碼的範例資料作為其初始狀態。我們將改為從一個空的文章陣列開始,然後從伺服器擷取文章清單。
為了做到這一點,我們必須變更 postsSlice
中狀態的結構,以便我們可以追蹤 API 請求的目前狀態。
擷取文章選擇器
目前,postsSlice
狀態是 posts
的單一陣列。我們需要將其變更為包含 posts
陣列和載入狀態欄位的物件。
同時,UI 元件(例如 <PostsList>
)會嘗試在 useSelector
鉤子中從 state.posts
讀取文章,假設該欄位是陣列。我們也需要變更這些位置,以符合新的資料。
如果我們不必在每次變更簡化器中的資料格式時,就不斷地重新撰寫元件,那會很好。避免此情況的一種方法是在區段檔案中定義可重複使用的選擇器函式,並讓元件使用這些選擇器來擷取他們需要的資料,而不是在每個元件中重複選擇器邏輯。這樣,如果我們再次變更狀態結構,我們只需要更新區段檔案中的程式碼即可。
<PostsList>
元件需要讀取所有文章的清單,而 <SinglePostPage>
和 <EditPostForm>
元件需要透過其 ID 查詢單篇文章。我們從 postsSlice.js
匯出兩個小型選擇器函式,以涵蓋這些案例
const postsSlice = createSlice(/* omit slice code*/)
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = state => state.posts
export const selectPostById = (state, postId) =>
state.posts.find(post => post.id === postId)
請注意,這些選擇器函式的 state
參數是 Redux 根狀態物件,就像我們直接在 useSelector
中撰寫的內嵌匿名選擇器一樣。
我們可以在元件中使用它們
// omit imports
import { selectAllPosts } from './postsSlice'
export const PostsList = () => {
const posts = useSelector(selectAllPosts)
// omit component contents
}
// omit imports
import { selectPostById } from './postsSlice'
export const SinglePostPage = ({ match }) => {
const { postId } = match.params
const post = useSelector(state => selectPostById(state, postId))
// omit component logic
}
// omit imports
import { postUpdated, selectPostById } from './postsSlice'
export const EditPostForm = ({ match }) => {
const { postId } = match.params
const post = useSelector(state => selectPostById(state, postId))
// omit component logic
}
透過撰寫可重複使用的選擇器來封裝資料查詢通常是個好主意。您也可以建立「暫存」選擇器,有助於提升效能,我們將在稍後的部分中探討。
但是,就像任何抽象一樣,這不是您應該隨時隨地都做的事。撰寫選擇器表示有更多程式碼需要理解和維護。不要覺得您需要為狀態的每個欄位撰寫選擇器。嘗試從不使用任何選擇器開始,然後在您發現自己在應用程式程式碼的許多部分查詢相同值時再新增一些。
要求的載入狀態
當我們呼叫 API 時,我們可以將其進度視為一個小型狀態機,它可以處於四種可能的狀態之一
- 要求尚未開始
- 要求正在進行中
- 要求成功,我們現在擁有需要的資料
- 要求失敗,而且可能有一個錯誤訊息
我們可以使用一些布林值來追蹤該資訊,例如 isLoading: true
,但最好將這些狀態追蹤為單一列舉值。一個好的模式是有一個狀態區段,它看起來像這樣(使用 TypeScript 型別表示法)
{
// Multiple possible status enum values
status: 'idle' | 'loading' | 'succeeded' | 'failed',
error: string | null
}
這些欄位會與儲存的任何實際資料並存。這些特定的字串狀態名稱並非必要 - 如果你願意,可以自由使用其他名稱,例如使用 'pending'
取代 'loading'
,或使用 'complete'
取代 'succeeded'
。
我們可以使用此資訊來決定在要求進行時在我們的 UI 中顯示什麼,並在我們的 reducer 中新增邏輯以防止重複載入資料等情況。
讓我們更新我們的 postsSlice
以使用此模式來追蹤「擷取文章」要求的載入狀態。我們會將我們的狀態從本身就是一個文章陣列,切換成看起來像 {posts, status, error}
的樣子。我們也會從我們的初始狀態中移除舊的範例文章條目。作為此變更的一部分,我們也需要將任何使用 state
作為陣列的用法改為 state.posts
,因為陣列現在更深一層
import { createSlice, nanoid } from '@reduxjs/toolkit'
const initialState = {
posts: [],
status: 'idle',
error: null
}
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.posts.push(action.payload)
},
prepare(title, content, userId) {
// omit prepare logic
}
},
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = state => state.posts.posts
export const selectPostById = (state, postId) =>
state.posts.posts.find(post => post.id === postId)
是的,這確實表示我們現在有一個巢狀物件路徑,看起來像 state.posts.posts
,這有點重複而且愚蠢 :) 我們可以將巢狀陣列名稱變更為 items
或 data
或其他名稱,如果我們想避免這樣做,但我們現在會先保持原樣。
使用 createAsyncThunk
擷取資料
Redux Toolkit 的 createAsyncThunk
API 會產生 thunk,這些 thunk 會自動為你傳送那些「開始/成功/失敗」動作。
讓我們從新增一個 thunk 開始,它會執行 AJAX 呼叫以擷取文章清單。我們會從 src/api
資料夾匯入 client
工具程式,並使用它對 '/fakeApi/posts'
提出要求。
import { createSlice, nanoid, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '../../api/client'
const initialState = {
posts: [],
status: 'idle',
error: null
}
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.data
})
createAsyncThunk
接受兩個參數
- 用作產生的動作類型前置詞的字串
- 「酬載建立器」回呼函數,應傳回包含某些資料的
Promise
,或傳回包含錯誤的已拒絕Promise
酬載建立器通常會執行某種 AJAX 呼叫,而且可以直接傳回 AJAX 呼叫的 Promise
,或從 API 回應中萃取某些資料並傳回。我們通常使用 JS async/await
語法撰寫這段程式碼,這讓我們可以撰寫使用 Promise
的函數,同時使用標準的 try/catch
邏輯,而不是 somePromise.then()
鏈。
在這個案例中,我們傳入 'posts/fetchPosts'
作為動作類型前置詞。我們的酬載建立回呼函數會等到 API 呼叫傳回回應。回應物件看起來像 {data: []}
,而我們希望我們發出的 Redux 動作的酬載只有貼文的陣列。因此,我們萃取 response.data
,並從回呼函數傳回。
如果我們嘗試呼叫 dispatch(fetchPosts())
,fetchPosts
thunk 會先發出動作類型為 'posts/fetchPosts/pending'
的動作
我們可以在我們的 reducer 中偵聽這個動作,並將要求狀態標記為 'loading'
。
一旦 Promise
解決,fetchPosts
thunk 會取得我們從回呼函數傳回的 response.data
陣列,並發出 'posts/fetchPosts/fulfilled'
動作,其中包含貼文陣列作為 action.payload
從元件發出 Thunk
因此,讓我們更新我們的 <PostsList>
元件,以實際自動為我們擷取這些資料。
我們會將 fetchPosts
thunk 匯入元件。就像我們所有其他動作建立器一樣,我們必須發出它,因此我們也需要新增 useDispatch
勾子。由於我們希望在 <PostsList>
掛載時擷取這些資料,因此我們需要匯入 React useEffect
勾子
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
// omit other imports
import { selectAllPosts, fetchPosts } from './postsSlice'
export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)
const postStatus = useSelector(state => state.posts.status)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
// omit rendering logic
}
重要的是,我們只嘗試擷取貼文清單一次。如果我們在每次 <PostsList>
元件重新整理時,或因為我們在檢視之間切換而重新建立時執行這項動作,我們可能會結束多次擷取貼文。我們可以使用 posts.status
枚舉來協助決定我們是否需要實際開始擷取,方法是將它選入元件,並僅在狀態為 'idle'
時開始擷取。
Reducer 和載入動作
接下來,我們需要在 reducer 中處理這兩個動作。這需要更深入了解我們一直在使用的 createSlice
API。
我們已經看到 createSlice
將為我們在 reducers
欄位中定義的每個 reducer 函式產生一個動作建立器,而且產生的動作類型包含區塊的名稱,例如
console.log(
postUpdated({ id: '123', title: 'First Post', content: 'Some text here' })
)
/*
{
type: 'posts/postUpdated',
payload: {
id: '123',
title: 'First Post',
content: 'Some text here'
}
}
*/
然而,有時候區塊 reducer 需要回應未定義為此區塊 reducers
欄位一部分的其他動作。我們可以使用區塊 extraReducers
欄位來執行此操作。
extraReducers
選項應為接收稱為 builder
的參數的函式。builder
物件提供讓我們定義其他案例 reducer 的方法,這些 reducer 會在回應區塊外部定義的動作時執行。我們將使用 builder.addCase(actionCreator, reducer)
來處理我們的非同步 thunk 派送的每個動作。
詳細說明:將額外 reducer 加入區塊
extraReducers
中的 builder
物件提供讓我們定義其他案例 reducer 的方法,這些 reducer 會在回應區塊外部定義的動作時執行
builder.addCase(actionCreator, reducer)
:定義一個案例 reducer,根據 RTK 動作建立器或純粹動作類型字串來處理單一已知動作類型builder.addMatcher(matcher, reducer)
:定義一個案例 reducer,可以在matcher
函式傳回true
的任何動作中執行builder.addDefaultCase(reducer)
:定義一個案例 reducer,如果沒有其他案例 reducer 為此動作執行,則會執行此案例 reducer。
您可以將這些串連在一起,例如 builder.addCase().addCase().addMatcher().addDefaultCase()
。如果多個比對器比對動作,它們將按照定義順序執行。
import { increment } from '../features/counter/counterSlice'
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// slice-specific reducers here
},
extraReducers: builder => {
builder
.addCase('counter/decrement', (state, action) => {})
.addCase(increment, (state, action) => {})
}
})
在這種情況下,我們需要偵聽我們的 fetchPosts
thunk 派送的「pending」和「fulfilled」動作類型。這些動作建立器附加到我們的實際 fetchPost
函式,我們可以將它們傳遞給 extraReducers
來偵聽這些動作
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.data
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers(builder) {
builder
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts = state.posts.concat(action.payload)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
}
})
我們將根據我們傳回的 Promise
處理 thunk 可能派送的所有三種類型的動作
- 當請求開始時,我們將
status
列舉設定為'loading'
- 如果請求成功,我們將
status
標記為'succeeded'
,並將擷取的貼文加入state.posts
- 如果請求失敗,我們將
status
標記為'failed'
,並將任何錯誤訊息儲存在狀態中,以便我們可以顯示它
顯示載入狀態
我們的 <PostsList>
元件已經檢查 Redux 中儲存的貼文有任何更新,並在清單變更時重新呈現它。因此,如果我們重新整理頁面,我們應該會在畫面上看到一組來自我們假 API 的隨機貼文
我們使用的假 API 會立即傳回資料。不過,實際的 API 呼叫可能會花一些時間才能傳回回應。一般來說,在 UI 中顯示某種「載入中...」指示器會比較好,這樣使用者就知道我們正在等待資料。
我們可以更新我們的 <PostsList>
,根據 state.posts.status
列舉值顯示不同的 UI 位元:如果正在載入,就顯示一個 spinner;如果失敗,就顯示一個錯誤訊息;如果我們有資料,就顯示實際的貼文清單。趁這個機會,這可能是萃取一個 <PostExcerpt>
元件的好時機,用來封裝清單中一個項目要如何呈現。
結果可能會像這樣
import { Spinner } from '../../components/Spinner'
import { PostAuthor } from './PostAuthor'
import { TimeAgo } from './TimeAgo'
import { ReactionButtons } from './ReactionButtons'
import { selectAllPosts, fetchPosts } from './postsSlice'
const PostExcerpt = ({ post }) => {
return (
<article className="post-excerpt">
<h3>{post.title}</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<ReactionButtons post={post} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
)
}
export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)
const postStatus = useSelector(state => state.posts.status)
const error = useSelector(state => state.posts.error)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
let content
if (postStatus === 'loading') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts
.slice()
.sort((a, b) => b.date.localeCompare(a.date))
content = orderedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))
} else if (postStatus === 'failed') {
content = <div>{error}</div>
}
return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}
你可能會注意到 API 呼叫需要花一段時間才能完成,載入 spinner 會停留在畫面中幾秒鐘。我們的模擬 API 伺服器設定會在所有回應中加入 2 秒的延遲,特別是為了幫助視覺化載入 spinner 可見的時間。如果你想要變更這個行為,你可以開啟 api/server.js
,並變更這行
// Add an extra delay to all endpoints, so loading spinners show up.
const ARTIFICIAL_DELAY_MS = 2000
如果你想要 API 呼叫完成得更快,你可以隨時在進行的過程中開啟或關閉它。
載入使用者
我們現在正在擷取和顯示我們的貼文清單。但是,如果我們查看貼文,會發現有一個問題:它們現在都顯示「未知作者」作為作者
這是因為貼文條目是由假的 API 伺服器隨機產生的,而且每次我們重新載入頁面時,它也會隨機產生一組假的使用者。我們需要更新我們的使用者區塊,在應用程式啟動時擷取那些使用者。
就像上次一樣,我們會建立另一個非同步 thunk,從 API 取得使用者並傳回它們,然後在 extraReducers
區塊欄位中處理 fulfilled
動作。我們現在先不擔心載入狀態
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '../../api/client'
const initialState = []
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.data
})
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload
})
}
})
export default usersSlice.reducer
你可能已經注意到,這次的 case reducer 完全沒有使用 state
變數。相反地,我們直接傳回 action.payload
。Immer 讓我們可以用兩種方式更新狀態:變異現有的狀態值,或傳回一個新的結果。如果我們傳回一個新的值,它會用我們傳回的任何值完全取代現有的狀態。(請注意,如果你想要手動傳回一個新的值,你必須自行撰寫任何可能需要的不可變更新邏輯。)
在這個例子中,初始狀態是一個空陣列,我們可能可以執行 state.push(...action.payload)
來變異它。但是,在我們的例子中,我們真的想要用伺服器傳回的任何值取代使用者的清單,這樣可以避免意外複製狀態中的使用者清單。
如需深入了解 Immer 如何更新狀態,請參閱 RTK 文件中的 「使用 Immer 編寫 Reducer」指南。
我們只需要擷取一次使用者清單,而且我們希望在應用程式啟動時立即執行此動作。我們可以在 index.js
檔案中執行此動作,並直接派送 fetchUsers
thunk,因為我們就在此處擁有 store
// omit other imports
import store from './app/store'
import { fetchUsers } from './features/users/usersSlice'
import { worker } from './api/server'
async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(fetchUsers())
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)
}
main()
現在,每則貼文都應再次顯示使用者名稱,而且我們也應在 <AddPostForm>
中的「作者」下拉式選單中顯示相同的使用者清單。
新增貼文
針對此區段,我們還有最後一個步驟。當我們從 <AddPostForm>
新增一則貼文時,該貼文只會新增至我們應用程式內的 Redux store。我們實際上需要呼叫 API,以在我們的假 API 伺服器中建立新的貼文分錄,如此一來才能「儲存」貼文。(由於這是假 API,如果我們重新載入頁面,新的貼文不會保留,但如果我們有真正的後端伺服器,則在下一次重新載入時,貼文會保留。)
使用 Thunk 傳送資料
我們可以使用 createAsyncThunk
來協助傳送資料,而不仅仅是擷取資料。我們將建立一個 thunk,接受 <AddPostForm>
中的值作為參數,並對假 API 執行 HTTP POST 呼叫以儲存資料。
在此過程中,我們將變更在 Reducer 中處理新貼文物件的方式。目前,我們的 postsSlice
會在 postAdded
的 prepare
回呼中建立新的貼文物件,並為該貼文產生新的唯一 ID。在將資料儲存至伺服器的多數應用程式中,伺服器會負責產生唯一 ID 並填寫任何額外欄位,而且通常會在回應中傳回已完成的資料。因此,我們可以將類似 { title, content, user: userId }
的要求主體傳送至伺服器,然後取得伺服器傳回的完整貼文物件,並將其新增至我們的 postsSlice
狀態。
export const addNewPost = createAsyncThunk(
'posts/addNewPost',
// The payload creator receives the partial `{title, content, user}` object
async initialPost => {
// We send the initial data to the fake API server
const response = await client.post('/fakeApi/posts', initialPost)
// The response includes the complete post object, including unique ID
return response.data
}
)
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// The existing `postAdded` reducer and prepare callback were deleted
reactionAdded(state, action) {}, // omit logic
postUpdated(state, action) {} // omit logic
},
extraReducers(builder) {
// omit posts loading reducers
builder.addCase(addNewPost.fulfilled, (state, action) => {
// We can directly add the new post object to our posts array
state.posts.push(action.payload)
})
}
})
檢查元件中的 Thunk 結果
最後,我們將更新 <AddPostForm>
以發送 addNewPost
thunk,而不是舊的 postAdded
動作。由於這是對伺服器的另一個 API 呼叫,因此需要一些時間,而且可能會失敗。addNewPost()
thunk 會自動將其 pending/fulfilled/rejected
動作發送到 Redux 儲存庫,而我們已經在處理中。如果我們願意,可以使用第二個載入列舉在 postsSlice
中追蹤要求狀態,但對於這個範例,我們將載入狀態追蹤限制在元件中。
如果我們可以在等待要求的同時至少停用「儲存貼文」按鈕,那會很好,這樣使用者就不會意外嘗試儲存貼文兩次。如果要求失敗,我們可能還想在表單中顯示錯誤訊息,或僅將其記錄到主控台中。
我們可以讓元件邏輯等待非同步 thunk 完成,並在完成時檢查結果
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { addNewPost } from './postsSlice'
export const AddPostForm = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')
const [addRequestStatus, setAddRequestStatus] = useState('idle')
// omit useSelectors and change handlers
const canSave =
[title, content, userId].every(Boolean) && addRequestStatus === 'idle'
const onSavePostClicked = async () => {
if (canSave) {
try {
setAddRequestStatus('pending')
await dispatch(addNewPost({ title, content, user: userId })).unwrap()
setTitle('')
setContent('')
setUserId('')
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}
}
// omit rendering logic
}
我們可以新增一個載入狀態列舉欄位作為 React useState
勾子,類似於我們在 postsSlice
中追蹤載入狀態以擷取貼文的方式。在這種情況下,我們只想了解要求是否正在進行中。
當我們呼叫 dispatch(addNewPost())
時,非同步 thunk 會從 dispatch
傳回一個 Promise
。我們可以在這裡 await
那個 promise,以了解 thunk 何時完成其要求。但是,我們還不知道該要求是否成功或失敗。
createAsyncThunk
會在內部處理任何錯誤,這樣我們就不會在記錄中看到任何關於「拒絕的 Promises」的訊息。然後,它會傳回它發送的最後一個動作:如果成功,則為 fulfilled
動作;如果失敗,則為 rejected
動作。
然而,通常會想要撰寫邏輯來查看所提出的實際要求的成功或失敗。Redux Toolkit 會將 .unwrap()
函式新增到傳回的 Promise
,這將傳回一個新的 Promise
,其中包含來自 fulfilled
動作的實際 action.payload
值,或者如果它是 rejected
動作,則擲出錯誤。這讓我們可以使用正常的 try/catch
邏輯在元件中處理成功和失敗。因此,如果貼文成功建立,我們將清除輸入欄位以重設表單,如果失敗,則將錯誤記錄到主控台中。
如果您想了解當 addNewPost
API 呼叫失敗時會發生什麼情況,請嘗試建立一個新貼文,其中「內容」欄位只有一個字「error」(沒有引號)。伺服器會看到並傳回失敗的回應,因此您應該會看到一條訊息記錄到主控台中。
您已學到的內容
非同步邏輯和資料擷取一直都是複雜的主題。如你所見,Redux Toolkit 包含一些工具,可自動執行典型的 Redux 資料擷取模式。
以下是我們從假 API 擷取資料後,應用程式的樣子
提醒一下,以下是我們在本節中涵蓋的內容
- 你可以撰寫可重複使用的「選擇器」函式,以封裝從 Redux 狀態讀取值
- 選擇器是取得 Redux
state
作為引數,並傳回一些資料的函式
- 選擇器是取得 Redux
- Redux 使用稱為「中間件」的外掛程式來啟用非同步邏輯
- 標準的非同步中間件稱為
redux-thunk
,它包含在 Redux Toolkit 中 - Thunk 函式接收
dispatch
和getState
作為引數,並可將它們用於非同步邏輯的一部分
- 標準的非同步中間件稱為
- 你可以發送其他動作,以協助追蹤 API 呼叫的載入狀態
- 典型的模式是在呼叫之前發送「待處理」動作,然後發送包含資料的「成功」動作或包含錯誤的「失敗」動作
- 載入狀態通常應儲存為列舉,例如
'idle' | 'loading' | 'succeeded' | 'failed'
- Redux Toolkit 有
createAsyncThunk
API,可為你發送這些動作createAsyncThunk
接受「有效負載建立器」回呼,該回呼應傳回Promise
,並自動產生pending/fulfilled/rejected
動作類型- 產生的動作建立器(例如
fetchPosts
)會根據你傳回的Promise
發送那些動作 - 你可以在
createSlice
中使用extraReducers
欄位來偵聽這些動作類型,並根據這些動作在還原器中更新狀態。 - 動作建立器可用於自動填入
extraReducers
物件的鍵,以便切片知道要偵聽哪些動作。 - Thunk 可以傳回承諾。特別是對於
createAsyncThunk
,你可以await dispatch(someThunk()).unwrap()
來處理元件層級的請求成功或失敗。
下一步?
我們還有另一組主題來涵蓋 Redux Toolkit API 和使用模式的核心。在 第 6 部分:效能和正規化資料 中,我們將探討 Redux 使用如何影響 React 效能,以及一些我們可以優化應用程式以提升效能的方法。