Redux 要點,第 6 部分:效能和正規化資料
- 如何使用
createSelector
建立記憶化選擇器函式 - 最佳化元件呈現效能的模式
- 如何使用
createEntityAdapter
來儲存和更新正規化資料
- 完成 第 5 部分 以了解資料擷取流程
簡介
在 第 5 部分:非同步邏輯和資料擷取 中,我們了解如何撰寫非同步 thunk 以從伺服器 API 擷取資料、處理非同步要求載入狀態的模式,以及使用選擇器函式來封裝從 Redux 狀態中查詢資料。
在本節中,我們將探討最佳化模式,以確保應用程式的良好效能,以及自動處理資料儲存中常見更新的技術。
到目前為止,我們的大部分功能都圍繞著 posts
功能。我們將新增應用程式的新區段。新增這些區段後,我們將探討我們如何建構事物的某些具體細節,並討論我們到目前為止建構內容的一些缺點,以及我們如何改善實作。
新增使用者頁面
我們從我們的假 API 中擷取使用者清單,我們可以在新增新文章時選擇使用者作為作者。但是,社群媒體應用程式需要能夠查看特定使用者的頁面,並查看他們所發表的文章。我們新增一個頁面來顯示所有使用者的清單,另一個頁面來顯示特定使用者的所有文章。
我們將從新增一個新的 <UsersList>
元件開始。它遵循使用 useSelector
從儲存中讀取一些資料,並對陣列進行對應以顯示使用者清單及其個別頁面連結的慣常模式
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { selectAllUsers } from './usersSlice'
export const UsersList = () => {
const users = useSelector(selectAllUsers)
const renderedUsers = users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))
return (
<section>
<h2>Users</h2>
<ul>{renderedUsers}</ul>
</section>
)
}
我們還沒有 selectAllUsers
選擇器,因此我們需要將它新增到 usersSlice.js
,以及一個 selectUserById
選擇器
export default usersSlice.reducer
export const selectAllUsers = state => state.users
export const selectUserById = (state, userId) =>
state.users.find(user => user.id === userId)
我們將新增一個 <UserPage>
,它類似於我們的 <SinglePostPage>
,從路由中取得 userId
參數
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { selectUserById } from '../users/usersSlice'
import { selectAllPosts } from '../posts/postsSlice'
export const UserPage = ({ match }) => {
const { userId } = match.params
const user = useSelector(state => selectUserById(state, userId))
const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state)
return allPosts.filter(post => post.user === userId)
})
const postTitles = postsForUser.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))
return (
<section>
<h2>{user.name}</h2>
<ul>{postTitles}</ul>
</section>
)
}
正如我們之前所見,我們可以從一個 useSelector
呼叫或從道具中取得資料,並使用它來協助決定在另一個 useSelector
呼叫中從儲存中讀取什麼。
我們會像往常一樣,在 <App>
中為這些元件新增路由
<Route exact path="/posts/:postId" component={SinglePostPage} />
<Route exact path="/editPost/:postId" component={EditPostForm} />
<Route exact path="/users" component={UsersList} />
<Route exact path="/users/:userId" component={UserPage} />
<Redirect to="/" />
我們也會在 <Navbar>
中新增另一個連結到 /users
的分頁,這樣我們就可以點選並前往 <UsersList>
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
</div>
</div>
</section>
</nav>
)
}
新增通知
沒有通知彈出告訴我們有人傳送訊息、留下留言或對我們的貼文按讚,就稱不上是社群媒體應用程式。
在真正的應用程式中,我們的應用程式用戶端會持續與後端伺服器通訊,而伺服器會在每次發生某些事情時,將更新推播給用戶端。由於這是一個小型範例應用程式,我們會透過新增按鈕,從我們的假 API 中實際擷取一些通知項目,來模擬這個流程。我們也沒有其他真正的使用者傳送訊息或對貼文按讚,因此假 API 會在我們每次提出要求時,隨機產生一些通知項目。(請記住,我們的目標是了解如何使用 Redux 本身。)
通知區塊
由於這是我們應用程式的新部分,因此第一步是為我們的通知建立一個新的區塊,以及一個非同步 thunk,以從 API 擷取一些通知項目。為了建立一些逼真的通知,我們會包含狀態中最新通知的時間戳記。這將讓我們的模擬伺服器產生比該時間戳記更新的通知。
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '../../api/client'
export const fetchNotifications = createAsyncThunk(
'notifications/fetchNotifications',
async (_, { getState }) => {
const allNotifications = selectAllNotifications(getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification ? latestNotification.date : ''
const response = await client.get(
`/fakeApi/notifications?since=${latestTimestamp}`
)
return response.data
}
)
const notificationsSlice = createSlice({
name: 'notifications',
initialState: [],
reducers: {},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})
export default notificationsSlice.reducer
export const selectAllNotifications = state => state.notifications
與其他區塊一樣,將 notificationsReducer
匯入 store.js
中,並將其新增到 configureStore()
呼叫中。
我們撰寫了一個名為 fetchNotifications
的非同步 thunk,它會從伺服器中擷取新通知清單。作為其中的一部分,我們希望將最新通知的建立時間戳記用於我們的要求,以便伺服器知道它只應該傳送實際上是新的通知。
我們知道我們將會取得一組通知,因此我們可以將它們作為個別參數傳遞給 state.push()
,而陣列會新增每個項目。我們還想要確定它們已排序,以便最新通知在陣列中是最先的,以防伺服器將它們傳送出順序。(提醒一下,array.sort()
始終會變更現有陣列 - 這樣做是安全的,因為我們在內部使用 createSlice
和 Immer。)
Thunk 參數
如果您查看我們的 fetchNotifications
thunk,它有一些我們之前沒看過的新東西。讓我們花點時間來討論 thunk 參數。
我們已經看到,當我們發送時,我們可以將參數傳遞到 thunk 動作建立器中,例如 dispatch(addPost(newPost))
。特別是對於 createAsyncThunk
,您只能傳入一個參數,而我們傳入的任何內容都會成為 payload 建立回呼的第一個參數。
我們的 payload 建立器的第二個參數是一個 thunkAPI
物件,其中包含幾個有用的函數和資訊
dispatch
和getState
:我們 Redux 儲存體中的實際dispatch
和getState
方法。您可以在 thunk 中使用這些方法來發送更多動作,或取得最新的 Redux 儲存體狀態(例如在發送另一個動作後讀取更新的值)。extra
:建立儲存體時可以傳遞到 thunk 中介軟體的「額外參數」。這通常是一些 API wrapper,例如一組函數,它們知道如何對應用程式的伺服器進行 API 呼叫並傳回資料,以便您的 thunk 不必直接在內部包含所有 URL 和查詢邏輯。requestId
:此 thunk 呼叫的唯一隨機 ID 值。對於追蹤個別要求的狀態很有用。signal
:一個AbortController.signal
函數,可用於取消進行中的要求。rejectWithValue
:一個實用程式,如果 thunk 收到錯誤,則有助於自訂rejected
動作的內容。
(如果您手動撰寫 thunk,而不是使用 createAsyncThunk
,則 thunk 函數會取得 (dispatch, getState)
作為個別參數,而不是將它們放在一個物件中。)
有關這些論點和如何處理取消 thunk 和要求的詳細資訊,請參閱 createAsyncThunk
API 參考頁面。
在這種情況下,我們知道通知清單在我們的 Redux store 狀態中,最新通知應在陣列中排第一。我們可以將 getState
函式從 thunkAPI
物件中解構出來,呼叫它來讀取狀態值,並使用 selectAllNotifications
選擇器僅提供通知陣列。由於通知陣列是最新排序的,我們可以使用陣列解構來取得最新通知。
新增通知清單
建立該區塊後,我們可以新增 <NotificationsList>
元件
import React from 'react'
import { useSelector } from 'react-redux'
import { formatDistanceToNow, parseISO } from 'date-fns'
import { selectAllUsers } from '../users/usersSlice'
import { selectAllNotifications } from './notificationsSlice'
export const NotificationsList = () => {
const notifications = useSelector(selectAllNotifications)
const users = useSelector(selectAllUsers)
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'
}
return (
<div key={notification.id} className="notification">
<div>
<b>{user.name}</b> {notification.message}
</div>
<div title={notification.date}>
<i>{timeAgo} ago</i>
</div>
</div>
)
})
return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}
我們再次從 Redux 狀態中讀取項目清單,對其進行對應,並為每個項目呈現內容。
我們還需要更新 <Navbar>
以新增「通知」標籤和一個用於擷取一些通知的新按鈕
import React from 'react'
import { useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'
import { fetchNotifications } from '../features/notifications/notificationsSlice'
export const Navbar = () => {
const dispatch = useDispatch()
const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">Notifications</Link>
</div>
<button className="button" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
</section>
</nav>
)
}
最後,我們需要使用「通知」路由更新 App.js
,以便我們可以導航到它
// omit imports
import { NotificationsList } from './features/notifications/NotificationsList'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Switch>
<Route exact path="/notifications" component={NotificationsList} />
// omit existing routes
<Redirect to="/" />
</Switch>
</div>
</Router>
)
}
以下是「通知」標籤目前的外觀
顯示新通知
每次我們點擊「重新整理通知」,我們的清單中就會新增幾個通知項目。在實際的應用程式中,當我們檢視 UI 的其他部分時,這些通知可能會來自伺服器。我們也可以在檢視 <PostsList>
或 <UserPage>
時,點擊「重新整理通知」來執行類似的動作。不過,現在我們不知道剛剛收到多少通知,如果我們持續點擊按鈕,可能會有很多我們尚未讀取的通知。讓我們新增一些邏輯來追蹤哪些通知已讀取,哪些通知是「新的」。這樣我們就能在導覽列的「通知」標籤上,以徽章顯示「未讀」通知的數量,並以不同的顏色顯示新通知。
我們的假 API 已經使用 isNew
和 read
欄位傳回通知項目,因此我們可以在程式碼中使用這些欄位。
首先,我們會更新 notificationsSlice
,讓它有一個將所有通知標記為已讀的 reducer,以及一些邏輯來處理將現有通知標記為「非新」的動作
const notificationsSlice = createSlice({
name: 'notifications',
initialState: [],
reducers: {
allNotificationsRead(state, action) {
state.forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
state.forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})
export const { allNotificationsRead } = notificationsSlice.actions
export default notificationsSlice.reducer
我們希望在 <NotificationsList>
元件重新呈現時,將這些通知標記為已讀,原因可能是我們點擊標籤來檢視通知,或我們已經開啟標籤,而且剛剛收到一些額外的通知。我們可以在這個元件重新呈現時,隨時發送 allNotificationsRead
來執行此動作。為了避免在更新時出現舊資料閃爍的問題,我們會在 useLayoutEffect
鉤子中發送動作。我們也希望在頁面中的任何通知清單項目中,新增一個額外的類別名稱,以突顯它們
import React, { useLayoutEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { formatDistanceToNow, parseISO } from 'date-fns'
import classnames from 'classnames'
import { selectAllUsers } from '../users/usersSlice'
import {
selectAllNotifications,
allNotificationsRead
} from './notificationsSlice'
export const NotificationsList = () => {
const dispatch = useDispatch()
const notifications = useSelector(selectAllNotifications)
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 notificationClassname = classnames('notification', {
new: notification.isNew
})
return (
<div key={notification.id} className={notificationClassname}>
<div>
<b>{user.name}</b> {notification.message}
</div>
<div title={notification.date}>
<i>{timeAgo} ago</i>
</div>
</div>
)
})
return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}
這項功能有效,但實際上有一些令人稍感意外的行為。每當有新通知時(可能是因為我們剛切換到這個標籤,或從 API 中擷取了一些新通知),你實際上會看到兩個 "notifications/allNotificationsRead"
動作被發送。這是為什麼呢?
假設我們在查看 <PostsList>
時擷取了一些通知,然後按一下「通知」標籤。<NotificationsList>
元件會掛載,而且 useLayoutEffect
回呼會在第一次重新整理後執行,並傳送 allNotificationsRead
。我們的 notificationsSlice
會透過更新儲存體中的通知項目來處理這件事。這會建立一個新的 state.notifications
陣列,其中包含不可變更新的項目,這會強制我們的元件再次重新整理,因為它看到 useSelector
傳回一個新的陣列,而且 useLayoutEffect
掛鉤會再次執行,並第二次傳送 allNotificationsRead
。還原器會再次執行,但這次沒有資料變更,所以元件不會重新整理。
有幾種方法可以避免第二次傳送,例如在元件掛載時將邏輯分成一次傳送,而且只有在通知陣列的大小變更時才再次傳送。不過,這其實不會造成任何傷害,所以我們可以不用管它。
這確實顯示了 傳送動作而不讓 任何 狀態變更都是可能的。請記住,由您的還原器決定是否 實際 需要更新任何狀態,而且「不需要發生任何事情」是還原器可以做出的有效決定。
以下是通知標籤在「新/已讀」行為運作後的外觀
在繼續之前,我們需要做的最後一件事是在導覽列的「通知」標籤上新增徽章。當我們在其他標籤時,這會顯示「未讀」通知的數量
// omit imports
import { useDispatch, useSelector } from 'react-redux'
import {
fetchNotifications,
selectAllNotifications
} from '../features/notifications/notificationsSlice'
export const Navbar = () => {
const dispatch = useDispatch()
const notifications = useSelector(selectAllNotifications)
const numUnreadNotifications = notifications.filter(n => !n.read).length
// omit component contents
let unreadNotificationsBadge
if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}
return (
<nav>
// omit component contents
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">
Notifications {unreadNotificationsBadge}
</Link>
</div>
// omit component contents
</nav>
)
}
改善重新整理效能
我們的應用程式看起來很有用,但我們在元件重新整理的時間和方式上確實有一些缺點。讓我們看看這些問題,並討論一些改善效能的方法。
調查重新整理行為
我們可以使用 React DevTools Profiler 來檢視一些元件在狀態更新時重新整理的圖表。請嘗試按一下 <UserPage>
以查看單一使用者。開啟瀏覽器的 DevTools,然後在 React 的「Profiler」標籤中,按一下左上角的圓形「記錄」按鈕。然後,按一下我們應用程式中的「重新整理通知」按鈕,並在 React DevTools Profiler 中停止記錄。您應該會看到一個類似這樣的圖表
我們可以看到 <Navbar>
重新整理了,這很有道理,因為它必須在標籤中顯示更新的「未讀通知」徽章。但是,為什麼我們的 <UserPage>
會重新整理?
如果我們檢查 Redux DevTools 中最後幾個發送的動作,我們可以看到只有通知狀態已更新。由於 <UserPage>
沒有讀取任何通知,因此它不應該重新渲染。這個組件一定出了一些問題。
如果我們仔細查看 <UserPage>
,會發現一個具體的問題
export const UserPage = ({ match }) => {
const { userId } = match.params
const user = useSelector(state => selectUserById(state, userId))
const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state)
return allPosts.filter(post => post.user === userId)
})
// omit rendering logic
}
我們知道 useSelector
將在每次發送動作時重新執行,如果我們傳回新的參考值,它會強制組件重新渲染。
我們在 useSelector
鉤子內部呼叫 filter()
,這樣我們只會傳回屬於此使用者的貼文清單。不幸的是,這表示 useSelector
總是 傳回新的陣列參考,因此我們的組件將在 每個 動作後重新渲染,即使貼文資料沒有變更!。
備忘選擇器函式
我們真正需要的是一種方法,只有當 state.posts
或 userId
發生變更時才計算新的過濾陣列。如果它們沒有變更,我們想要傳回與上次相同的過濾陣列參考。
這個想法稱為「備忘」。我們想要儲存一組先前的輸入和計算結果,如果輸入相同,則傳回先前的結果,而不是重新計算它。
到目前為止,我們一直自己撰寫選擇器函式,只是為了不必複製和貼上從儲存區讀取資料的程式碼。如果有一種方法可以讓我們的選擇器函式備忘,那就太好了。
Reselect 是用於建立備忘選擇器函式的函式庫,特別設計用於與 Redux 搭配使用。它有一個 createSelector
函式,用於產生備忘選擇器,這些選擇器僅在輸入變更時才會重新計算結果。Redux Toolkit 匯出 createSelector
函式,因此我們已經可以使用它了。
讓我們使用 Reselect 建立一個新的 selectPostsByUser
選擇器函式,並在此處使用它。
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'
// omit slice logic
export const selectAllPosts = state => state.posts.posts
export const selectPostById = (state, postId) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)
createSelector
將一個或多個「輸入選擇器」函式作為引數,以及一個「輸出選擇器」函式。當我們呼叫 selectPostsByUser(state, userId)
時,createSelector
會將所有引數傳遞到我們的每個輸入選擇器中。這些輸入選擇器傳回的任何內容都會成為輸出選擇器的引數。
在這個情況下,我們知道我們需要所有貼文的陣列和使用者 ID 作為輸出選擇器的兩個參數。我們可以重複使用現有的 selectAllPosts
選擇器來萃取貼文陣列。由於使用者 ID 是我們傳遞到 selectPostsByUser
的第二個參數,我們可以撰寫一個只回傳 userId
的小選擇器。
我們的輸出選擇器接著會採用 posts
和 userId
,並回傳僅針對該使用者的已篩選貼文陣列。
如果我們嘗試多次呼叫 selectPostsByUser
,它只會在 posts
或 userId
其中之一變更時重新執行輸出選擇器。
const state1 = getState()
// Output selector runs, because it's the first call
selectPostsByUser(state1, 'user1')
// Output selector does _not_ run, because the arguments haven't changed
selectPostsByUser(state1, 'user1')
// Output selector runs, because `userId` changed
selectPostsByUser(state1, 'user2')
dispatch(reactionAdded())
const state2 = getState()
// Output selector does not run, because `posts` and `userId` are the same
selectPostsByUser(state2, 'user2')
// Add some more posts
dispatch(addNewPost())
const state3 = getState()
// Output selector runs, because `posts` has changed
selectPostsByUser(state3, 'user2')
如果我們在 <UserPage>
中呼叫這個選擇器,並在擷取通知時重新執行 React Profiler,我們應該會看到 <UserPage>
這次不會重新渲染。
export const UserPage = ({ match }) => {
const { userId } = match.params
const user = useSelector(state => selectUserById(state, userId))
const postsForUser = useSelector(state => selectPostsByUser(state, userId))
// omit rendering logic
}
記憶化選擇器是改善 React+Redux 應用程式效能的寶貴工具,因為它們可以協助我們避免不必要的重新渲染,並避免在輸入資料未變更時進行潛在的複雜或昂貴運算。
有關我們使用選擇器函式的詳細資訊,以及如何使用 Reselect 撰寫記憶化選擇器,請參閱
調查貼文清單
如果我們回到我們的 <PostsList>
,並嘗試在其中一則貼文上按一下反應按鈕,同時擷取 React Profiler 追蹤,我們會看到不僅 <PostsList>
和已更新的 <PostExcerpt>
執行個體會渲染,所有 <PostExcerpt>
元件都會渲染。
這是為什麼?其他貼文都沒有變更,所以它們為什麼需要重新渲染?
React 的預設行為是,當父元件渲染時,React 會遞迴渲染其內部所有子元件!。一則貼文物件的不可變更新也建立了一個新的 posts
陣列。我們的 <PostsList>
必須重新渲染,因為 posts
陣列是一個新的參考,所以它渲染後,React 會繼續向下,並重新渲染所有 <PostExcerpt>
元件。
對於我們的小範例應用程式,這不是一個嚴重的問題,但在一個較大的實際應用程式中,我們可能有一些非常長的清單或非常大的元件樹,而讓所有這些額外的元件重新渲染可能會減慢速度。
有幾種不同的方式可以最佳化 <PostsList>
中的這個行為。
首先,我們可以將 <PostExcerpt>
元件包覆在 React.memo()
中,這將確保其內部的元件只會在道具實際變更時重新渲染。這實際上會運作得很好 - 試試看,看看會發生什麼事。
let PostExcerpt = ({ post }) => {
// omit logic
}
PostExcerpt = React.memo(PostExcerpt)
另一個選項是改寫 <PostsList>
,讓它只從儲存庫中選取文章 ID 清單,而不是整個 posts
陣列,並改寫 <PostExcerpt>
,讓它接收 postId
道具並呼叫 useSelector
來讀取它需要的文章物件。如果 <PostsList>
取得與之前相同的 ID 清單,它就不需要重新渲染,因此只有我們一個已變更的 <PostExcerpt>
元件需要渲染。
很不幸地,這變得棘手,因為我們也需要讓所有文章依日期排序並以正確順序渲染。我們可以更新我們的 postsSlice
,讓陣列隨時保持已排序狀態,因此我們不必在元件中對它排序,並使用記憶化選取器來萃取文章 ID 清單。我們也可以 自訂 useSelector
執行的比較函式來檢查結果,例如 useSelector(selectPostIds, shallowEqual)
,如此一來,如果 ID 陣列的內容沒有變更,它就會略過重新渲染。
最後一個選項是找到某種方法,讓我們的 reducer 保留所有文章的 ID 獨立陣列,並只在新增或移除文章時修改該陣列,並對 <PostsList>
和 <PostExcerpt>
進行相同的改寫。這樣,<PostsList>
僅需要在該 ID 陣列變更時重新渲染。
很方便的是,Redux Toolkit 有個 createEntityAdapter
函式,它將協助我們執行這項工作。
正規化資料
你已經看到許多我們的邏輯都是透過其 ID 欄位來查詢項目。由於我們一直將資料儲存在陣列中,這表示我們必須使用 array.find()
迴圈遍歷陣列中的所有項目,直到找到具有我們正在尋找的 ID 的項目。
實際上,這花不了多少時間,但如果我們的陣列中有數百或數千個項目,那麼在整個陣列中尋找一個項目就會浪費精力。我們需要的是一種方法,可以根據其 ID 直接查詢單個項目,而無需檢查所有其他項目。此過程稱為「正規化」。
正規化狀態結構
「正規化狀態」表示
- 我們在狀態中只有一個特定資料片段的副本,因此沒有重複
- 已正規化的資料保存在查詢表中,其中項目 ID 是鍵,而項目本身是值。
- 也可能有一個特定項目類型的所有 ID 陣列
JavaScript 物件可以用作查詢表,類似於其他語言中的「映射」或「字典」。以下是使用者物件群組的正規化狀態可能看起來的樣子
{
users: {
ids: ["user1", "user2", "user3"],
entities: {
"user1": {id: "user1", firstName, lastName},
"user2": {id: "user2", firstName, lastName},
"user3": {id: "user3", firstName, lastName},
}
}
}
這使得可以輕鬆地透過其 ID 找到特定使用者物件,而無需在陣列中迴圈瀏覽所有其他使用者物件
const userId = 'user2'
const userObject = state.users.entities[userId]
使用 createEntityAdapter
管理正規化狀態
Redux Toolkit 的 createEntityAdapter
API 提供了一種標準化方式,可以透過取得項目集合並將它們放入 { ids: [], entities: {} }
形狀來將資料儲存在區段中。除了這個預先定義的狀態形狀外,它還會產生一組還原器函數和選擇器,這些函數和選擇器知道如何使用該資料。
這有幾個好處
- 我們不必自己撰寫程式碼來管理正規化
createEntityAdapter
的預建還原器函數處理常見情況,例如「新增所有這些項目」、「更新一個項目」或「移除多個項目」createEntityAdapter
可以根據項目的內容將 ID 陣列保持在已排序順序中,並且只有在新增/移除項目或排序順序變更時才會更新該陣列。
createEntityAdapter
接受一個選項物件,其中可能包含 sortComparer
函數,該函數將用於透過比較兩個項目(並與 Array.sort()
的運作方式相同)來將項目 ID 陣列保持在已排序順序中。
它會傳回一個包含一組用於新增、更新和從實體狀態物件中移除項目的已產生還原器函數的物件。這些還原器函數可以用作特定動作類型的案例還原器,或用作 createSlice
中另一個還原器中的「變異」工具函數。
adapter 物件還有一個 getSelectors
函式。你可以傳入一個選擇器,從 Redux 根狀態回傳這個特定狀態片段,它會產生像 selectAll
和 selectById
這樣的選擇器。
最後,adapter 物件有一個 getInitialState
函式,用來產生一個空的 {ids: [], entities: {}}
物件。你可以傳入更多欄位到 getInitialState
,它們會被合併進去。
更新貼文片段
記住這點,讓我們更新我們的 postsSlice
以使用 createEntityAdapter
import {
createEntityAdapter
// omit other imports
} from '@reduxjs/toolkit'
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
const initialState = postsAdapter.getInitialState({
status: 'idle',
error: null
})
// omit thunks
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.entities[id]
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
},
extraReducers(builder) {
// omit other reducers
builder
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
// Use the `upsertMany` reducer as a mutating update utility
postsAdapter.upsertMany(state, action.payload)
})
// Use the `addOne` reducer for the fulfilled case
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
// Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors(state => state.posts)
export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)
這裡發生了很多事!讓我們分解一下。
首先,我們匯入 createEntityAdapter
,並呼叫它來建立我們的 postsAdapter
物件。我們知道我們想要保留一個所有貼文 ID 的陣列,並按照最新貼文排序,所以我們傳入一個 sortComparer
函式,它會根據 post.date
欄位將較新的項目排序到前面。
getInitialState()
回傳一個空的 {ids: [], entities: {}}
正規化狀態物件。我們的 postsSlice
需要保留 status
和 error
欄位來載入狀態,所以我們將它們傳遞到 getInitialState()
。
現在我們的貼文被保留在 state.entities
中的查詢表中,我們可以更改我們的 reactionAdded
和 postUpdated
減少器,直接透過它們的 ID 查詢正確的貼文,而不是必須迴圈舊的 posts
陣列。
當我們收到 fetchPosts.fulfilled
動作時,我們可以使用 postsAdapter.upsertMany
函式將所有傳入的貼文新增到狀態中,方法是傳入草稿 state
和 action.payload
中的貼文陣列。如果 action.payload
中有任何項目已經存在於我們的狀態中,upsertMany
函式會根據匹配的 ID 將它們合併在一起。
當我們收到 addNewPost.fulfilled
動作時,我們知道我們需要將那個新的貼文物件新增到我們的狀態中。我們可以直接將 adapter 函式作為減少器,所以我們會傳遞 postsAdapter.addOne
作為減少器函式來處理該動作。
最後,我們可以用 `postsAdapter.getSelectors` 產生的函式取代舊的手寫 `selectAllPosts` 和 `selectPostById` 選擇器函式。由於選擇器會呼叫根 Redux 狀態物件,因此它們需要知道在 Redux 狀態中找出我們的文章資料,所以我們傳入一個會傳回 `state.posts` 的小型選擇器。產生的選擇器函式總是稱為 `selectAll` 和 `selectById`,所以我們可以使用解構語法在匯出時將它們重新命名,並與舊的選擇器名稱相符。我們也會以相同的方式匯出 `selectPostIds`,因為我們想要在 `<PostsList>` 元件中讀取已排序文章 ID 的清單。
最佳化文章清單
現在我們的文章區塊使用 `createEntityAdapter`,我們可以更新 `<PostsList>` 來最佳化其渲染行為。
我們會更新 `<PostsList>` 以僅讀取已排序的文章 ID 陣列,並將 `postId` 傳遞給每個 `<PostExcerpt>`
// omit other imports
import {
selectAllPosts,
fetchPosts,
selectPostIds,
selectPostById
} from './postsSlice'
let PostExcerpt = ({ postId }) => {
const post = useSelector(state => selectPostById(state, postId))
// omit rendering logic
}
export const PostsList = () => {
const dispatch = useDispatch()
const orderedPostIds = useSelector(selectPostIds)
// omit other selections and effects
if (postStatus === 'loading') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
content = orderedPostIds.map(postId => (
<PostExcerpt key={postId} postId={postId} />
))
} else if (postStatus === 'error') {
content = <div>{error}</div>
}
// omit other rendering
}
現在,如果我們嘗試在擷取 React 元件效能剖析時按一下其中一篇文章的反應按鈕,我們應該會看到只有該元件重新渲染
轉換其他區塊
我們快完成了。作為最後的清理步驟,我們會更新我們的其他兩個區塊,也使用 `createEntityAdapter`。
轉換使用者區塊
`usersSlice` 相當小,所以我們只有幾件事要變更
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '../../api/client'
const usersAdapter = createEntityAdapter()
const initialState = usersAdapter.getInitialState()
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.users
})
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, usersAdapter.setAll)
}
})
export default usersSlice.reducer
export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors(state => state.users)
我們在此處理的唯一動作總是會用從伺服器擷取的陣列取代整個使用者清單。我們可以使用 `usersAdapter.setAll` 來實作它。
我們的 `<AddPostForm>` 仍然嘗試將 `state.users` 讀取為陣列,`<PostAuthor>` 也是。更新它們分別使用 `selectAllUsers` 和 `selectUserById`。
轉換通知區塊
最後但並非最不重要,我們也會更新 `notificationsSlice`
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '../../api/client'
const notificationsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
// omit fetchNotifications thunk
const notificationsSlice = createSlice({
name: 'notifications',
initialState: notificationsAdapter.getInitialState(),
reducers: {
allNotificationsRead(state, action) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
notificationsAdapter.upsertMany(state, action.payload)
Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
})
}
})
export const { allNotificationsRead } = notificationsSlice.actions
export default notificationsSlice.reducer
export const { selectAll: selectAllNotifications } =
notificationsAdapter.getSelectors(state => state.notifications)
我們再次匯入 `createEntityAdapter`,呼叫它,並呼叫 `notificationsAdapter.getInitialState()` 來協助設定區塊。
諷刺的是,我們確實有幾個地方需要迴圈遍歷所有通知物件並更新它們。由於這些通知不再保存在陣列中,我們必須使用 Object.values(state.entities)
取得這些通知的陣列並迴圈遍歷。另一方面,我們可以用 notificationsAdapter.upsertMany
取代先前的擷取更新邏輯。
這樣一來... 我們就完成 Redux Toolkit 核心概念和功能的學習了!
你學到了什麼
我們在這個區段建構了許多新的行為。讓我們看看應用程式在所有這些變更後是什麼樣子
以下是我們在這個區段涵蓋的內容
- 可記憶化的選擇器函式可用於最佳化效能
- Redux Toolkit 從 Reselect 重新匯出
createSelector
函式,它會產生可記憶化的選擇器 - 可記憶化的選擇器只會在輸入選擇器傳回新值時重新計算結果
- 記憶化可以略過昂貴的計算,並確保傳回相同的結果參考
- Redux Toolkit 從 Reselect 重新匯出
- 你可以使用多種模式來最佳化 React 元件使用 Redux 的渲染
- 避免在
useSelector
內部建立新的物件/陣列參考 - 這些會導致不必要的重新渲染 - 可記憶化的選擇器函式可以傳遞給
useSelector
以最佳化渲染 useSelector
可以接受備用比較函式,例如shallowEqual
,而不是參考相等- 元件可以包裝在
React.memo()
中,只有在它們的道具變更時才重新渲染 - 清單渲染可以透過讓清單父元件只讀取項目 ID 陣列、將 ID 傳遞給清單項目子元件,以及在子元件中按 ID 擷取項目來最佳化
- 避免在
- 正規化的狀態結構是儲存項目的建議方法
- 「正規化」表示沒有重複資料,並將項目儲存在按項目 ID 查詢的查詢表中
- 正規化的狀態形狀通常看起來像
{ids: [], entities: {}}
- Redux Toolkit 的
createEntityAdapter
API 協助管理區段中的正規化資料- 項目 ID 可以透過傳入
sortComparer
選項來按排序順序保留 - 適配器物件包含
adapter.getInitialState
,它可以接受其他狀態欄位,例如載入狀態- 針對常見案例的預建式 reducer,例如
setAll
、addMany
、upsertOne
和removeMany
adapter.getSelectors
,用來產生selectAll
和selectById
等選取器
- 項目 ID 可以透過傳入
接下來是什麼?
Redux Essentials 教學課程中還有幾節,但這是一個暫停並將你所學付諸實踐的好地方。
到目前為止,我們在本教學課程中涵蓋的概念應足以讓你開始使用 React 和 Redux 建置自己的應用程式。現在是嘗試自己進行專案,以鞏固這些概念並了解它們在實務中的運作方式的好時機。如果你不確定要建置什麼樣的專案,請參閱 這個應用程式專案構想清單 以獲得一些靈感。
Redux Toolkit 還包含一個強大的資料擷取和快取 API,稱為「RTK Query」。RTK Query 是可選的附加元件,可以完全消除自己撰寫任何資料擷取邏輯的需要。在 第 7 部分:RTK Query 基礎 中,你將學習 RTK Query 是什麼、它解決了什麼問題,以及如何使用它在應用程式中擷取和使用快取資料。
Redux Essentials 教學課程專注於「如何正確使用 Redux」,而不是「它是如何運作的」或「為什麼它會這樣運作」。特別是,Redux Toolkit 是一組較高層級的抽象和公用程式,了解 RTK 中的抽象實際上為你執行了什麼動作是有幫助的。閱讀 「Redux 基礎」教學課程 將有助於你了解如何「手動」撰寫 Redux 程式碼,以及為什麼我們建議將 Redux Toolkit 作為撰寫 Redux 邏輯的預設方式。
使用 Redux 部分包含許多重要概念的資訊,例如 如何建構你的 reducer,以及 我們的風格指南頁面 包含有關我們建議模式和最佳實務的重要資訊。
如果你想進一步了解 Redux 為什麼 存在、它試圖解決什麼問題以及它應該如何使用,請參閱 Redux 維護者 Mark Erikson 在 Redux 的道,第 1 部分:實作和意圖 和 Redux 的道,第 2 部分:實務和哲學 中的文章。
如果你正在尋找 Redux 問題的協助,請加入 Discord 上 Reactiflux 伺服器中的 #redux
頻道。
感謝您閱讀本教學課程,我們希望您享受使用 Redux 建立應用程式的過程!