Redux Logic > Thunks: writing logic that interacts with the store">Redux Logic > Thunks: writing logic that interacts with the store">
跳至主要內容

使用 Thunk 編寫邏輯

您將學到什麼
  • 「thunk」是什麼,以及它們為何用於編寫 Redux 邏輯
  • thunk 中間件如何運作
  • 在 thunk 中撰寫同步和非同步邏輯的技巧
  • 常見 thunk 使用模式

Thunk 概述

什麼是「thunk」?

「thunk」一詞是一個程式設計術語,意指 「一段執行延遲工作的程式碼」。我們可以撰寫函式主體或程式碼,用於在稍後執行工作,而不是現在執行一些邏輯。

特別針對 Redux,「thunk」是一種撰寫函式的模式,函式內部具有邏輯,可以與 Redux 儲存庫的 dispatchgetState 方法互動

使用 thunk 需要將 redux-thunk 中介軟體新增至 Redux 儲存庫,作為其組態的一部分。

Thunk 是 在 Redux 應用程式中撰寫非同步邏輯的標準方法,通常用於資料擷取。然而,它們可以用於各種任務,且可以包含同步和非同步邏輯。

撰寫 Thunk

thunk 函式是一個接受兩個參數的函式:Redux 儲存庫 dispatch 方法和 Redux 儲存庫 getState 方法。thunk 函式不會由應用程式程式碼直接呼叫。相反地,它們會傳遞給 store.dispatch()

傳送 thunk 函式
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}

store.dispatch(thunkFunction)

thunk 函式可能包含任何任意邏輯,同步或非同步,且可以隨時呼叫 dispatchgetState

與 Redux 程式碼通常使用 動作建立器產生動作物件以進行傳送,而不是手動撰寫動作物件的方式相同,我們通常使用thunk 動作建立器產生傳送的 thunk 函式。thunk 動作建立器是一個函式,可能有一些參數,並傳回新的 thunk 函式。thunk 通常會封閉傳遞給動作建立器的任何參數,因此它們可以用於邏輯

Thunk動作建立器和 thunk 函式
// fetchTodoById is the "thunk action creator"
export function fetchTodoById(todoId) {
// fetchTodoByIdThunk is the "thunk function"
return async function fetchTodoByIdThunk(dispatch, getState) {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
}

Thunk 函式和動作建立器可以使用 function 關鍵字或箭頭函式撰寫 - 在這裡沒有有意義的差異。相同的 fetchTodoById thunk 也可以使用箭頭函式撰寫,如下所示

使用箭頭函式撰寫 thunk
export const fetchTodoById = todoId => async dispatch => {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}

在任何情況下,thunk 都會透過呼叫動作建立器來傳送,就像傳送任何其他 Redux 動作一樣

function TodoComponent({ todoId }) {
const dispatch = useDispatch()

const onFetchClicked = () => {
// Calls the thunk action creator, and passes the thunk function to dispatch
dispatch(fetchTodoById(todoId))
}
}

為什麼要使用 Thunk?

Thunk 讓我們可以撰寫與 UI 層分開的額外 Redux 相關邏輯。此邏輯可以包含副作用,例如非同步要求或產生隨機值,以及需要傳送多個動作或存取 Redux 儲存庫狀態的邏輯。

Redux 減速器 不得包含副作用,但實際應用程式需要具有副作用的邏輯。其中一些可能存在於元件內部,但有些可能需要存在於 UI 層外部。Thunk(和其他 Redux 中介軟體)為我們提供了一個放置這些副作用的地方。

直接在元件中放置邏輯很常見,例如在按一下處理常式或 useEffect 鉤子中提出非同步要求,然後處理結果。然而,通常需要將儘可能多的邏輯移出 UI 層。這可能是為了提高邏輯的可測試性,讓 UI 層盡可能精簡且「呈現」,或改善程式碼重複使用和共用。

從某種意義上來說,thunk 是一個漏洞,您可以在其中撰寫任何需要與 Redux 儲存庫互動的程式碼,而且不需要事先知道將使用哪個 Redux 儲存庫。這可以防止邏輯與任何特定 Redux 儲存庫實例繫結,並保持其可重複使用性。

詳細說明:Thunk、Connect 和「容器元件」

過去,使用 Thunk 的另一個原因是,有助於讓 React 元件「不認識 Redux」。connect API 允許傳遞動作建立器,並將它們「繫結」到動作呼叫時自動派送動作。由於元件通常無法在內部存取 dispatch,因此將 Thunk 傳遞到 connect,讓元件可以呼叫 this.props.doSomething(),而不需要知道它是來自父項目的回呼、派送純粹的 Redux 動作、派送執行同步或非同步邏輯的 Thunk,或測試中的模擬函式。

隨著 React-Redux Hook API 的出現,這種情況已經改變。社群普遍不再使用「容器/展示」模式,而且 元件現在可以透過 useDispatch Hook 直接存取 dispatch。這確實表示可以在元件內部直接擁有更多邏輯,例如非同步擷取 + 派送結果。然而,Thunk 可以存取 getState,而元件不能,因此將該邏輯移出元件外仍然有其價值。

Thunk 使用案例

由於 Thunk 是可以包含任意邏輯的通用工具,因此可以廣泛用於各種用途。最常見的使用案例是

  • 將複雜的邏輯移出元件
  • 進行非同步要求或其他非同步邏輯
  • 撰寫需要連續或隨著時間推移派送多個動作的邏輯
  • 撰寫需要存取 getState 來進行決策或在動作中包含其他狀態值的邏輯

Thunk 是「一次性」函式,沒有生命週期的概念。它們也看不到其他已派送的動作。因此,通常不應將它們用於初始化持續連線(例如 Websocket),而且無法使用它們來回應其他動作。

Thunk 最適合用於複雜的同步邏輯,以及簡單到中等的非同步邏輯,例如進行標準 AJAX 要求,並根據要求結果派送動作。

Redux Thunk 中介軟體

要派送 thunk 函數,需要將 redux-thunk 中介軟體 加入 Redux 儲存庫的設定中。

加入中介軟體

Redux Toolkit configureStore API 會在建立儲存庫時自動加入 thunk 中介軟體,因此通常不需要額外設定即可使用。

如果您需要手動將 thunk 中介軟體加入儲存庫,可以 在設定過程中將 thunk 中介軟體傳遞給 applyMiddleware()

中介軟體如何運作?

首先,讓我們回顧 Redux 中介軟體的一般運作方式。

Redux 中介軟體都是以一系列 3 個巢狀函數寫成:

  • 最外層函數接收一個包含 {dispatch, getState} 的「儲存庫 API」物件
  • 中間函數接收鏈中的「下一個」中介軟體(或實際的 store.dispatch 方法)
  • 最內層函數會在每個 action 通過中介軟體鏈時被呼叫

重要的是要注意,中介軟體可用於允許傳遞不是 action 物件的值進入 store.dispatch(),只要中介軟體攔截這些值並不要讓它們到達 reducer 即可。

記住這一點,我們可以進一步了解 thunk 中介軟體的具體運作方式。

thunk 中介軟體的實際實作非常簡短,只有大約 10 行。以下是原始碼,並加上額外的註解

Redux thunk 中介軟體實作,附註解
// standard middleware definition, with 3 nested functions:
// 1) Accepts `{dispatch, getState}`
// 2) Accepts `next`
// 3) Accepts `action`
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(dispatch, getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

換句話說

  • 如果您將一個函數傳遞給 dispatch,thunk 中介軟體會發現它是一個函數而不是一個 action 物件,攔截它,並以 (dispatch, getState) 作為其引數呼叫該函數
  • 如果它是一個正常的 action 物件(或其他任何東西),它會被轉發到鏈中的下一個中介軟體

將設定值注入 thunk

thunk 中介軟體有一個自訂選項。您可以在設定時建立一個 thunk 中介軟體的自訂實例,並將「額外引數」注入中介軟體。然後,中介軟體會將該額外值注入每個 thunk 函數的第三個引數。這最常被用於將 API 服務層注入 thunk 函數,以便它們不會對 API 方法有硬編碼的依賴性

帶有額外引數的 thunk 設定
import thunkMiddleware from 'redux-thunk'

const serviceApi = createServiceApi('/some/url')

const thunkMiddlewareWithArg = thunkMiddleware.withExtraArgument({ serviceApi })

Redux Toolkit 的 configureStore 支援 這作為其在 getDefaultMiddleware 中自訂中間件的一部分

使用 configureStore 的 Thunk 額外參數
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { serviceApi }
}
})
})

只有一個額外參數值。如果您需要傳遞多個值,請傳遞包含這些值的物件。

Thunk 函式會將該額外值作為其第三個參數接收

帶有額外參數的 Thunk 函式
export const fetchTodoById =
todoId => async (dispatch, getState, extraArgument) => {
// In this example, the extra arg is an object with an API service inside
const { serviceApi } = extraArgument
const response = await serviceApi.getTodo(todoId)
dispatch(todosLoaded(response.todos))
}

Thunk 使用模式

傳送動作

Thunk 可以存取 dispatch 方法。這可以用於傳送動作,甚至是其他 Thunk。這對於連續傳送多個動作很有用(儘管 這是一個應該盡量減少的模式),或編排需要在過程中多個點傳送的複雜邏輯。

範例:傳送動作和 Thunk 的 Thunk
// An example of a thunk dispatching other action creators,
// which may or may not be thunks themselves. No async code, just
// orchestration of higher-level synchronous logic.
function complexSynchronousThunk(someValue) {
return (dispatch, getState) => {
dispatch(someBasicActionCreator(someValue))
dispatch(someThunkActionCreator())
}
}

存取狀態

與元件不同,Thunk 也可以存取 getState。這可以在任何時候呼叫以擷取目前的根 Redux 狀態值。這對於根據目前的狀態執行條件式邏輯很有用。在 Thunk 內部讀取狀態時,使用選擇器函式 而不是直接存取巢狀狀態欄位是很常見的,但兩種方法都可以。

範例:根據狀態進行條件式傳送
const MAX_TODOS = 5

function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()

// Could also check `state.todos.length < MAX_TODOS`
if (selectCanAddNewTodo(state, MAX_TODOS)) {
dispatch(todoAdded(todoText))
}
}
}

最好 將盡可能多的邏輯放入 reducer,但讓 Thunk 也有額外的內部邏輯也是可以的。

由於狀態在 reducer 處理動作後會同步更新,因此您可以在傳送後呼叫 getState 以取得更新的狀態。

範例:在傳送後檢查狀態
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())

const secondState = getState()

if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}

考慮在 Thunk 中存取狀態的另一個原因是使用額外資訊填寫動作。有時,區段 reducer 確實需要讀取不在其自身狀態區段中的值。可能的解決方法是傳送 Thunk,從狀態中擷取所需值,然後傳送包含額外資訊的純粹動作。

範例:包含跨區段資料的動作
// One solution to the "cross-slice state in reducers" problem:
// read the current state in a thunk, and include all the necessary
// data in the action
function crossSliceActionThunk() {
return (dispatch, getState) => {
const state = getState()
// Read both slices out of state
const { a, b } = state

// Include data from both slices in the action
dispatch(actionThatNeedsMoreData(a, b))
}
}

非同步邏輯和副作用

Thunk 可能包含非同步邏輯,以及更新 localStorage 等副作用。該邏輯可能使用 Promise 串接,例如 someResponsePromise.then(),儘管 async/await 語法通常較易於閱讀。

進行非同步請求時,標準做法是在請求前後傳送動作,以 協助追蹤載入狀態。通常,在請求之前的「待處理」動作和載入狀態列舉會標示為「進行中」。如果請求成功,會傳送包含結果資料的「已完成」動作,或傳送包含錯誤資訊的「已拒絕」動作。

此處的錯誤處理可能比大多數人想像的更棘手。如果您將 resPromise.then(dispatchFulfilled).catch(dispatchRejected) 串接在一起,如果在處理「已完成」動作的過程中發生某些非網路錯誤,您可能會傳送「已拒絕」動作。最好使用 .then() 的第二個參數,以確保您只處理與請求本身相關的錯誤

範例:使用 Promise 串接的非同步請求
function fetchData(someValue) {
return (dispatch, getState) => {
dispatch(requestStarted())

myAjaxLib.post('/someEndpoint', { data: someValue }).then(
response => dispatch(requestSucceeded(response.data)),
error => dispatch(requestFailed(error.message))
)
}
}

使用 async/await 時,這可能會更棘手,因為 try/catch 邏輯通常是以何種方式組織的。為了確保 catch 區塊處理網路層級的錯誤,可能需要重新組織邏輯,以便在發生錯誤時 thunk 會提早傳回,而「已完成」動作只會在最後發生

範例:使用 async/await 處理錯誤
function fetchData(someValue) {
return async (dispatch, getState) => {
dispatch(requestStarted())

// Have to declare the response variable outside the try block
let response

try {
response = await myAjaxLib.post('/someEndpoint', { data: someValue })
} catch (error) {
// Ensure we only catch network errors
dispatch(requestFailed(error.message))
// Bail out early on failure
return
}

// We now have the result and there's no error. Dispatch "fulfilled".
dispatch(requestSucceeded(response.data))
}
}

請注意,此問題不只限於 Redux 或 thunk - 即使你只使用 React 元件狀態,或任何需要對成功結果進行額外處理的邏輯,也可能發生。

必須承認,這種模式寫起來和讀起來都很彆扭。在多數情況下,你可能可以使用更典型的 try/catch 模式,其中請求和 dispatch(requestSucceeded()) 是連續的。還是值得知道這可能會是個問題。

從 Thunk 傳回值

預設情況下,store.dispatch(action) 會傳回實際的動作物件。中間件可以覆寫從 dispatch 傳回的傳回值,並替換成他們想要傳回的任何其他值。例如,中間件可以選擇總是傳回 42

中間件傳回值
const return42Middleware = storeAPI => next => action => {
const originalReturnValue = next(action)
return 42
}

// later
const result = dispatch(anyAction())
console.log(result) // 42

Thunk 中間件會透過傳回呼叫的 thunk 函式傳回的任何值來執行此操作。

最常見的用例是從 thunk 傳回 promise。這允許發送 thunk 的程式碼等待 promise,以知道 thunk 的非同步工作已完成。元件通常會使用此方式來協調額外的作業

範例:等待 thunk 結果 promise
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}

你還可以對此執行一個巧妙的技巧:你可以將 thunk 重新用作從 Redux 狀態進行一次性選取的方式,而你只有存取權限 dispatch。由於發送 thunk 會傳回 thunk 傳回值,因此你可以撰寫一個接受選取器的 thunk,並立即使用狀態呼叫選取器,然後傳回結果。這在 React 元件中很有用,因為你可以在其中存取 dispatch,但無法存取 getState

範例:重新使用 thunk 來選取資料
// In your Redux slices:
const getSelectedData = selector => (dispatch, getState) => {
return selector(getState())
}

// in a component
const onClick = () => {
const todos = dispatch(getSelectedData(selectTodos))
// do more logic with this data
}

這本身並不是建議的做法,但它在語意上是合法的,而且會正常運作。

使用 createAsyncThunk

使用 thunk 撰寫非同步邏輯可能會有點繁瑣。每個 thunk 通常需要定義三種類型的動作 + 匹配的動作建立器,用於「處理中/已完成/已拒絕」,加上實際的 thunk 動作建立器 + thunk 函式。還有需要處理的錯誤處理邊緣案例。

Redux Toolkit 有 createAsyncThunk API,它抽象了產生這些動作的程序,根據 Promise 生命週期發送它們,並正確處理錯誤。它接受部分動作類型字串(用於產生 pendingfulfilledrejected 的動作類型),以及執行實際非同步請求並傳回 Promise 的「payload 建立回呼」。然後,它會在請求前後自動發送動作,並附上正確的參數。

由於這是針對非同步請求特定使用案例的抽象,因此 createAsyncThunk 沒有涵蓋 thunk 的所有可能使用案例。如果您需要撰寫同步邏輯或其他自訂行為,您仍然應該自己手動撰寫「一般」thunk。

thunk 動作建立器附有 pendingfulfilledrejected 的動作建立器。您可以在 createSlice 中使用 extraReducers 選項來偵聽這些動作類型,並相應地更新區段狀態。

範例:createAsyncThunk
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// omit imports and state

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})

使用 RTK Query 擷取資料

Redux Toolkit 有新的 RTK Query 資料擷取 API。RTK Query 是專為 Redux 應用程式打造的資料擷取和快取解決方案,而且 可以消除撰寫任何 thunk 或 reducer 來管理資料擷取的需要

RTK Query 實際上在內部使用 createAsyncThunk 來處理所有請求,並搭配自訂中介軟體來管理快取資料的生命週期。

首先,建立一個「API 區段」,其中包含應用程式將與之通訊的伺服器端點定義。每個端點都會自動產生一個 React hook,其名稱根據端點和請求類型而定,例如 useGetPokemonByNameQuery

RTK Query:API 區段 (pokemonSlice.js)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query({
query: (name: string) => `pokemon/${name}`
})
})
})

export const { useGetPokemonByNameQuery } = pokemonApi

然後,將產生的 API 區段 reducer 和自訂中介軟體新增至儲存

RTK Query:儲存設定
import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'

export const store = configureStore({
reducer: {
// Add the generated reducer as a specific top-level slice
[pokemonApi.reducerPath]: pokemonApi.reducer
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(pokemonApi.middleware)
})

最後,將自動產生的 React hook 匯入您的元件並呼叫它。當元件掛載時,hook 會自動擷取資料,而且如果多個元件使用相同參數使用相同的 hook,它們會共用快取的結果

RTK Query:使用擷取掛勾
import { useGetPokemonByNameQuery } from './services/pokemon'

export default function Pokemon() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')

// rendering logic
}

我們鼓勵您試用 RTK Query,看看它是否能協助簡化您自己的應用程式中的資料擷取程式碼。

進一步資訊