跳到主要內容

Redux 精華,第 8 部分:RTK Query 進階模式

您將會學到
  • 如何使用帶有 ID 的標籤來管理快取失效和重新擷取
  • 如何在 React 外部使用 RTK Query 快取
  • 處理回應資料的技巧
  • 實作樂觀更新和串流更新
先備條件

簡介

第 7 部分:RTK Query 基礎中,我們了解如何設定和使用 RTK Query API 來處理應用程式中的資料擷取和快取。我們在 Redux 儲存區中新增了「API 區段」,定義了「查詢」端點來擷取文章資料,以及「異動」端點來新增新文章。

在本節中,我們將繼續將範例應用程式遷移到使用 RTK Query 來處理其他資料類型,並了解如何使用一些進階功能來簡化程式碼庫並改善使用者體驗。

資訊

本節中的一些變更並非絕對必要,而是為了展示 RTK Query 的功能,並展示您可以執行的部分操作,以便在需要時了解如何使用這些功能。

編輯文章

我們已經新增一個異動端點來將新的文章項目儲存到伺服器,並在 <AddPostForm> 中使用該端點。接下來,我們需要處理更新 <EditPostForm> 以讓我們編輯現有文章。

更新「編輯文章」表單

與新增文章一樣,第一步是在 API 區段中定義一個新的異動端點。這將非常類似於新增文章的異動,但端點需要在 URL 中包含文章 ID,並使用 HTTP PATCH 要求來表示它正在更新部分欄位。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post']
}),
getPost: builder.query({
query: postId => `/posts/${postId}`
}),
addNewPost: builder.mutation({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
}),
editPost: builder.mutation({
query: post => ({
url: `/posts/${post.id}`,
method: 'PATCH',
body: post
})
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice

新增後,我們可以更新 <EditPostForm>。它需要從儲存區中讀取原始的 Post 項目,使用它來初始化元件狀態以編輯欄位,然後將更新的變更傳送至伺服器。目前,我們使用 selectPostById 讀取 Post 項目,並手動發送 postUpdated thunk 以進行要求。

我們可以使用與在 <SinglePostPage> 中使用的 useGetPostQuery 掛勾相同的掛勾從儲存區中的快取中讀取 Post 項目,我們將使用新的 useEditPostMutation 掛勾來處理儲存變更。

features/posts/EditPostForm.js
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'

import { Spinner } from '../../components/Spinner'
import { useGetPostQuery, useEditPostMutation } from '../api/apiSlice'

export const EditPostForm = ({ match }) => {
const { postId } = match.params

const { data: post } = useGetPostQuery(postId)
const [updatePost, { isLoading }] = useEditPostMutation()

const [title, setTitle] = useState(post.title)
const [content, setContent] = useState(post.content)

const history = useHistory()

const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)

const onSavePostClicked = async () => {
if (title && content) {
await updatePost({ id: postId, title, content })
history.push(`/posts/${postId}`)
}
}

// omit rendering logic
}

快取資料訂閱生命週期

讓我們試試看會發生什麼事。開啟瀏覽器的 DevTools,前往「網路」索引標籤,然後重新整理主頁面。當我們擷取初始資料時,您應該會看到傳送至 /postsGET 要求。當您按一下「檢視文章」按鈕時,您應該會看到傳回該篇文章項目的第二次傳送至 /posts/:postId 的要求。

現在在單一文章頁面內按一下「編輯文章」。使用者介面會切換為顯示 <EditPostForm>,但這次不會對個別文章發出網路要求。為什麼?

RTK Query network requests

RTK Query 允許多個元件訂閱相同的資料,並會確保每個獨特的資料集合只會擷取一次。在內部,RTK Query 會保留每個端點 + 快取金鑰組合的活動「訂閱」參考計數器。如果元件 A 呼叫 useGetPostQuery(42),就會擷取該資料。如果元件 B 接著掛載並也呼叫 useGetPostQuery(42),則會要求完全相同的資料。這兩個掛鉤用法會傳回完全相同的結果,包括已擷取的 資料和載入狀態旗標。

當活動訂閱的數量降至 0 時,RTK Query 會啟動內部計時器。如果計時器在為資料新增任何新訂閱之前到期,RTK Query 會自動從快取中移除該資料,因為應用程式不再需要該資料。但是,如果在計時器到期之前新增新的訂閱,則會取消計時器,並使用已快取的資料,而不需要重新擷取。

在這種情況下,我們的 <SinglePostPage> 已掛載並透過 ID 要求個別 文章。當我們按一下「編輯文章」時,<SinglePostPage> 元件會因路由而取消掛載,而活動訂閱也會因取消掛載而移除。RTK Query 會立即啟動「移除此文章資料」計時器。但是,<EditPostPage> 元件會立即掛載並訂閱具有相同快取金鑰的相同 文章 資料。因此,RTK Query 會取消計時器,並繼續使用相同的快取資料,而不是從伺服器擷取。

預設情況下,未使用的資料會在 60 秒後從快取中移除,但這可以在根 API 片段定義中設定,或使用 keepUnusedDataFor 旗標在個別端點定義中覆寫,該旗標會指定快取生命週期(以秒為單位)。

使特定項目失效

我們的 <EditPostForm> 元件現在可以將已編輯的文章儲存到伺服器,但我們有一個問題。如果我們在編輯時按一下「儲存文章」,它會將我們傳回 <SinglePostPage>,但它仍然顯示舊資料,沒有編輯內容。<SinglePostPage> 仍然使用稍早擷取的快取 文章 項目。就這一點而言,如果我們回到主頁並查看 <PostsList>,它也會顯示舊資料。我們需要一種方法來強制重新擷取個別 文章 項目和所有文章清單

稍早,我們看過如何使用「標籤」使快取資料的部分失效。我們宣告 getPosts 查詢端點提供 '文章' 標籤,而 addNewPost 變更端點使相同的 '文章' 標籤失效。這樣,每次新增一篇文章時,我們就會強制 RTK Query 從 getQuery 端點重新擷取所有文章清單。

我們可以為 `getPost` 查詢和 `editPost` 變異新增一個 'Post' 標籤,但這樣會強制所有其他個別文章也重新擷取。幸運的是,RTK Query 讓我們定義特定標籤,讓我們在使資料失效時更具選擇性。這些特定標籤看起來像 `{type: 'Post', id: 123}`。

我們的 `getPosts` 查詢定義了一個 `providesTags` 欄位,它是一個字串陣列。`providesTags` 欄位也可以接受一個回呼函式,接收 `result` 和 `arg`,並傳回一個陣列。這讓我們可以根據正在擷取的資料的 ID 建立標籤條目。類似地,`invalidatesTags` 也可以是一個回呼函式。

為了獲得正確的行為,我們需要使用正確的標籤設定每個端點

  • getPosts:為整個清單提供一個通用的 `'Post' 標籤,以及為每個接收到的文章物件提供一個特定的 `{type: 'Post', id}` 標籤
  • getPost:為個別文章物件提供一個特定的 `{type: 'Post', id}` 物件
  • addNewPost:使通用的 `'Post' 標籤失效,以重新擷取整個清單
  • editPost:使特定的 `{type: 'Post', id}` 標籤失效。這將強制重新擷取來自 `getPost` 的個別文章,以及來自 `getPosts` 的整個文章清單,因為它們都提供了與該 `{type, id}` 值相符的標籤。
features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: (result = [], error, arg) => [
'Post',
...result.map(({ id }) => ({ type: 'Post', id }))
]
}),
getPost: builder.query({
query: postId => `/posts/${postId}`,
providesTags: (result, error, arg) => [{ type: 'Post', id: arg }]
}),
addNewPost: builder.mutation({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
}),
editPost: builder.mutation({
query: post => ({
url: `posts/${post.id}`,
method: 'PATCH',
body: post
}),
invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.id }]
})
})
})

如果回應沒有資料或有錯誤,這些回呼函式中的 `result` 引數可能會是未定義的,所以我們必須安全地處理它。對於 `getPosts`,我們可以使用預設引數陣列值來對應,而對於 `getPost`,我們已經根據引數 ID 傳回一個單一項目陣列。對於 `editPost`,我們從傳遞到觸發函式的部分文章物件中知道文章的 ID,所以我們可以從那裡讀取它。

有了這些變更,讓我們回去並再次嘗試編輯文章,並在瀏覽器 DevTools 中開啟網路標籤。

RTK Query invalidation and refetching

當我們這次儲存已編輯的文章時,我們應該會看到兩個請求連續發生

  • 來自 `editPost` 變異的 `PATCH /posts/:postId`
  • getPost 查詢作為 GET /posts/:postId 重新擷取時

然後,如果我們按一下返回到「文章」主要標籤,我們也應該會看到

  • getPosts 查詢作為 GET /posts 重新擷取時

因為我們使用標籤提供了端點之間的關聯,RTK Query 知道當我們進行編輯,且具有該 ID 的特定標籤已失效時,它需要重新擷取個別文章和文章清單 - 無需進一步變更!同時,當我們編輯文章時,getPosts 資料的快取移除計時器已過期,因此它已從快取中移除。當我們再次開啟 <PostsList> 元件時,RTK Query 發現快取中沒有資料,並重新擷取它。

這裡有一個注意事項。透過在 getPosts 中指定一個純文字 'Post' 標籤,並在 addNewPost 中使它失效,我們實際上會強制重新擷取所有個別文章。如果我們真的只想重新擷取 getPost 端點的文章清單,你可以包含一個具有任意 ID 的額外標籤,例如 {type: 'Post', id: 'LIST'},並使該標籤失效。RTK Query 文件有一個表格,說明如果某些一般/特定標籤組合失效,會發生什麼事

資訊

RTK Query 有許多其他選項,用於控制何時以及如何重新擷取資料,包括「條件式擷取」、「延遲查詢」和「預先擷取」,而且查詢定義可以透過各種方式自訂。請參閱 RTK Query 使用指南文件,以取得有關使用這些功能的更多詳細資訊

管理使用者資料

我們已經完成將文章資料管理轉換為使用 RTK Query。接下來,我們將轉換使用者清單。

由於我們已經看過如何使用 RTK Query 勾子擷取和讀取資料,因此在本節中,我們將嘗試不同的方法。RTK Query 的核心 API 與 UI 無關,可以用於任何 UI 層,不只 React。通常你應該堅持使用勾子,但這裡我們將使用 RTK Query 核心 API 來處理使用者資料,以便你可以看到如何使用它。

手動擷取使用者

我們目前在 usersSlice.js 中定義一個 fetchUsers 非同步 thunk,並在 index.js 中手動傳送該 thunk,以便使用者清單可以盡快使用。我們可以使用 RTK Query 執行相同的程序。

我們將從在 apiSlice.js 中定義一個 getUsers 查詢端點開始,類似於我們現有的端點。我們將匯出 useGetUsersQuery 勾子,只是為了保持一致性,但目前我們不會使用它。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints

getUsers: builder.query({
query: () => '/users'
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useGetUsersQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice

如果我們檢查 API 片段物件,它包含一個 endpoints 欄位,其中包含一個端點物件,表示我們定義的每個端點。

API slice endpoint contents

每個端點物件包含

  • 與我們從根 API 片段物件匯出的相同的基礎查詢/變異掛勾,但命名為 useQueryuseMutation
  • 對於查詢端點,額外提供一組查詢掛勾,適用於「延遲查詢」或部分訂閱等場景
  • 一組 「比對器」公用程式,用於檢查此端點請求所發出的 pending/fulfilled/rejected 動作
  • 一個 initiate thunk,用於觸發此端點的請求
  • 一個 select 函式,用於建立 記憶化選取器,可以擷取此端點的快取結果資料 + 狀態項目

如果我們想要在 React 之外擷取使用者清單,我們可以在 index 檔案中發送 getUsers.initiate() thunk

index.js
// omit other imports
import { apiSlice } from './features/api/apiSlice'

async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })

store.dispatch(apiSlice.endpoints.getUsers.initiate())

ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)
}
main()

此發送會在查詢掛勾內自動執行,但我們可以在需要時手動啟動它。

注意

手動發送 RTKQ 請求 thunk 會建立訂閱項目,但之後由你決定 稍後取消訂閱該資料 - 否則資料會永久保留在快取中。在本例中,我們總是需要使用者資料,因此可以跳過取消訂閱。

選取使用者資料

我們目前有由我們的 createEntityAdapter 使用者介面產生的選擇器,例如 selectAllUsersselectUserById,並從 state.users 讀取。如果我們重新載入頁面,所有與使用者相關的顯示都會中斷,因為 state.users 片段沒有資料。現在我們正在擷取 RTK Query 快取的資料,我們應該用從快取中讀取的等效選擇器取代這些選擇器。

API 片段端點中的 endpoint.select() 函式會在我們每次呼叫時建立一個新的備忘選擇器函式。select() 將快取金鑰作為其引數,而這必須與你傳遞給查詢掛勾或 initiate() thunk 的快取金鑰相同。產生的選擇器使用該快取金鑰來確切知道它應該從儲存體中的快取狀態傳回哪個快取結果。

在這種情況下,我們的 getUsers 端點不需要任何參數 - 我們總是擷取完整的使用者清單。因此,我們可以建立沒有引數的快取選擇器,而快取金鑰會變成 undefined

features/users/usersSlice.js
import {
createSlice,
createEntityAdapter,
createSelector
} from '@reduxjs/toolkit'

import { apiSlice } from '../api/apiSlice'

/* Temporarily ignore adapter - we'll use this again shortly
const usersAdapter = createEntityAdapter()

const initialState = usersAdapter.getInitialState()
*/

// Calling `someEndpoint.select(someArg)` generates a new selector that will return
// the query result object for a query with those parameters.
// To generate a selector for a specific query argument, call `select(theQueryArg)`.
// In this case, the users query has no params, so we don't pass anything to select()
export const selectUsersResult = apiSlice.endpoints.getUsers.select()

const emptyUsers = []

export const selectAllUsers = createSelector(
selectUsersResult,
usersResult => usersResult?.data ?? emptyUsers
)

export const selectUserById = createSelector(
selectAllUsers,
(state, userId) => userId,
(users, userId) => users.find(user => user.id === userId)
)

/* Temporarily ignore selectors - we'll come back to this later
export const {
selectAll: selectAllUsers,
selectById: selectUserById,
} = usersAdapter.getSelectors((state) => state.users)
*/

一旦我們有了那個初始的 selectUsersResult 選擇器,我們就可以用一個從快取結果傳回使用者陣列的選擇器取代現有的 selectAllUsers 選擇器,然後用一個從該陣列中找到正確使用者的選擇器取代 selectUserById

現在我們將從 usersAdapter 註解掉那些選擇器 - 我們稍後會進行另一項變更,改回使用那些選擇器。

我們的元件已經匯入了 selectAllUsersselectUserById,所以這個變更應該可以正常運作!試著重新整理頁面,並按一下文章清單和單篇文章檢視。正確的使用者名稱應該會顯示在每個顯示的文章中,以及 <AddPostForm> 的下拉式選單中。

由於 usersSlice 甚至完全沒有被使用,我們可以繼續從這個檔案中刪除 createSlice 呼叫,並從我們的儲存體設定中移除 users: usersReducer。我們仍然有一些程式碼片段會參照 postsSlice,所以我們還不能移除它 - 我們很快就會處理它。

注入端點

較大的應用程式通常會將功能「程式碼分割」成不同的套件,然後在首次使用該功能時「延遲載入」。我們曾說過,RTK Query 通常每個應用程式只有一個「API 片段」,到目前為止,我們已在 apiSlice.js 中直接定義所有端點。如果我們想要對部分端點定義進行程式碼分割,或將它們移至其他檔案,以避免 API 片段檔案過大,該怎麼辦?

RTK Query 支援使用 apiSlice.injectEndpoints() 分割端點定義。這樣一來,我們仍然可以只有一個 API 片段,只有一個中間軟體和快取簡約器,但可以將部分端點的定義移至其他檔案。這允許進行程式碼分割,以及在需要時將部分端點與功能資料夾並置。

為了說明這個流程,我們將 getUsers 端點切換到 usersSlice.js 中注入,而不是在 apiSlice.js 中定義。

我們已將 apiSlice 匯入 usersSlice.js,以便存取 getUsers 端點,因此我們可以改為在此呼叫 apiSlice.injectEndpoints()

features/users/usersSlice.js
import { apiSlice } from '../api/apiSlice'

export const extendedApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query({
query: () => '/users'
})
})
})

export const { useGetUsersQuery } = extendedApiSlice

export const selectUsersResult = extendedApiSlice.endpoints.getUsers.select()

injectEndpoints() 會變更原始 API 片段物件,以新增其他端點定義,然後再傳回。我們最初新增到儲存的實際快取簡約器和中間軟體仍然正常運作。此時,apiSliceextendedApiSlice 是同一個物件,但在此將 extendedApiSlice 物件視為 apiSlice 的提醒會很有幫助。(如果您使用 TypeScript,這一點更為重要,因為只有 extendedApiSlice 值有新增端點的類型。)

目前,唯一參照 getUsers 端點的檔案是我們的索引檔案,它正在發送 initiate thunk。我們需要更新它,改為匯入已擴充的 API 片段

index.js
  // omit other imports
- import { apiSlice } from './features/api/apiSlice'
+ import { extendedApiSlice } from './features/users/usersSlice'


async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })


- store.dispatch(apiSlice.endpoints.getUsers.initiate())
+ store.dispatch(extendedApiSlice.endpoints.getUsers.initiate())


ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)
}
main()

或者,您也可以僅從片段檔案匯出特定端點本身。

處理回應資料

到目前為止,我們所有的查詢端點都只是將來自伺服器的回應資料儲存在主體中,就像在主體中接收到的資料一樣。getPostsgetUsers 都預期伺服器會傳回陣列,而 getPost 則預期個別 Post 物件為主體。

客戶端通常需要從伺服器回應中擷取資料片段,或在快取資料之前以某種方式轉換資料。例如,如果 /getPost 要求傳回類似 {post: {id}} 的主體,資料會巢狀嗎?

我們可以在概念上處理這個問題的方法有幾種。一種選擇是擷取 responseData.post 欄位,並將其儲存在快取中,而不是整個主體。另一種方法是將整個回應資料儲存在快取中,但讓我們的元件僅指定他們需要的快取資料的特定片段。

轉換回應

端點可以定義一個 transformResponse 處理常式,它可以在快取資料前,萃取或修改從伺服器接收到的資料。對於 getPost 範例,我們可以有 transformResponse: (responseData) => responseData.post,它只會快取實際的 Post 物件,而不是回應的整個主體。

第 6 部分:效能和正規化 中,我們討論了將資料儲存在正規化結構中是有用的原因。特別是,它讓我們可以根據 ID 查詢和更新項目,而不是必須迴圈陣列來尋找正確的項目。

我們的 selectUserById 選擇器目前必須迴圈快取的使用者陣列,才能找到正確的 User 物件。如果我們要轉換回應資料,以使用正規化方法儲存,我們可以簡化為直接透過 ID 找到使用者。

我們之前在 usersSlice 中使用 createEntityAdapter 來管理正規化的使用者資料。我們可以將 createEntityAdapter 整合到我們的 extendedApiSlice 中,並實際上使用 createEntityAdapter 在快取資料之前轉換資料。我們將取消註解我們原本有的 usersAdapter 行,並再次使用它的更新函式和選擇器。

features/users/usersSlice.js
import { apiSlice } from '../api/apiSlice'

const usersAdapter = createEntityAdapter()

const initialState = usersAdapter.getInitialState()

export const extendedApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query({
query: () => '/users',
transformResponse: responseData => {
return usersAdapter.setAll(initialState, responseData)
}
})
})
})

export const { useGetUsersQuery } = extendedApiSlice

// Calling `someEndpoint.select(someArg)` generates a new selector that will return
// the query result object for a query with those parameters.
// To generate a selector for a specific query argument, call `select(theQueryArg)`.
// In this case, the users query has no params, so we don't pass anything to select()
export const selectUsersResult = extendedApiSlice.endpoints.getUsers.select()

const selectUsersData = createSelector(
selectUsersResult,
usersResult => usersResult.data
)

export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors(state => selectUsersData(state) ?? initialState)

我們已將 transformResponse 選項新增到 getUsers 端點。它接收整個回應資料主體作為其引數,並應傳回要快取的實際資料。透過呼叫 usersAdapter.setAll(initialState, responseData),它將傳回包含所有接收項目的標準 {ids: [], entities: {}} 正規化資料結構。

adapter.getSelectors() 函式需要提供一個「輸入選擇器」,以便它知道在哪裡找到正規化資料。在這種情況下,資料嵌套在 RTK Query 快取簡約器內,因此我們從快取狀態中選擇正確的欄位。

正規化快取與文件快取

值得花點時間回顧我們剛剛進一步執行的操作。

你可能聽過「正規化快取」這個術語,它與其他資料擷取函式庫(例如 Apollo)有關。重要的是要了解RTK Query 使用「文件快取」方法,而不是「正規化快取」

完全正規化的快取會根據項目類型和 ID,嘗試在所有查詢中重複類似項目。例如,假設我們有一個包含 getTodosgetTodo 端點的 API 切片,而我們的元件會執行下列查詢

  • getTodos()
  • getTodos({filter: 'odd'})
  • getTodo({id: 1})

這些查詢結果中的每個都會包含一個看起來像 {id: 1} 的 Todo 物件。

在完全正規化的重複刪除快取中,只會儲存這個 Todo 物件的單一副本。但是,RTK Query 會獨立將每個查詢結果儲存在快取中。因此,這將導致這個 Todo 的三個獨立副本快取在 Redux 儲存區中。不過,如果所有端點持續提供相同的標籤(例如 {type: 'Todo', id: 1}),那麼使該標籤失效將強制所有配對端點重新擷取其資料以保持一致性。

RTK Query 故意實作會在多個要求中重複相同項目的快取。有幾個原因

  • 跨查詢完全正規化的共用快取是一個困難的問題
  • 我們現在沒有時間、資源或興趣嘗試解決這個問題
  • 在許多情況下,當資料失效時重新擷取資料效果很好且更容易理解
  • 至少,RTKQ 可以協助解決「擷取一些資料」的一般使用案例,這對許多人來說是一個很大的痛點

相較之下,我們剛剛正規化了 getUsers 端點的回應資料,因為它被儲存為 {[id]: value} 查詢表。但是,不等於「正規化快取」 - 我們只轉換這個回應的儲存方式,而不是重複刪除端點或要求中的結果。

從結果中選取值

從舊的 postsSlice 讀取的最後一個元件是 <UserPage>,它會根據目前的使用者篩選文章清單。我們已經看到,我們可以使用 useGetPostsQuery() 取得文章的完整清單,然後在元件中轉換它,例如在 useMemo 內部排序。查詢掛鉤也讓我們能夠透過提供 selectFromResult 選項來選取快取狀態的片段,而且只在選取的片段變更時重新呈現。

我們可以使用 selectFromResult<UserPage> 從快取中讀取已過濾的貼文清單。然而,為了讓 selectFromResult 避免不必要的重新渲染,我們需要確保所萃取的資料正確地備忘。為此,我們應該建立一個新的選擇器實例,讓 <UsersPage> 元件每次渲染時都能重複使用,這樣選擇器就能根據其輸入備忘結果。

features/users/UsersPage.js
import { createSelector } from '@reduxjs/toolkit'

import { selectUserById } from '../users/usersSlice'
import { useGetPostsQuery } from '../api/apiSlice'

export const UserPage = ({ match }) => {
const { userId } = match.params

const user = useSelector(state => selectUserById(state, userId))

const selectPostsForUser = useMemo(() => {
const emptyArray = []
// Return a unique selector instance for this page so that
// the filtered results are correctly memoized
return createSelector(
res => res.data,
(res, userId) => userId,
(data, userId) => data?.filter(post => post.user === userId) ?? emptyArray
)
}, [])

// Use the same posts query, but extract only part of its data
const { postsForUser } = useGetPostsQuery(undefined, {
selectFromResult: result => ({
// We can optionally include the other metadata fields from the result here
...result,
// Include a field called `postsForUser` in the hook result object,
// which will be a filtered list of posts
postsForUser: selectPostsForUser(result, userId)
})
})

// omit rendering logic
}

我們在此建立的備忘選擇器函式有一個關鍵差異。通常,選擇器會將整個 Redux state 當作第一個引數,並從 state 萃取或衍生一個值。然而,在這個案例中,我們只處理快取中保留的「結果」值。結果物件內有一個 data 欄位,其中包含我們需要的實際值,以及一些要求的元資料欄位。

我們的 selectFromResult 回呼函式會接收包含原始要求元資料和伺服器 dataresult 物件,並應該傳回一些萃取或衍生的值。由於查詢掛勾會將一個額外的 refetch 方法新增到在此傳回的任何內容,因此最好總是從 selectFromResult 傳回一個物件,其中包含您需要的欄位。

由於 result 保存在 Redux 儲存區中,我們無法變更它 - 我們需要傳回一個新物件。查詢掛勾會對這個傳回的物件執行「淺層」比較,並且僅在其中一個欄位變更時重新渲染元件。我們可以透過僅傳回此元件需要的特定欄位來最佳化重新渲染 - 如果我們不需要其他元資料旗標,我們可以完全省略它們。如果您需要它們,您可以散佈原始 result 值以將它們包含在輸出中。

在這個案例中,我們會將欄位稱為 postsForUser,我們可以從掛勾結果中解構那個新欄位。每次呼叫 selectPostsForUser(result, userId) 時,它會備忘已過濾的陣列,並且僅在擷取的資料或使用者 ID 變更時重新計算它。

比較轉換方法

我們現在已經看到三種不同的方式來管理轉換回應

  • 將原始回應保留在快取中,在元件中讀取完整結果並衍生值
  • 將原始回應保留在快取中,使用 selectFromResult 讀取衍生結果
  • 在儲存在快取中之前轉換回應

這些方法中的每一個在不同的情況下都可能很有用。以下是一些您應該考慮使用它們的建議

  • transformResponse:端點的所有使用者都想要一個特定格式,例如標準化回應以啟用透過 ID 更快速地查詢
  • selectFromResult:端點的某些使用者只需要部分資料,例如經過篩選的清單
  • 每個元件 / useMemo:當只有某些特定元件需要轉換快取資料時

進階快取更新

我們已完成更新文章和使用者資料,因此剩下的就是處理反應和通知。將它們切換為使用 RTK Query 會讓我們有機會嘗試一些進階技術,用於處理 RTK Query 的快取資料,並讓我們能夠為使用者提供更好的體驗。

持續反應

最初,我們只追蹤用戶端上的反應,而不會將它們持續到伺服器。讓我們新增一個 addReaction 變異,並使用它來更新伺服器上的對應 Post,每次使用者按一下反應按鈕時都會更新。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints
addReaction: builder.mutation({
query: ({ postId, reaction }) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
// In a real app, we'd probably need to base this on user ID somehow
// so that a user can't do the same reaction more than once
body: { reaction }
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Post', id: arg.postId }
]
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation,
useAddReactionMutation
} = apiSlice

與我們的其他變異類似,我們會取得一些參數並向伺服器提出請求,請求主體中會包含一些資料。由於這個範例應用程式很小,我們只會提供反應的名稱,並讓伺服器增加這個貼文上該反應類型的計數器。

我們已經知道需要重新擷取這個貼文,才能在用戶端上看到任何資料變更,因此我們可以根據其 ID 來使這個特定的 Post 條目失效。

有了這個,讓我們更新 <ReactionButtons> 以使用這個變異。

features/posts/ReactionButtons.js
import React from 'react'

import { useAddReactionMutation } from '../api/apiSlice'

const reactionEmoji = {
thumbsUp: '👍',
hooray: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}

export const ReactionButtons = ({ post }) => {
const [addReaction] = useAddReactionMutation()

const reactionButtons = Object.entries(reactionEmoji).map(
([reactionName, emoji]) => {
return (
<button
key={reactionName}
type="button"
className="muted-button reaction-button"
onClick={() => {
addReaction({ postId: post.id, reaction: reactionName })
}}
>
{emoji} {post.reactions[reactionName]}
</button>
)
}
)

return <div>{reactionButtons}</div>
}

讓我們看看實際運作狀況!前往主要的 <PostsList>,並按一下其中一個反應,看看會發生什麼事。

PostsList disabled while fetching

糟糕。整個 <PostsList> 元件都變灰了,因為我們剛剛重新擷取了 整個 貼文清單,以回應那個已更新的貼文。這是故意讓它更明顯,因為我們的模擬 API 伺服器設定為在回應之前有 2 秒鐘的延遲,但即使回應更快,這仍然不是好的使用者體驗。

實作樂觀更新

對於新增反應這類的小更新,我們可能不需要重新擷取整個貼文清單。相反地,我們可以嘗試只更新用戶端上已快取的資料,以符合我們預期在伺服器上發生的情況。此外,如果我們立即更新快取,使用者在按一下按鈕時會立即收到回饋,而不用等到回應傳回。這種立即更新用戶端狀態的方法稱為「樂觀更新」,這是網路應用程式中常見的模式。

RTK Query 讓你可以透過修改基於「要求生命週期」處理常式的用戶端快取來實作樂觀更新。端點可以定義一個 onQueryStarted 函式,這個函式會在要求開始時呼叫,我們可以在該處理常式中執行其他邏輯。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints

addReaction: builder.mutation({
query: ({ postId, reaction }) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
// In a real app, we'd probably need to base this on user ID somehow
// so that a user can't do the same reaction more than once
body: { reaction }
}),
async onQueryStarted({ postId, reaction }, { dispatch, queryFulfilled }) {
// `updateQueryData` requires the endpoint name and cache key arguments,
// so it knows which piece of cache state to update
const patchResult = dispatch(
apiSlice.util.updateQueryData('getPosts', undefined, draft => {
// The `draft` is Immer-wrapped and can be "mutated" like in createSlice
const post = draft.find(post => post.id === postId)
if (post) {
post.reactions[reaction]++
}
})
)
try {
await queryFulfilled
} catch {
patchResult.undo()
}
}
})
})
})

onQueryStarted 處理常式會收到兩個參數。第一個是要求開始時傳遞的快取金鑰 arg。第二個是一個物件,其中包含一些與 createAsyncThunkthunkApi 相同的欄位({dispatch, getState, extra, requestId}),但還有一個稱為 queryFulfilledPromise。這個 Promise 會在要求傳回時解析,並根據要求來達成或拒絕。

API 切片物件包含一個 updateQueryData 工具函式,讓我們可以更新快取值。它需要三個參數:要更新的端點名稱、用於識別特定快取資料的快取金鑰值,以及更新快取資料的回呼。updateQueryData 使用 Immer,因此你可以像在 createSlice 中一樣「變異」已草擬的快取資料

我們可以透過在 getPosts 快取中找到特定的 Post 條目,並「變異」它來增加反應計數器,來實作樂觀更新。

updateQueryData 會產生一個動作物件,其中包含我們所做的變更的修補程式差異。當我們傳送該動作時,傳回值是一個 patchResult 物件。如果我們呼叫 patchResult.undo(),它會自動傳送一個動作,來反轉修補程式差異變更。

預設情況下,我們預期要求會成功。如果要求失敗,我們可以 await queryFulfilled,捕捉失敗,並取消修補程式變更來還原樂觀更新。

對於這個案例,我們也移除了我們剛剛加入的 invalidatesTags 行,因為我們希望在我們按一下反應按鈕時重新擷取文章。

現在,如果我們快速按一下反應按鈕好幾次,我們應該會看到 UI 中的數字每次都會增加。如果我們查看「網路」標籤,我們也會看到每個個別要求都會傳送至伺服器。

串流快取更新

我們的最後一個功能是通知標籤。當我們最初在 第 6 部分 中建置這個功能時,我們說「在一個真實的應用程式中,伺服器會在每次發生事情時將更新推送到我們的用戶端」。我們最初透過加入一個「重新整理通知」按鈕來偽造該功能,並讓它發出 HTTP GET 要求以取得更多通知條目。

應用程式通常會發出一個初始要求來從伺服器擷取資料,然後開啟一個 WebSocket 連線以接收其他更新。RTK Query 提供一個 onCacheEntryAdded 端點生命週期處理常式,讓我們可以實作對快取資料的「串流更新」。我們將使用該功能來實作一個更實際的方法來管理通知。

我們的 src/api/server.js 檔案已設定好一個模擬的 Websocket 伺服器,類似於模擬的 HTTP 伺服器。我們將撰寫一個新的 getNotifications 端點,用於擷取初始的通知清單,然後建立 Websocket 連線以偵聽未來的更新。我們仍然需要手動告訴模擬伺服器何時發送新的通知,因此我們將繼續假裝這樣做,並有一個按鈕可以按一下來強制更新。

我們將在 notificationsSlice 中注入 getNotifications 端點,就像我們使用 getUsers 所做的那樣,只是為了展示這是可行的。

features/notifications/notificationsSlice.js
import { forceGenerateNotifications } from '../../api/server'
import { apiSlice } from '../api/apiSlice'

export const extendedApi = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query({
query: () => '/notifications',
async onCacheEntryAdded(
arg,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved }
) {
// create a websocket connection when the cache subscription starts
const ws = new WebSocket('ws://127.0.0.1')
try {
// wait for the initial query to resolve before proceeding
await cacheDataLoaded

// when data is received from the socket connection to the server,
// update our query result with the received message
const listener = event => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'notifications': {
updateCachedData(draft => {
// Insert all received notifications from the websocket
// into the existing RTKQ cache array
draft.push(...message.payload)
draft.sort((a, b) => b.date.localeCompare(a.date))
})
break
}
default:
break
}
}

ws.addEventListener('message', listener)
} catch {
// no-op in case `cacheEntryRemoved` resolves before `cacheDataLoaded`,
// in which case `cacheDataLoaded` will throw
}
// cacheEntryRemoved will resolve when the cache subscription is no longer active
await cacheEntryRemoved
// perform cleanup steps once the `cacheEntryRemoved` promise resolves
ws.close()
}
})
})
})

export const { useGetNotificationsQuery } = extendedApi

const emptyNotifications = []

export const selectNotificationsResult =
extendedApi.endpoints.getNotifications.select()

const selectNotificationsData = createSelector(
selectNotificationsResult,
notificationsResult => notificationsResult.data ?? emptyNotifications
)

export const fetchNotificationsWebsocket = () => (dispatch, getState) => {
const allNotifications = selectNotificationsData(getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification?.date ?? ''
// Hardcode a call to the mock server to simulate a server push scenario over websockets
forceGenerateNotifications(latestTimestamp)
}

// omit existing slice code

onQueryStarted 一樣,onCacheEntryAdded 生命週期處理常式將 arg 快取金鑰作為其第一個參數,並將包含 thunkApi 值的選項物件作為第二個參數。選項物件還包含一個 updateCachedData 實用函式,以及兩個生命週期 Promise - cacheDataLoadedcacheEntryRemovedcacheDataLoaded 在將此訂閱的初始資料新增到儲存區時解析。這會在新增此端點 + 快取金鑰的第一個訂閱時發生。只要資料的訂閱者仍有 1 個以上處於活動狀態,快取項目就會保持活動狀態。當訂閱者的數量變為 0 且快取生命週期計時器到期時,快取項目將被移除,並且 cacheEntryRemoved 將解析。通常,使用模式為

  • 立即await cacheDataLoaded
  • 建立伺服器端資料訂閱,例如 Websocket
  • 收到更新時,使用 updateCachedData 根據更新「變異」快取值
  • 最後await cacheEntryRemoved
  • 之後清除訂閱

我們的模擬 Websocket 伺服器檔案公開了一個 forceGenerateNotifications 方法,用於模擬將資料推送到用戶端。這取決於知道最近的通知時間戳記,因此我們新增一個 thunk,我們可以派送它來從快取狀態中讀取最新時間戳記,並告訴模擬伺服器產生更新的通知。

onCacheEntryAdded 內部,我們建立一個到 localhost 的真實 Websocket 連線。在真實的應用程式中,這可以是您需要接收持續更新的任何類型的外部訂閱或輪詢連線。每當模擬伺服器向我們發送更新時,我們會將所有收到的通知推送到快取中並重新排序它。

當快取項目被移除時,我們會清除 Websocket 訂閱。在此應用程式中,通知快取項目永遠不會被移除,因為我們永遠不會取消訂閱資料,但了解清除作業在真實應用程式中的運作方式非常重要。

追蹤用戶端狀態

我們需要進行最後一組更新。我們的 <Navbar> 元件必須啟動通知的擷取,而 <NotificationsList> 需要以正確的已讀/未讀狀態顯示通知項目。然而,我們先前在收到項目時,在我們的 notificationsSlice reducer 中於用戶端新增已讀/未讀欄位,而現在通知項目保存在 RTK Query 快取中。

我們可以重新撰寫 notificationsSlice,使其偵聽任何收到的通知,並為每個通知項目追蹤用戶端的一些額外狀態。

在收到新的通知項目時,有兩種情況:當我們透過 HTTP 擷取初始清單時,以及當我們收到透過 Websocket 連線推播的更新時。理想情況下,我們希望對這兩種情況使用相同的邏輯來回應。我們可以使用 RTK 的 「比對工具」 來撰寫一個案例 reducer,以回應多個動作類型。

讓我們看看在新增此邏輯後,notificationsSlice 的樣貌。

features/notifications/notificationsSlice.js
import {
createAction,
createSlice,
createEntityAdapter,
createSelector,
isAnyOf
} from '@reduxjs/toolkit'

import { forceGenerateNotifications } from '../../api/server'
import { apiSlice } from '../api/apiSlice'

const notificationsReceived = createAction(
'notifications/notificationsReceived'
)

export const extendedApi = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query({
query: () => '/notifications',
async onCacheEntryAdded(
arg,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved, dispatch }
) {
// create a websocket connection when the cache subscription starts
const ws = new WebSocket('ws://127.0.0.1')
try {
// wait for the initial query to resolve before proceeding
await cacheDataLoaded

// when data is received from the socket connection to the server,
// update our query result with the received message
const listener = event => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'notifications': {
updateCachedData(draft => {
// Insert all received notifications from the websocket
// into the existing RTKQ cache array
draft.push(...message.payload)
draft.sort((a, b) => b.date.localeCompare(a.date))
})
// Dispatch an additional action so we can track "read" state
dispatch(notificationsReceived(message.payload))
break
}
default:
break
}
}

ws.addEventListener('message', listener)
} catch {
// no-op in case `cacheEntryRemoved` resolves before `cacheDataLoaded`,
// in which case `cacheDataLoaded` will throw
}
// cacheEntryRemoved will resolve when the cache subscription is no longer active
await cacheEntryRemoved
// perform cleanup steps once the `cacheEntryRemoved` promise resolves
ws.close()
}
})
})
})

export const { useGetNotificationsQuery } = extendedApi

// omit selectors and websocket thunk

const notificationsAdapter = createEntityAdapter()

const matchNotificationsReceived = isAnyOf(
notificationsReceived,
extendedApi.endpoints.getNotifications.matchFulfilled
)

const notificationsSlice = createSlice({
name: 'notifications',
initialState: notificationsAdapter.getInitialState(),
reducers: {
allNotificationsRead(state, action) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addMatcher(matchNotificationsReceived, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsMetadata = action.payload.map(notification => ({
id: notification.id,
read: false,
isNew: true
}))

Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})

notificationsAdapter.upsertMany(state, notificationsMetadata)
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

export const {
selectAll: selectNotificationsMetadata,
selectEntities: selectMetadataEntities
} = notificationsAdapter.getSelectors(state => state.notifications)

有很多事情發生,但讓我們一次分解變更。

目前沒有好的方法讓 notificationsSlice reducer 知道我們何時透過 Websocket 收到新的通知更新清單。因此,我們將匯入 createAction,定義一個新的動作類型,特別針對「收到一些通知」的情況,並在更新快取狀態後派送該動作。

我們希望對「已完成 getNotifications」動作 「從 Websocket 收到」動作執行相同的「新增已讀/新資料」邏輯。我們可以透過呼叫 isAnyOf() 並傳入每個動作建立者,來建立一個新的「比對器」函式。matchNotificationsReceived 比對器函式將在目前的動作比對其中任何一個類型時,傳回 true。

先前,我們有一個所有通知的正規化查詢表,而 UI 將它們選取為一個單一的排序陣列。我們將重新使用此區段來儲存描述已讀/未讀狀態的「資料」物件。

我們可以在 extraReducers 內部使用 builder.addMatcher() API,來新增一個案例 reducer,當我們比對到這兩個動作類型之一時,就會執行。在其中,我們新增一個新的「已讀/isNew」資料對應到每個通知 ID,並將其儲存在 notificationsSlice 內部。

最後,我們需要變更從此區段匯出的選擇器。我們將 selectAll 匯出為 selectAllNotifications,而不是 selectNotificationsMetadata。它仍然傳回正規化狀態中值的陣列,但我們變更名稱,因為項目本身已經變更。我們也將匯出 selectEntities 選擇器,它傳回查詢表物件本身,為 selectMetadataEntities。當我們嘗試在 UI 中使用此資料時,這將很有用。

有了這些變更,我們可以更新我們的 UI 元件來擷取和顯示通知。

app/Navbar.js
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Link } from 'react-router-dom'

import {
fetchNotificationsWebsocket,
selectNotificationsMetadata,
useGetNotificationsQuery
} from '../features/notifications/notificationsSlice'

export const Navbar = () => {
const dispatch = useDispatch()

// Trigger initial fetch of notifications and keep the websocket open to receive updates
useGetNotificationsQuery()

const notificationsMetadata = useSelector(selectNotificationsMetadata)
const numUnreadNotifications = notificationsMetadata.filter(
n => !n.read
).length

const fetchNewNotifications = () => {
dispatch(fetchNotificationsWebsocket())
}

let unreadNotificationsBadge

if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}

// omit rendering logic
}

<NavBar> 中,我們使用 useGetNotificationsQuery() 觸發初始通知擷取,並切換為從 state.notificationsSlice 讀取元資料物件。現在,按一下「重新整理」按鈕會觸發模擬 Websocket 伺服器推送出另一組通知。

我們的 <NotificationsList> 也會切換為讀取快取資料和元資料。

features/notifications/NotificationsList.js
import {
useGetNotificationsQuery,
allNotificationsRead,
selectMetadataEntities,
} from './notificationsSlice'

export const NotificationsList = () => {
const dispatch = useDispatch()
const { data: notifications = [] } = useGetNotificationsQuery()
const notificationsMetadata = useSelector(selectMetadataEntities)
const users = useSelector(selectAllUsers)

useLayoutEffect(() => {
dispatch(allNotificationsRead())
})

const renderedNotifications = notifications.map((notification) => {
const date = parseISO(notification.date)
const timeAgo = formatDistanceToNow(date)
const user = users.find((user) => user.id === notification.user) || {
name: 'Unknown User',
}

const metadata = notificationsMetadata[notification.id]

const notificationClassname = classnames('notification', {
new: metadata.isNew,
})

// omit rendering logic
}

我們從快取中讀取通知清單,並從 notificationsSlice 中讀取新的元資料項目,並繼續以與之前相同的方式顯示它們。

最後,我們可以在這裡執行一些額外的清理動作 - postsSlice 不再被使用,因此可以完全移除。

這樣,我們就完成了將我們的應用程式轉換為使用 RTK Query!所有資料擷取都已切換為使用 RTKQ,而且我們透過新增樂觀更新和串流更新來改善使用者體驗。

您已學到的內容

正如我們所見,RTK Query 包含一些強大的選項,用於控制我們如何管理快取資料。雖然您可能不需要立即使用所有這些選項,但它們提供了彈性和關鍵功能,以協助實作特定的應用程式行為。

讓我們最後再看一次整個應用程式的實際運作

摘要
  • 特定快取標籤可用於更精細的快取失效
    • 快取標籤可以是 'Post'{type: 'Post', id}
    • 端點可以根據結果和 arg 快取金鑰提供或失效快取標籤
  • RTK Query 的 API 與 UI 無關,可以在 React 之外使用
    • 端點物件包括用於啟動請求、產生結果選擇器和比對請求動作物件的函式
  • 回應可以根據需要以不同的方式轉換
    • 端點可以定義 transformResponse 回呼函數,在快取之前修改資料
    • 掛勾可以給予 selectFromResult 選項,用於萃取/轉換資料
    • 元件可以讀取整個值,並使用 useMemo 轉換
  • RTK Query 有進階選項,用於處理快取資料,以提供更好的使用者體驗
    • onQueryStarted 生命週期可以用於樂觀更新,透過在要求傳回之前立即更新快取來實現
    • onCacheEntryAdded 生命週期可以用於串流更新,透過根據伺服器推播連線,隨著時間更新快取來實現

下一步?

恭喜,您已完成 Redux Essentials 教學課程!您現在應該對 Redux Toolkit 和 React-Redux 有了紮實的了解,包括如何撰寫和組織 Redux 邏輯、Redux 資料流程和與 React 的使用方式,以及如何使用 configureStorecreateSlice 等 API。您也應該了解 RTK Query 如何簡化擷取和使用快取資料的流程。

"第 6 部分中的「下一步?」區段 有連結至其他資源,提供應用程式構想、教學課程和文件。

如需有關使用 RTK Query 的更多詳細資訊,請參閱 RTK Query 使用指南文件API 參考

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

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