副作用方法
- 「副作用」是什麼,以及它們如何融入 Redux
- 使用 Redux 管理副作用的常見工具
- 針對不同使用案例,我們推薦使用哪些工具
Redux 和副作用
副作用概觀
Redux store 本身不了解任何非同步邏輯。它只知道如何同步發送動作、透過呼叫根部 reducer 函式更新狀態,並通知 UI 有東西變更。任何非同步都必須在 store 外部發生。
Redux reducer 永遠不應包含「副作用」。「副作用」是指在函式傳回值之外可見的任何狀態或行為變更。一些常見的副作用類型包括
- 將值記錄到主控台
- 儲存檔案
- 設定非同步計時器
- 發出 AJAX HTTP 要求
- 修改函式外部存在的某些狀態,或變異函式的引數
- 產生亂數或唯一的亂數 ID(例如
Math.random()
或Date.now()
)
然而,任何實際的應用程式都需要在某個地方執行這些類型的動作。因此,如果我們無法將副作用放入 reducer,我們可以將它們放在哪裡?
中間件和副作用
Redux 中間件的設計目的是啟用撰寫具有副作用的邏輯.
當 Redux 中間件看到發送的動作時,它可以執行任何動作:記錄某些內容、修改動作、延遲動作、發出非同步呼叫等。此外,由於中間件在真正的 store.dispatch
函式周圍形成一個管線,這也表示我們實際上可以將不是純粹動作物件的東西傳遞給 dispatch
,只要有中間件攔截該值並讓它無法到達 reducer 即可。
中間件也可以存取 dispatch
和 getState
。這表示您可以在中間件中撰寫一些非同步邏輯,並仍然能夠透過發送動作與 Redux store 互動。
因此,Redux 副作用和非同步邏輯通常透過中間件實作。
副作用使用案例
在實務上,在典型的 Redux 應用程式中,副作用最常見的單一使用案例是從伺服器擷取和快取資料。
更特定於 Redux 的另一個使用案例是撰寫邏輯,透過執行額外的邏輯(例如調度更多動作)來回應已調度的動作或狀態變更。
建議
我們建議使用最適合每個使用案例的工具(請參閱下方我們的建議原因,以及每個工具的更多詳細資訊)
為何使用 RTK Query 進行資料擷取
根據 React 文件中「在 Effects 中進行資料擷取的替代方案」,你應該使用內建於伺服器端架構的資料擷取方法,或使用客戶端快取。你不應該自行撰寫資料擷取和快取管理程式碼。
RTK Query 專門設計為基於 Redux 的應用程式的完整資料擷取和快取層。它會為你管理所有擷取、快取和載入狀態邏輯,並涵蓋許多如果你自行撰寫資料擷取程式碼時通常會遺忘或難以處理的邊緣案例,以及內建快取生命週期管理。它也讓你可以透過自動產生的 React hook 輕鬆擷取和使用資料。
我們特別建議不要將 saga 用於資料擷取,因為 saga 的複雜性沒有幫助,而且你仍然必須自行撰寫所有快取 + 載入狀態管理邏輯。
為何使用監聽器進行反應式邏輯
我們刻意設計 RTK 監聽器中介軟體,讓它易於使用。它使用標準的 async/await
語法,涵蓋大多數常見的反應式使用案例(回應動作或狀態變更、防抖動、延遲),甚至還有幾個進階案例(啟動子任務)。它的套件大小很小(約 3K),包含在 Redux Toolkit 中,而且非常適合與 TypeScript 搭配使用。
我們特別建議不要對大多數反應式邏輯使用 saga 或可觀察物件,原因如下
- Saga:需要了解產生器函數語法以及 saga 效果行為;由於需要發送額外的動作,因此增加了多層間接性;對 TypeScript 的支援不佳;而且大多數 Redux 使用案例根本不需要這種強大且複雜的功能。
- 可觀察物件:需要了解 RxJS API 和心智模型;可能很難除錯;可能會增加大量的套件大小
常見的副作用方法
使用 Redux 管理副作用的最低層級技術是撰寫您自己的自訂中介軟體,用來偵聽特定動作並執行邏輯。不過,這種方法很少使用。相反地,大多數應用程式過去都使用生態系統中可用的常見預先建置的 Redux 副作用中介軟體:thunk、saga 或可觀察物件。每種方法都有其不同的使用案例和權衡取捨。
最近,我們的官方 Redux Toolkit 套件新增了兩個用於管理副作用的新 API:「監聽器」中介軟體,用於撰寫反應式邏輯,以及 RTK Query,用於擷取和快取伺服器狀態。
Thunk
Redux「thunk」中介軟體 傳統上一直是撰寫非同步邏輯最廣泛使用的中介軟體。
Thunk 的運作方式是將函數傳遞到 dispatch
中。Thunk 中介軟體會攔截函數,呼叫它,並傳入 theThunkFunction(dispatch, getState)
。Thunk 函數現在可以執行任何同步/非同步邏輯並與儲存體互動。
Thunk 使用案例
Thunk 最適合用於需要存取 dispatch
和 getState
的複雜同步邏輯,或中等非同步邏輯,例如一次性的「擷取一些非同步資料並使用結果發送動作」要求。
我們傳統上建議將 thunk 作為預設方法,Redux Toolkit 特別包含 createAsyncThunk
API 以供「請求和發送」使用案例。對於其他使用案例,您可以撰寫自己的 thunk 函數。
Thunk 權衡
- 👍:只需撰寫函數;可以包含任何邏輯
- 👎:無法回應已發送的動作;命令式;無法取消
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
// Original "hand-written" thunk fetch request pattern
const fetchUserById = userId => {
return async (dispatch, getState) => {
// Dispatch "pending" action to help track loading state
dispatch(fetchUserStarted())
// Need to pull this out to have correct error handling
let lastAction
try {
const user = await userApi.getUserById(userId)
// Dispatch "fulfilled" action on success
lastAction = fetchUserSucceeded(user)
} catch (err) {
// Dispatch "rejected" action on failure
lastAction = fetchUserFailed(err.message)
}
dispatch(lastAction)
}
}
// Similar request with `createAsyncThunk`
const fetchUserById2 = createAsyncThunk('fetchUserById', async userId => {
const user = await userApi.getUserById(userId)
return user
})
Sagas
Redux-Saga 中介軟體 傳統上是僅次於 thunk 的第二個最常見副作用工具。它受到後端「saga」模式的啟發,在該模式中,長時間運作的工作流程可以回應系統中觸發的事件。
在概念上,您可以將 sagas 視為 Redux 應用程式內的「背景執行緒」,它有能力偵聽已發送的動作並執行額外的邏輯。
Sagas 是使用產生器函數編寫的。Saga 函數會傳回副作用的描述並暫停它們,而 saga 中介軟體負責執行副作用並以結果繼續 saga 函數。redux-saga
函式庫包含各種效果定義,例如
call
:執行非同步函數,並在承諾解決時傳回結果put
:發送 Redux 動作fork
:產生「子 saga」,就像可以執行更多工作的額外執行緒takeLatest
:偵聽給定的 Redux 動作,觸發 saga 函數執行,如果再次發送,則取消 saga 的先前執行副本
Saga 使用案例
Sagas 非常強大,最適合用於需要「背景執行緒」類型行為或防呆/取消的高度複雜非同步工作流程。
Saga 使用者經常指出,saga 函數僅傳回所需效果的描述,這是讓它們更易於測試的主要優點。
Saga 權衡
- 👍:Sagas 可測試,因為它們僅傳回效果的描述;強大的效果模型;暫停/取消功能
- 👎:產生器函數很複雜;獨特的 saga 效果 API;saga 測試通常只測試實作結果,並且需要在每次觸及 saga 時重新撰寫,這讓它們的價值降低許多;無法與 TypeScript 搭配使用;
import { call, put, takeEvery } from 'redux-saga/effects'
// "Worker" saga: will be fired on USER_FETCH_REQUESTED actions
function* fetchUser(action) {
yield put(fetchUserStarted())
try {
const user = yield call(userApi.getUserById, action.payload.userId)
yield put(fetchUserSucceeded(user))
} catch (err) {
yield put(fetchUserFailed(err.message))
}
}
// "Watcher" saga: starts fetchUser on each `USER_FETCH_REQUESTED` action
function* fetchUserWatcher() {
yield takeEvery('USER_FETCH_REQUESTED', fetchUser)
}
// Can use also use sagas for complex async workflows with "child tasks":
function* fetchAll() {
const task1 = yield fork(fetchResource, 'users')
const task2 = yield fork(fetchResource, 'comments')
yield delay(1000)
}
function* fetchResource(resource) {
const { data } = yield call(api.fetch, resource)
yield put(receiveData(data))
}
可觀察物件
Redux-Observable 中介軟體 讓您可以使用 RxJS 可觀察物件建立稱為「史詩」的處理管線。
由於 RxJS 是與框架無關的函式庫,可觀察使用者指出,你可以重複使用知識,了解如何在不同平台上使用它,作為一個主要的賣點。此外,RxJS 讓你建構宣告式管線,處理取消或防呆等計時案例。
可觀察使用案例
與 sagas 類似,可觀察功能強大,最適合用於需要「背景執行緒」類型行為或防呆/取消的高度複雜非同步工作流程。
可觀察折衷
- 👍:可觀察是功能強大的資料流模型;RxJS 知識可以獨立於 Redux 使用;宣告式語法
- 👎:RxJS API 很複雜;心智模型;可能很難除錯;套件大小
// Typical AJAX example:
const fetchUserEpic = action$ =>
action$.pipe(
filter(fetchUser.match),
mergeMap(action =>
ajax
.getJSON(`https://api.github.com/users/${action.payload}`)
.pipe(map(response => fetchUserFulfilled(response)))
)
)
// Can write highly complex async pipelines, including delays,
// cancellation, debouncing, and error handling:
const fetchReposEpic = action$ =>
action$.pipe(
filter(fetchReposInput.match),
debounceTime(300),
switchMap(action =>
of(fetchReposStart()).pipe(
concat(
searchRepos(action.payload).pipe(
map(payload => fetchReposSuccess(payload.items)),
catchError(error => of(fetchReposError(error)))
)
)
)
)
)
監聽器
Redux Toolkit 包含 createListenerMiddleware
API 來處理「反應式」邏輯。它特別旨在成為 sagas 和可觀察的較輕量級替代方案,處理 90% 的相同使用案例,具有較小的套件大小、更簡單的 API 和更好的 TypeScript 支援。
在概念上,這類似於 React 的 useEffect
勾子,但適用於 Redux store 更新。
監聽器中介軟體讓你新增與動作相符的項目,以確定何時執行 effect
回呼。與 thunk 類似,effect
回呼可以是同步或非同步,並且可以存取 dispatch
和 getState
。它們還接收一個 listenerApi
物件,其中包含幾個用於建構非同步工作流程的原語,例如
condition()
:暫停,直到發送特定動作或發生狀態變更cancelActiveListeners()
:取消效果的現有進行中執行個體fork()
:建立可以執行額外工作的「子任務」
這些原語允許監聽器複製 Redux-Saga 中幾乎所有效果的行為。
監聽器使用案例
監聽器可用於各種任務,例如輕量級儲存持久性、在發送動作時觸發額外邏輯、觀察狀態變更,以及複雜的長期「背景執行緒」樣式非同步工作流程。
此外,可以在執行階段透過發送特殊的 add/removeListener
動作,動態地新增和移除監聽器項目。這與 React 的 useEffect
勾子整合得很好,可用於新增與組件生命週期相應的其他行為。
監聽器權衡
- 👍:內建於 Redux Toolkit;
async/await
是更熟悉的語法;類似於 thunk;輕量級的概念和大小;與 TypeScript 搭配使用效果極佳 - 👎:相對較新,且尚未經過「實戰測試」;靈活性不如 saga/可觀察物件
// Create the middleware instance and methods
const listenerMiddleware = createListenerMiddleware()
// Add one or more listener entries that look for specific actions.
// They may contain any sync or async logic, similar to thunks.
listenerMiddleware.startListening({
actionCreator: todoAdded,
effect: async (action, listenerApi) => {
// Run whatever additional side-effect-y logic you want here
console.log('Todo added: ', action.payload.text)
// Can cancel other running instances
listenerApi.cancelActiveListeners()
// Run async logic
const data = await fetchData()
// Use the listener API methods to dispatch, get state,
// unsubscribe the listener, start child tasks, and more
listenerApi.dispatch(todoAdded('Buy pet food'))
}
})
listenerMiddleware.startListening({
// Can match against actions _or_ state changes/contents
predicate: (action, currentState, previousState) => {
return currentState.counter.value !== previousState.counter.value
},
// Listeners can have long-running async workflows
effect: async (action, listenerApi) => {
// Pause until action dispatched or state changed
if (await listenerApi.condition(matchSomeAction)) {
// Spawn "child tasks" that can do more work and return results
const task = listenerApi.fork(async forkApi => {
// Can pause execution
await forkApi.delay(5)
// Complete the child by returning a value
return 42
})
// Unwrap the child result in the listener
const result = await task.result
if (result.status === 'ok') {
console.log('Child succeeded: ', result.value)
}
}
}
})
RTK Query
Redux Toolkit 包含 RTK Query,這是一個專為 Redux 應用程式打造的資料擷取和快取解決方案。它旨在簡化網頁應用程式中載入資料的常見案例,無需自行手動撰寫資料擷取和快取邏輯。
RTK Query 仰賴建立一個由許多「端點」組成的 API 定義。端點可以是擷取資料的「查詢」,或是傳送更新至伺服器的「變異」。RTKQ 內部管理資料的擷取和快取,包括追蹤每個快取項目的使用情況,以及移除不再需要的快取資料。它具有一個獨特的「標籤」系統,用於在變異更新伺服器上的狀態時觸發資料的自動重新擷取。
與 Redux 的其他部分一樣,RTKQ 的核心是與 UI 無關的,且可用於任何 UI 架構。然而,它也內建了 React 整合,並可自動為每個端點產生 React 勾子。這為從 React 組件擷取和更新資料提供了一個熟悉且簡單的 API。
RTKQ 提供了一個開箱即用的「擷取」為基礎的實作,並與 REST API 搭配使用效果極佳。它也足夠靈活,可用於 GraphQL API,甚至可以設定為與任意非同步函數搭配使用,允許與外部 SDK(例如 Firebase、Supabase 或您自己的非同步邏輯)整合。
RTKQ 也有強大的功能,例如端點「生命週期方法」,讓您可以在新增和移除快取項目時執行邏輯。這可用於各種場景,例如擷取聊天室的初始資料,然後訂閱一個用於更新快取的額外訊息的 socket。
RTK Query 使用案例
RTK Query 專門建置來解決伺服器狀態的資料擷取和快取使用案例。
RTK Query 的折衷
- 👍:內建於 RTK;無需撰寫 任何 程式碼(thunk、selector、effect、reducer)來管理資料擷取和載入狀態;與 TS 完美搭配;整合至 Redux store 的其餘部分;內建 React hooks
- 👎:刻意採用「文件」式快取,而非「正規化」;增加一次性的額外套件大小成本
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'
// Create an API definition using a base URL and expected endpoints
export const api = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query<Pokemon, string>({
query: name => `pokemon/${name}`
}),
getPosts: builder.query<Post[], void>({
query: () => '/posts'
}),
addNewPost: builder.mutation<void, Post>({
query: initialPost => ({
url: '/posts',
method: 'POST',
// Include the entire post object as the body of the request
body: initialPost
})
})
})
})
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = api
export default function App() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// render UI based on data and loading state
}
其他方法
自訂中介軟體
由於 thunk、saga、observable 和 listener 都是 Redux 中介軟體的形式(且 RTK Query 包含其自訂中介軟體),因此如果這些工具均無法充分處理您的使用案例,您始終可以撰寫自訂中介軟體。
請注意,我們特別建議 不要 嘗試使用自訂中介軟體作為管理應用程式大部分邏輯的技術!有些使用者曾嘗試建立數十個自訂中介軟體,每個針對特定應用程式功能。這會增加顯著的負擔,因為每個中介軟體都必須作為 dispatch
的每個呼叫的一部分執行。建議改用一般用途的中介軟體,例如 thunk 或 listener,其中新增單一中介軟體實例即可處理許多不同的邏輯區塊。
const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}
return next(action)
}
WebSocket
許多應用程式使用 WebSocket 或其他形式的持續連線,主要是接收來自伺服器的串流更新。
我們通常建議 Redux 應用程式中大部分的 WebSocket 使用都應存在於自訂中介軟體中,原因有幾個
- 中介軟體存在於應用程式的生命週期中
- 與 store 本身一樣,您可能只需要的單一連線實例,整個應用程式都可以使用
- 中介軟體可以看到所有已發送的動作,並自行發送動作。這表示中介軟體可以取得已發送的動作,並將其轉換為透過 WebSocket 傳送的訊息,並在透過 WebSocket 收到訊息時發送新的動作。
- websocket 連線實例不可序列化,因此 不應放入儲存狀態本身
根據應用程式的需求,您可以在中介軟體初始化程序中建立 socket,在中介軟體中透過發送初始化動作依需求建立 socket,或在個別模組檔案中建立 socket,以便在其他地方存取。
Websocket 也可用於 RTK Query 生命週期回呼,它們可以透過套用 RTKQ 快取更新來回應訊息。
XState
狀態機對於定義系統可能的已知狀態以及各狀態之間可能的轉換非常有用,還可以在轉換發生時觸發副作用。
Redux 減速器可以寫成真正的有限狀態機,但 RTK 沒有包含任何有助於此的內容。實際上,它們往往是部分狀態機,真正關心的是已發送的動作,以決定如何更新狀態。監聽器、saga 和可觀察對象可用於「在發送後執行副作用」方面,但有時需要更多工作才能確保副作用只在特定時間執行。
XState 是定義真實狀態機並執行它們的強大函式庫,包括根據事件管理狀態轉換和觸發相關副作用。它還有相關工具,可透過圖形編輯器建立狀態機定義,然後載入 XState 邏輯中執行。
雖然目前 XState 和 Redux 之間沒有正式整合,但可以使用 XState 狀態機作為 Redux 減速器,而 XState 開發人員已建立一個有用的 POC,展示如何使用 XState 作為 Redux 副作用中介軟體
進一步資訊
- 簡報:Redux 非同步邏輯的演進
- 中介軟體和副作用的原因
- 文件和教學課程
- 文章和比較