Redux 風格指南
簡介
這是撰寫 Redux 程式碼的官方風格指南。它列出我們建議的模式、最佳實務和建議方法,用於撰寫 Redux 應用程式。
Redux 核心程式庫和大部分 Redux 文件都是不帶意見的。有許多方法可以使用 Redux,而且很多時候沒有單一的「正確」方法來做事。
然而,時間和經驗表明,對於某些主題,某些方法比其他方法更有效。此外,許多開發人員要求我們提供官方指導,以減少決策疲勞。
有鑑於此,我們整理了這份建議清單,以幫助您避免錯誤、無謂的爭論和反模式。我們也了解團隊偏好不同,不同的專案有不同的需求,因此沒有風格指南可以適用於所有規模。我們鼓勵您遵循這些建議,但請花時間評估您自己的情況,並決定它們是否符合您的需求。
最後,我們要感謝 Vue 文件作者撰寫了 Vue 風格指南頁面,它是此頁面的靈感來源。
規則類別
我們將這些規則分為三類
優先順序 A:必要
這些規則有助於防止錯誤,因此不論如何都要學習並遵守它們。例外情況可能存在,但應非常罕見,且僅限於同時精通 JavaScript 和 Redux 的專家提出。
優先順序 B:強烈建議
已發現這些規則可以在大多數專案中改善可讀性和/或開發人員體驗。即使違反這些規則,您的程式碼仍然會執行,但違反情況應罕見且有充分理由。只要合理可行,請遵循這些規則。
優先順序 C:建議
在有多個同樣好的選項時,可以做出任意選擇以確保一致性。在這些規則中,我們描述每個可接受的選項並建議預設選項。這表示您可以自由地在自己的程式碼庫中做出不同的選擇,只要保持一致且有充分的理由即可。不過,請務必有充分的理由!
優先順序 A 規則:必要
不要變異狀態
變異狀態是 Redux 應用程式中最常見的錯誤原因,包括元件無法正確重新渲染,並且還會中斷 Redux DevTools 中的時間旅行除錯。無論是在 reducer 中或其他所有應用程式程式碼中,都應始終避免實際變異狀態值。
使用 redux-immutable-state-invariant
等工具來捕捉開發期間的變異,以及 Immer 來避免在狀態更新中意外變異。
注意:修改現有值的副本是可以的 - 這是撰寫不可變更新邏輯的正常部分。此外,如果您使用 Immer 函式庫進行不可變更新,則撰寫「變異」邏輯是可以接受的,因為實際資料並未變異 - Immer 會安全地追蹤變更並在內部產生不可變更新的值。
Reducer 不應有副作用
Reducer 函式僅應取決於其 state
和 action
參數,並且僅應根據這些參數計算並傳回新的狀態值。它們不得執行任何類型的非同步邏輯(AJAX 呼叫、逾時、承諾)、產生隨機值(Date.now()
、Math.random()
)、修改 reducer 之外的變數,或執行其他會影響 reducer 函式範圍之外事項的程式碼。
注意:reducer 呼叫定義在自身之外的其他函式是可以接受的,例如從函式庫或公用函式匯入的函式,只要它們遵循相同的規則即可。
詳細說明
此規則的目的是保證在呼叫時,reducer 會有可預測的行為。例如,如果你正在進行時間旅行除錯,reducer 函數可能會被呼叫多次,並帶有較早的動作,以產生「目前的」狀態值。如果 reducer 有副作用,這將導致這些副作用在除錯過程中執行,並導致應用程式以意外的方式運作。
此規則有一些灰色地帶。嚴格來說,console.log(state)
等程式碼是一種副作用,但實際上並不會對應用程式的運作方式產生影響。
不要在狀態或動作中放入不可序列化值
避免將不可序列化值(例如 Promise、符號、Map/Set、函數或類別實例)放入 Redux 儲存狀態或發送的動作中。這可確保透過 Redux DevTools 進行除錯等功能可以按預期運作。它還可確保 UI 會按預期更新。
例外:如果你在動作中放入不可序列化值,且該動作會在到達 reducer 之前被中介軟體攔截並停止,則可以這麼做。例如
redux-thunk
和redux-promise
等中介軟體。
每個應用程式只有一個 Redux 儲存
標準 Redux 應用程式只應有一個 Redux 儲存實例,整個應用程式都會使用此實例。它通常會定義在一個獨立的檔案中,例如 store.js
。
理想情況下,沒有任何應用程式邏輯會直接匯入儲存。它應透過 <Provider>
傳遞給 React 元件樹,或透過中介軟體(例如 thunk)間接參照。在極少數情況下,你可能需要將它匯入其他邏輯檔案,但這應作為最後的手段。
優先順序 B 規則:強烈建議
使用 Redux Toolkit 編寫 Redux 邏輯
Redux Toolkit 是我們建議用於 Redux 的工具集。它具有內建建議最佳實務的函數,包括設定儲存以捕捉變異並啟用 Redux DevTools 擴充功能,使用 Immer 簡化不可變更新邏輯,以及更多功能。
你不需要在 Redux 中使用 RTK,如果你願意,你可以自由使用其他方法,但使用 RTK 將簡化你的邏輯,並確保你的應用程式設定了良好的預設值。
使用 Immer 編寫不可變更新
手動編寫不可變更新邏輯通常很困難,而且容易出錯。 Immer 允許你使用「變異」邏輯編寫更簡單的不可變更新,甚至凍結你的狀態在開發中,以捕捉應用程式其他地方的變異。我們建議使用 Immer 編寫不可變更新邏輯,最好作為 Redux Toolkit 的一部分。
結構檔案作為具有單一檔案邏輯的功能資料夾
Redux 本身並不在乎應用程式的資料夾和檔案結構。然而,將特定功能的邏輯集中於一處通常可以讓維護該程式碼變得更容易。
因此,我們建議大多數應用程式應使用「功能資料夾」方法來建構檔案結構(將特定功能的所有檔案放在同一個資料夾中)。在特定的功能資料夾中,該功能的 Redux 邏輯應寫成單一的「區塊」檔案,最好使用 Redux Toolkit 的 createSlice
API。(這也稱為 "ducks" 模式)。雖然較舊的 Redux 程式碼庫通常使用「按類型分類的資料夾」方法,並為「動作」和「簡約器」建立不同的資料夾,但將相關邏輯放在一起可以讓尋找和更新該程式碼變得更容易。
詳細說明:範例資料夾結構
範例資料夾結構可能如下所示/src
index.tsx
:呈現 React 元件樹的進入點檔案/app
store.ts
:儲存體設定rootReducer.ts
:根簡約器(選用)App.tsx
:根 React 元件
/common
:勾子、一般元件、工具程式等/features
:包含所有「功能資料夾」/todos
:單一功能資料夾todosSlice.ts
:Redux 簡約器邏輯和相關動作Todos.tsx
:React 元件
/app
包含應用程式全域設定和佈局,這些設定和佈局依賴於所有其他資料夾。
/common
包含真正一般且可重複使用的工具程式和元件。
/features
具有包含與特定功能相關的所有功能的資料夾。在此範例中,todosSlice.ts
是「duck」樣式的檔案,其中包含呼叫 RTK 的 createSlice()
函式的程式碼,並匯出區塊簡約器和動作建立器。
將儘可能多的邏輯放入簡約器中
在可能的情況下,請嘗試將計算新狀態的邏輯儘可能多地放入適當的簡約器中,而不是放入準備和傳送動作的程式碼中(例如按鈕點擊處理常式)。這有助於確保更多實際應用程式邏輯容易進行測試,能更有效地使用時光旅行除錯,並有助於避免可能導致突變和錯誤的常見錯誤。
有些情況下,部分或全部新狀態應先進行計算(例如產生唯一 ID),但這應盡量減少。
詳細說明
Redux 核心實際上並不在乎新的狀態值是在 reducer 或 action 建立邏輯中計算。例如,對於 todo 應用程式,「切換 todo」動作的邏輯需要不可變地更新 todo 陣列。讓 action 僅包含 todo ID 並在 reducer 中計算新陣列是合法的
// Click handler:
const onTodoClicked = (id) => {
dispatch({type: "todos/toggleTodo", payload: {id}})
}
// Reducer:
case "todos/toggleTodo": {
return state.map(todo => {
if(todo.id !== action.payload.id) return todo;
return {...todo, completed: !todo.completed };
})
}
也可以先計算新陣列,然後將整個新陣列放入 action
// Click handler:
const onTodoClicked = id => {
const newTodos = todos.map(todo => {
if (todo.id !== id) return todo
return { ...todo, completed: !todo.completed }
})
dispatch({ type: 'todos/toggleTodo', payload: { todos: newTodos } })
}
// Reducer:
case "todos/toggleTodo":
return action.payload.todos;
不過,在 reducer 中執行邏輯有幾個原因較佳
- Reducer 始終易於測試,因為它們是純函數 - 您只需呼叫
const result = reducer(testState, action)
,並斷言結果符合您的預期。因此,您可以在 reducer 中放入的邏輯越多,您擁有的易於測試的邏輯就越多。 - Redux 狀態更新必須始終遵循 不可變更新規則。大多數 Redux 使用者意識到他們必須在 reducer 內部遵循這些規則,但並不明顯的是,如果您在 reducer 外部 計算新狀態,您也必須這樣做。這很容易導致錯誤,例如意外變異,甚至從 Redux 儲存區讀取值並將其直接傳遞回 action 內部。在 reducer 中執行所有狀態計算可以避免這些錯誤。
- 如果您使用 Redux Toolkit 或 Immer,在 reducer 中撰寫不可變更新邏輯會容易得多,而 Immer 會凍結狀態並捕捉意外變異。
- 時光旅行除錯透過讓您「復原」已發送的動作,然後執行不同的動作或「重做」動作來運作。此外,reducer 的熱重載通常涉及使用現有動作重新執行新的 reducer。如果您有正確的動作但有錯誤的 reducer,您可以編輯 reducer 以修正錯誤,熱重載它,您應該會立即取得正確的狀態。如果動作本身有誤,您必須重新執行導致發送該動作的步驟。因此,如果在 reducer 中有更多邏輯,除錯會更容易。
- 最後,在 reducer 中放入邏輯表示您知道在哪裡尋找更新邏輯,而不是讓它分散在應用程式程式碼的其他隨機部分。
Reducer 應擁有狀態形狀
Redux 根狀態由單一根 reducer 函數擁有和計算。為了可維護性,該 reducer 旨在按 key/value「區段」分割,每個「區段 reducer」負責提供初始值並計算對該狀態區段的更新。
此外,切片簡化器應控制作為計算狀態一部分返回的其他值。盡量減少使用「盲目散布/傳回」,例如 `return action.payload` 或 `return {...state, ...action.payload}`,因為這些依賴於發送動作的程式碼正確格式化內容,而簡化器實際上放棄了對該狀態外觀的所有權。如果動作內容不正確,可能會導致錯誤。
注意:「散布傳回」簡化器可能是合理選擇的場景,例如編輯表單中的資料,為每個個別欄位撰寫一個單獨的動作類型會很耗時且效益不大。
詳細說明
想像一個「目前使用者」簡化器,如下所示const initialState = {
firstName: null,
lastName: null,
age: null,
};
export default usersReducer = (state = initialState, action) {
switch(action.type) {
case "users/userLoggedIn": {
return action.payload;
}
default: return state;
}
}
在此範例中,簡化器完全假設 `action.payload` 會是格式正確的物件。
然而,想像一下如果程式碼的某個部分要在動作中發送「待辦事項」物件,而不是「使用者」物件
dispatch({
type: 'users/userLoggedIn',
payload: {
id: 42,
text: 'Buy milk'
}
})
簡化器會盲目傳回待辦事項,而現在當應用程式嘗試從儲存區讀取使用者時,應用程式的其他部分可能會中斷。
如果簡化器有一些驗證檢查,以確保 `action.payload` 實際上具有正確的欄位,或嘗試按名稱讀取正確的欄位,則可以至少部分修正此問題。不過,這確實會增加更多程式碼,因此這是權衡更多程式碼與安全性的問題。
使用靜態型別確實會讓此類程式碼更安全且更可接受。如果簡化器知道 `action` 是 `PayloadAction<User>`,則執行 `return action.payload` 應該是安全的。
根據儲存資料命名狀態切片
如 簡化器應擁有狀態形狀 中所述,分割簡化器邏輯的標準方法基於狀態的「切片」。相應地,combineReducers
是將這些切片簡化器合併成較大簡化器函式的標準函式。
傳遞給 combineReducers
的物件中的金鑰名稱將定義結果狀態物件中金鑰的名稱。務必根據儲存在內部的資料來命名這些金鑰,並避免在金鑰名稱中使用「reducer」這個字。你的物件應看起來像 {users: {}, posts: {}}
,而不是 {usersReducer: {}, postsReducer: {}}
。
詳細說明
物件文字簡寫讓同時定義物件中的金鑰名稱和值變得容易const data = 42
const obj = { data }
// same as: {data: data}
combineReducers
接受一個充滿 reducer 函式的物件,並使用它來產生具有相同金鑰名稱的狀態物件。這表示函式物件中的金鑰名稱定義狀態物件中的金鑰名稱。
這導致一個常見的錯誤,其中 reducer 使用「reducer」作為變數名稱匯入,然後使用物件文字簡寫傳遞給 combineReducers
import usersReducer from 'features/users/usersSlice'
const rootReducer = combineReducers({
usersReducer
})
在這種情況下,使用物件文字簡寫會建立一個像 {usersReducer: usersReducer}
的物件。因此,「reducer」現在在狀態金鑰名稱中。這是多餘且無用的。
相反地,定義僅與內部資料相關的金鑰名稱。我們建議使用明確的 key: value
語法以提高清晰度
import usersReducer from 'features/users/usersSlice'
import postsReducer from 'features/posts/postsSlice'
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer
})
這需要多打一點字,但會產生最容易理解的程式碼和狀態定義。
根據資料類型,而非元件,組織狀態結構
根狀態區塊應根據應用程式中的主要資料類型或功能領域來定義和命名,而不是根據 UI 中的特定元件。這是因為 Redux 儲存體中的資料與 UI 中的元件之間沒有嚴格的 1:1 對應關係,而且許多元件可能需要存取相同的資料。將狀態樹視為一種任何應用程式部分都可以存取的全球資料庫,以讀取該元件中所需的狀態部分。
例如,部落格應用程式可能需要追蹤誰已登入、作者和文章的資訊,以及可能一些關於哪個畫面處於活動狀態的資訊。良好的狀態結構可能看起來像 {auth, posts, users, ui}
。一個不好的結構會像 {loginScreen, usersList, postsList}
。
將 reducer 視為狀態機器
許多 Redux reducer 被寫成「無條件」。它們只查看已發出的動作並計算一個新的狀態值,而不會根據當前狀態可能是什麼來為任何邏輯做基礎。這可能會導致錯誤,因為某些動作在概念上可能在某些時候「無效」,具體取決於應用程式邏輯的其餘部分。例如,「請求成功」動作應僅在狀態表示它已「載入」時才計算新值,或者僅在標記為「正在編輯」時才應發出「更新此項目」動作。
若要修正此問題,將 reducer 視為「狀態機」,其中當前狀態和傳送的動作的組合決定是否實際計算新的狀態值,而並非僅動作本身無條件地決定。
詳細說明
有限狀態機是一種有用的方式,用於建模在任何時間都應僅處於有限數量的「有限狀態」之一的事物。例如,如果您有 fetchUserReducer
,有限狀態可以是
「閒置」
(尚未開始擷取)「載入中」
(目前正在擷取使用者)「成功」
(已成功擷取使用者)「失敗」
(擷取使用者失敗)
若要讓這些有限狀態清楚且讓不可能的狀態不可能,您可以指定一個包含此有限狀態的屬性
const initialUserState = {
status: 'idle', // explicit finite state
user: null,
error: null
}
使用 TypeScript 時,這也讓您可以輕鬆使用辨別聯合來表示每個有限狀態。例如,如果 state.status === 'success'
,則您會預期 state.user
已定義,且不會預期 state.error
為真值。您可以使用類型來強制執行這一點。
通常,reducer 邏輯是透過首先考量動作來撰寫的。在使用狀態機建模邏輯時,重要的是要首先考量狀態。為每個狀態建立「有限狀態 reducer」有助於封裝每個狀態的行為
import {
FETCH_USER,
// ...
} from './actions'
const IDLE_STATUS = 'idle';
const LOADING_STATUS = 'loading';
const SUCCESS_STATUS = 'success';
const FAILURE_STATUS = 'failure';
const fetchIdleUserReducer = (state, action) => {
// state.status is "idle"
switch (action.type) {
case FETCH_USER:
return {
...state,
status: LOADING_STATUS
}
}
default:
return state;
}
}
// ... other reducers
const fetchUserReducer = (state, action) => {
switch (state.status) {
case IDLE_STATUS:
return fetchIdleUserReducer(state, action);
case LOADING_STATUS:
return fetchLoadingUserReducer(state, action);
case SUCCESS_STATUS:
return fetchSuccessUserReducer(state, action);
case FAILURE_STATUS:
return fetchFailureUserReducer(state, action);
default:
// this should never be reached
return state;
}
}
現在,由於您定義的是每個狀態的行為,而非每個動作的行為,您也可以防止不可能的轉換。例如,當 status === LOADING_STATUS
時,FETCH_USER
動作不應產生任何效果,您可以強制執行這一點,而不是意外地引入臨界狀況。
正規化複雜的巢狀/關係狀態
許多應用程式需要將複雜資料快取在儲存區中。這些資料通常從 API 以巢狀形式接收,或在資料中的不同實體之間具有關係(例如包含使用者、文章和留言的部落格)。
偏好將這些資料儲存在「正規化」形式中。這使得根據 ID 查詢項目和更新儲存區中的單一項目變得更容易,並最終導致更好的效能模式。
保持狀態最小化並推導其他值
只要有可能,將 Redux 儲存區中的實際資料保持在最小的程度,並根據需要推導其他值。這包括計算篩選清單或加總值等事項。舉例來說,待辦事項應用程式會在狀態中保留待辦事項物件的原始清單,但只要狀態更新,就會在狀態外部推導出篩選後的待辦事項清單。類似地,也可以在儲存區外部計算是否已完成所有待辦事項或剩餘待辦事項的數量。
這有幾個好處
- 實際狀態更容易閱讀
- 不需要太多邏輯來計算這些額外值,並讓它們與其他資料保持同步
- 原始狀態仍然存在,作為參考,並未被取代
導出資料通常在「選擇器」函數中完成,它可以封裝執行衍生資料計算的邏輯。為了提升效能,這些選擇器可以備忘,以快取先前的結果,使用像 reselect
和 proxy-memoize
這樣的函式庫。
將模型動作視為事件,而非設定器
Redux 不在乎 action.type
欄位的內容是什麼 - 它只需要被定義。用現在式 ("users/update"
)、過去式 ("users/updated"
)、描述為事件 ("upload/progress"
) 或視為「設定器」 ("users/setUserName"
) 來撰寫動作類型是合法的。由您決定特定動作在應用程式中的意義,以及您如何建模這些動作。
然而,我們建議嘗試將動作視為「描述已發生的事件」,而非「設定器」。將動作視為「事件」通常會產生更有意義的動作名稱、減少總共發送的動作,以及更有意義的動作記錄歷程。撰寫「設定器」通常會導致太多個別動作類型、太多發送,以及較不具意義的動作記錄。
詳細說明
想像您有一個餐廳應用程式,有人訂了一個披薩和一瓶可樂。您可以發送一個像這樣的動作{ type: "food/orderAdded", payload: {pizza: 1, coke: 1} }
或者您可以發送
{
type: "orders/setPizzasOrdered",
payload: {
amount: getState().orders.pizza + 1,
}
}
{
type: "orders/setCokesOrdered",
payload: {
amount: getState().orders.coke + 1,
}
}
第一個範例會是一個「事件」。「嘿,有人訂了一個披薩和一瓶汽水,想辦法處理」。
第二個範例是一個「設定器」。「我知道有『已訂購披薩』和『已訂購汽水』的欄位,我命令你將它們目前的數值設定為這些數字」。
「事件」方法實際上只需要發送一個動作,而且更靈活。已經訂購了多少披薩並不重要。也許沒有廚師可用,所以訂單被忽略了。
使用「設定器」方法,客戶端程式碼需要進一步了解狀態的實際結構、哪些應該是「正確」的數值,並最終必須發送多個動作才能完成「交易」。
撰寫有意義的動作名稱
action.type
欄位有兩個主要用途
- Reducer 邏輯會檢查 action 類型,以查看是否應處理此 action 以計算新狀態
- Redux DevTools 歷程記錄中會顯示 action 類型,供你閱讀
根據 將模型動作視為「事件」,type
欄位的實際內容對 Redux 本身並不重要。然而,type
值對你這個開發者來說很重要。動作應寫入有意義、具資訊性、描述性的類型欄位。理想情況下,你應該能夠讀取已發送的動作類型清單,並在不查看每個動作內容的情況下,對應用程式中發生的事情有很好的理解。避免使用非常通用的動作名稱,例如 "SET_DATA"
或 "UPDATE_STORE"
,因為它們沒有提供有關發生的事情的有意義資訊。
允許多個 Reducer 回應相同的動作
Redux reducer 邏輯旨在分割成許多較小的 reducer,每個 reducer 獨立更新狀態樹中的自己的部分,然後全部組合回一起形成根 reducer 函式。當發送特定動作時,它可能會由所有、某些或沒有 reducer 處理。
作為此的一部分,如果你可能的話,建議讓許多 reducer 函式都分別處理相同的動作。在實務上,經驗顯示大多數動作通常只由單一 reducer 函式處理,這很好。但是,將動作建模為「事件」並允許多個 reducer 回應這些動作通常會讓應用程式的程式碼庫有更好的擴充性,並將你執行多個動作以完成一個有意義的更新的次數減到最低。
避免連續發送多個動作
避免連續發送多個動作以完成較大的概念性「交易」。這在法律上是合法的,但通常會導致多次相對昂貴的 UI 更新,而某些中間狀態可能會因應用程式邏輯的其他部分而無效。優先發送單一「事件」類型動作,一次產生所有適當的狀態更新,或考慮使用動作批次附加元件,在最後只使用單一 UI 更新來發送多個動作。
詳細說明
你可以連續發送的動作數量沒有限制。然而,每個已發送的動作都會導致執行所有儲存訂閱回呼(通常每個 Redux 連接的 UI 元件一個或多個),並且通常會導致 UI 更新。儘管從 React 事件處理常式佇列的 UI 更新通常會批次處理成單一的 React 渲染傳遞,但在這些事件處理常式之外佇列的更新則不會。這包括大多數 async
函式、逾時回呼和非 React 程式碼的發送。在這些情況下,每個發送都會在發送完成之前產生一個完整的同步 React 渲染傳遞,這會降低效能。
此外,概念上屬於較大「交易」風格更新序列的多重發送,將會產生可能不被視為有效的中間狀態。例如,如果動作 "UPDATE_A"
、"UPDATE_B"
和 "UPDATE_C"
依序發送,且某些程式碼預期 a
、b
和 c
三者會同時更新,則前兩次發送後的狀態實際上會不完整,因為只更新了其中一或兩個。
如果確實需要多重發送,請考慮以某種方式批次處理更新。根據您的使用案例,這可能只是批次處理 React 本身的呈現(可能使用 batch()
from React-Redux),延遲儲存通知回呼,或將多個動作分組成一個較大的單一發送,只產生一個訂閱者通知。請參閱 「減少儲存更新事件」的常見問題解答項目,以取得其他範例和相關附加元件的連結。
評估每個狀態區塊應該存在的位置
「Redux 的三個原則」 指出「整個應用程式的狀態儲存在單一樹狀結構中」。這句話被過度詮釋了。它並非表示應用程式中的每個值都必須儲存在 Redux 儲存體中。相反地,應該有一個單一的地方可以找到您認為是全域且應用程式範圍的值。一般而言,應將「區域」值保留在最近的 UI 元件中。
因此,由您作為開發人員決定哪些狀態應實際存在於 Redux 儲存體中,以及哪些狀態應保留在元件狀態中。使用這些經驗法則來協助評估每個狀態區塊,並決定其應存在的位置。
使用 React-Redux Hooks API
建議使用 React-Redux hooks API(useSelector
和 useDispatch
) 作為與 React 元件互動 Redux 儲存庫的預設方式。儘管傳統的 connect
API 仍然運作良好,且將持續獲得支援,但 hooks API 在許多方面通常較容易使用。hooks 具有較少的間接層級、較少的撰寫程式碼,且比 connect
更容易與 TypeScript 搭配使用。
hooks API 的確在效能和資料流程方面引入了與 connect
不同的折衷方案,但我們現在建議將其作為預設值。
詳細說明
傳統的 connect
API 是 高階元件。它會產生一個新的包裝元件,訂閱儲存庫、呈現您自己的元件,並將儲存庫中的資料和動作建立函式傳遞為 props。
這是一個經過深思熟慮的間接層級,讓您可以撰寫「展示型」元件,這些元件會接收所有值作為 props,而不會特別依賴 Redux。
hooks 的引入改變了大多數 React 開發人員撰寫元件的方式。儘管「容器/展示型」概念仍然有效,但 hooks 會促使您撰寫元件,這些元件負責透過呼叫適當的 hook 來內部請求自己的資料。這會導致我們撰寫和測試元件及邏輯時採用不同的方法。
connect
的間接層級一直讓一些使用者難以追蹤資料流程。此外,由於多重超載、選用參數、合併來自 mapState
/ mapDispatch
/ 父元件的 props,以及動作建立函式和 thunk 的繫結,因此 connect
的複雜性使得使用 TypeScript 正確輸入資料變得非常困難。
useSelector
和 useDispatch
消除了間接層級,因此您的元件如何與 Redux 互動變得更加清楚。由於 useSelector
只接受單一選擇器,因此使用 TypeScript 定義它容易得多,useDispatch
也是如此。
如需更多詳細資訊,請參閱 Redux 維護者 Mark Erikson 的文章和會議演講,了解 hooks 和 HOC 之間的折衷方案
另請參閱 React-Redux hooks API 文件,以取得如何正確最佳化元件和處理罕見邊緣案例的資訊。
連接更多元件以從儲存庫中讀取資料
偏好有更多 UI 元件訂閱 Redux 儲存區,並在更精細的層級讀取資料。這通常會帶來更好的 UI 效能,因為當特定狀態變更時,需要重新渲染的元件會更少。
例如,與其只連接 <UserList>
元件並讀取所有使用者的陣列,讓 <UserList>
擷取所有使用者 ID 的清單,將清單項目渲染為 <UserListItem userId={userId}>
,並讓 <UserListItem>
連接並從儲存區中擷取其自己的使用者項目。
這適用於 React-Redux connect()
API 和 useSelector()
鉤子。
使用 connect
的 mapDispatch
的物件簡寫形式
connect
的 mapDispatch
參數可以定義為接收 dispatch
作為參數的函式,或包含動作建立器的物件。我們建議總是使用 mapDispatch
的「物件簡寫」形式,因為它可以大幅簡化程式碼。幾乎沒有實際需要將 mapDispatch
寫成函式。
在函式元件中多次呼叫 useSelector
使用 useSelector
鉤子擷取資料時,偏好多次呼叫 useSelector
並擷取較少量的資料,而不是使用單一較大的 useSelector
呼叫,在物件中傳回多個結果。與 mapState
不同,useSelector
不需要傳回物件,而且讓選擇器讀取較小的值表示特定狀態變更較不可能導致此元件重新渲染。
不過,請嘗試找到適當的精細度平衡。如果單一元件確實需要狀態區段中的所有欄位,請只寫一個傳回整個區段的 useSelector
,而不是為每個個別欄位寫入個別的選擇器。
使用靜態型別
使用 TypeScript 或 Flow 等靜態型別系統,而不是純粹的 JavaScript。型別系統會偵測到許多常見錯誤,改善程式碼的文件,並最終帶來更好的長期可維護性。雖然 Redux 和 React-Redux 最初是針對純粹的 JS 設計,但兩者都與 TS 和 Flow 搭配得很好。Redux Toolkit 特別以 TS 編寫,並設計為在最少額外的型別宣告下提供良好的型別安全性。
使用 Redux DevTools 擴充功能進行除錯
設定您的 Redux 儲存空間以啟用 使用 Redux DevTools 擴充功能進行除錯。它允許您查看
- 已發送動作的歷程記錄
- 每個動作的內容
- 發送動作後的最終狀態
- 發送動作後的狀態差異
- 顯示動作實際發送位置的程式碼函式堆疊追蹤
此外,DevTools 允許您進行「時光旅行除錯」,在動作歷程記錄中前後移動以查看不同時間點的整個應用程式狀態和 UI。
Redux 特別設計為啟用這種除錯,而 DevTools 是使用 Redux 最有力的原因之一.
將純 JavaScript 物件用於狀態
偏好使用純 JavaScript 物件和陣列作為您的狀態樹,而不是 Immutable.js 等專用函式庫。儘管使用 Immutable.js 有一些潛在的好處,但大多數常見的陳述目標(例如輕鬆的參考比較)是不可變更新的一般屬性,不需要特定的函式庫。這也可以縮小套件大小並降低資料類型轉換的複雜性。
如上所述,如果您想簡化不可變更新邏輯,我們特別建議使用 Immer,特別是作為 Redux Toolkit 的一部分。
詳細說明
自一開始,Immutable.js 就已經在 Redux 應用程式中被半頻繁地使用。使用 Immutable.js 有幾個常見的原因- 透過便宜的參考比較提升效能
- 透過專用資料結構進行更新提升效能
- 防止意外變異
- 透過
setIn()
等 API 進行更輕鬆的巢狀更新
這些理由有一些有效的方面,但實際上,好處不如所述,而且使用它有許多缺點
- 便宜的參考比較是任何不可變更新的屬性,不只是 Immutable.js
- 意外變異可以透過其他機制防止,例如使用 Immer(它消除了容易出錯的手動複製邏輯,並在開發中預設深度凍結狀態)或
redux-immutable-state-invariant
(它檢查狀態是否有變異) - Immer 整體允許更簡單的更新邏輯,消除了對
setIn()
的需求 - Immutable.js 有非常大的套件大小
- API 相當複雜
- API「感染」了您的應用程式程式碼。所有邏輯都必須知道它處理的是純 JS 物件還是 Immutable 物件
- 將不可變物件轉換為純粹的 JS 物件相對昂貴,而且總是會產生全新的深度物件參考
- 缺乏對程式庫的持續維護
使用 Immutable.js 最強而有力的理由是快速更新非常大的物件(數萬個鍵)。大多數應用程式不會處理這麼大的物件。
整體而言,Immutable.js 增加了太多開銷,但實際效益卻太少。Immer 是更好的選擇。
優先順序 C 規則:建議
將動作類型寫成 domain/eventName
原始 Redux 文件和範例通常使用「SCREAMING_SNAKE_CASE」慣例來定義動作類型,例如 "ADD_TODO"
和 "INCREMENT"
。這符合大多數程式語言中宣告常數值的典型慣例。缺點是,大寫字串可能難以閱讀。
其他社群採用了其他慣例,通常會指出動作相關的「功能」或「網域」,以及特定的動作類型。NgRx 社群通常使用 "[Domain] Action Type"
這樣的模式,例如 "[Login Page] Login"
。其他模式,例如 "domain:action"
也已使用。
Redux Toolkit 的 createSlice
函式目前會產生看起來像 "domain/action"
的動作類型,例如 "todos/addTodo"
。未來,我們建議使用 "domain/action"
慣例以提高可讀性。
使用 Flux 標準動作慣例撰寫動作
原始「Flux 架構」文件僅指定動作物件應具有 type
欄位,並未進一步說明動作中欄位的類型或命名慣例。為了提供一致性,Andrew Clark 在 Redux 開發初期建立了一個稱為 "Flux 標準動作" 的慣例。簡而言之,FSA 慣例表示動作
- 應始終將其資料放入
payload
欄位 - 可以有一個
meta
欄位,用於提供其他資訊 - 可以有一個
error
欄位,用於表示動作代表某種失敗
Redux 生態系統中的許多程式庫都採用了 FSA 慣例,而 Redux Toolkit 會產生符合 FSA 格式的動作建立器。
為了保持一致性,建議使用 FSA 格式的動作.
注意:FSA 規範指出「錯誤」動作應設定
error: true
,並使用與動作「有效」形式相同的動作類型。實際上,大多數開發人員會為「成功」和「錯誤」案例撰寫不同的動作類型。兩者皆可接受。
使用動作建立器
「動作建立器」函式從原始「Flux 架構」方法開始使用。使用 Redux 時,動作建立器並非絕對必要。元件和其他邏輯始終可以呼叫 dispatch({type: "some/action"})
,其中動作物件內嵌撰寫。
但是,使用動作建立器可提供一致性,特別是在需要某種準備或額外邏輯來填寫動作內容(例如產生唯一 ID)的情況下。
建議使用動作建立器來發送任何動作。但是,我們建議使用 Redux Toolkit 中的 createSlice
函式,而不是手動撰寫動作建立器,它會自動產生動作建立器和動作類型。
使用 RTK Query 進行資料擷取
實際上,典型 Redux 應用程式中副作用最常見的單一使用案例是從伺服器擷取和快取資料。
因此,我們建議將 RTK Query 作為 Redux 應用程式中資料擷取和快取的預設方法。RTK Query 已設計為正確管理從伺服器擷取資料、快取資料、取消重複要求、更新元件等邏輯。我們建議在幾乎所有情況下都不要手動撰寫資料擷取邏輯。
使用 Thunks 和 Listeners 進行其他非同步邏輯
Redux 被設計為可擴充,而中間件 API 是專門建立的,以允許將不同形式的非同步邏輯插入 Redux 儲存。這樣,使用者就不會被迫學習特定的函式庫(例如 RxJS),如果它不適合自己的需求。
這導致建立了各種 Redux 非同步中間件外掛程式,進而造成混淆和疑問,不知道應該使用哪個非同步中間件。
我們建議使用 Redux thunk 中間件 進行命令式邏輯,例如需要存取 dispatch
或 getState
的複雜同步邏輯,以及中等複雜度的非同步邏輯。這包括將邏輯移出元件等使用案例。
我們建議使用 RTK「監聽器」中介軟體 來處理需要回應已發送動作或狀態變更的「反應式」邏輯,例如較長執行的非同步工作流程和「背景執行緒」類型行為。
我們建議在多數情況下不要使用較複雜的 Redux-Saga 和 Redux-Observable 函式庫,特別是對於非同步資料擷取。只有在沒有其他工具足夠強大來處理你的使用案例時,才使用這些函式庫。
將複雜邏輯移出元件
我們傳統上建議盡可能將邏輯保留在元件外部。這部分是為了鼓勵使用「容器/展示」模式,其中許多元件僅接受資料作為道具並相應地顯示使用者介面,但也是因為在類別元件生命週期方法中處理非同步邏輯可能會難以維護。
我們仍然鼓勵將複雜的同步或非同步邏輯移出元件,通常是移到 thunk 中。如果邏輯需要從儲存狀態中讀取,這一點尤其正確。
然而,使用 React 勾子確實讓直接在元件內管理資料擷取等邏輯變得更容易,在某些情況下,這可能會取代對 thunk 的需求。
使用選擇器函式從儲存狀態中讀取
「選擇器函式」是封裝從 Redux 儲存狀態讀取值並從這些值衍生進一步資料的強大工具。此外,像 Reselect 這樣的函式庫可以建立快取選擇器函式,這些函式僅在輸入已變更時重新計算結果,這是最佳化效能的重要面向。
我們強烈建議盡可能使用快取選擇器函式來讀取儲存狀態,並建議使用 Reselect 建立這些選擇器。
但是,不要覺得你必須為狀態中的每個欄位撰寫選擇器函式。根據欄位存取和更新的頻率,以及選擇器在應用程式中提供的實際效益,找到合理的分辨率平衡。
將選擇器函式命名為 selectThing
我們建議選擇器函式名稱加上 select
字首,並結合所選取值的說明。範例包括 selectTodos
、selectVisibleTodos
和 selectTodoById
。
避免將表單狀態放入 Redux
大多數表單狀態不應放入 Redux。在多數使用案例中,資料並非真正全域性,不會快取,也不會同時由多個元件使用。此外,將表單連接到 Redux 通常會在每次變更事件時發送動作,這會造成效能負擔,且沒有帶來實際好處。(您可能不需要從 name: "Mark"
回溯一個字元到 name: "Mar"
。)
即使資料最終會進入 Redux,也建議將表單編輯本身保留在區域元件狀態中,並只在使用者完成表單後才發送動作以更新 Redux 儲存庫。
在某些使用案例中,將表單狀態保留在 Redux 中確實有其道理,例如所見即所得的已編輯項目屬性即時預覽。但是,在多數情況下,這並非必要。