使用 Thunks 编写逻辑
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
- 什么是 "thunks",以及为什么用于编写 Redux 逻辑
- thunk 中间件的工作原理
- 在 thunks 中编写同步和异步逻辑的技巧
- 常见的 thunk 使用模式
Thunk 概述
什么是 "thunk"?
"thunk" 是一个编程术语,指代「一段执行延迟工作的代码」。不同于_立即_执行某些逻辑,我们可以编写函数体或代码块,以便在_稍后_执行这项工作。
在 Redux 中特指:"thunks" 是一种编写函数的模式,函数内部包含的逻辑可以与 Redux store 的 dispatch 和 getState 方法交互。
使用 thunks 需要将 redux-thunk 中间件 添加到 Redux store 的配置中。
Thunks 是 Redux 应用中编写异步逻辑的标准方法,通常用于数据获取。但它们也可用于多种任务,并能包含同步和异步逻辑。
编写 Thunks
thunk 函数 是接受两个参数的函数:Redux store 的 dispatch 方法和 getState 方法。thunk 函数不会被应用代码直接调用,而是传递给 store.dispatch():
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}
store.dispatch(thunkFunction)
thunk 函数可以包含_任意_逻辑(同步或异步),并能在任何时候调用 dispatch 或 getState。
正如 Redux 代码通常使用 action 创建器生成待分发的 action 对象 而非手动编写,我们通常使用 thunk action 创建器 来生成待分发的 thunk 函数。thunk action 创建器是带有若干参数的函数,它返回一个新的 thunk 函数。thunk 通常会闭包传入 action 创建器的参数,以便在逻辑中使用:
// 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 函数和 action 创建器可使用 function 关键字或箭头函数编写——两者没有本质区别。同样的 fetchTodoById thunk 也可用箭头函数表示:
export const fetchTodoById = todoId => async dispatch => {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
无论哪种情况,thunk 都通过调用 action 创建器来分发,方式与分发其他 Redux action 相同:
function TodoComponent({ todoId }) {
const dispatch = useDispatch()
const onFetchClicked = () => {
// Calls the thunk action creator, and passes the thunk function to dispatch
dispatch(fetchTodoById(todoId))
}
}
为什么使用 Thunks?
Thunks 允许我们将额外的 Redux 相关逻辑与 UI 层分离。这些逻辑可包含副作用(如异步请求或生成随机值),以及需要分发多个 action 或访问 Redux store 状态的逻辑。
Redux reducers 不得包含副作用,但实际应用需要处理副作用的逻辑。部分逻辑可能位于组件内部,但有些需要置于 UI 层之外。Thunks(及其他 Redux 中间件)为此类副作用提供了安置空间。
常见做法是将逻辑直接放在组件中,如在点击处理函数或 useEffect 钩子中发起异步请求并处理结果。但通常有必要将尽可能多的此类逻辑移出 UI 层,这能提高逻辑可测试性、保持 UI 层轻薄且「展示性」,或提升代码复用与共享性。
从某种意义上说,thunk 是一个巧妙的设计——你可以提前编写任何需要与 Redux store 交互的代码,而无需知道具体使用哪个 store。这使得逻辑不必绑定到特定的 Redux store 实例,保持其可复用性。
Detailed Explanation: Thunks, Connect, and "Container Components"
Historically, another reason to use thunks was to help keep React components "unaware of Redux". The connect API allowed passing action creators and "binding" them to automatically dispatch actions when called. Since components typically did not have access to dispatch internally, passing thunks to connect made it possible for components to just call this.props.doSomething(), without needing to know if it was a callback from a parent, dispatching a plain Redux action, dispatching a thunk performing sync or async logic, or a mock function in a test.
With the arrival of the React-Redux hooks API, that situation has changed. The community has switched away from the "container/presentational" pattern in general, and components now have access to dispatch directly via the useDispatch hook. This does mean that it's possible to have more logic directly inside of a component, such as an async fetch + dispatch of the results. However, thunks have access to getState, which components do not, and there's still value in moving that logic outside of components.
Thunk 的使用场景
由于 thunk 是包含任意逻辑的通用工具,其应用场景十分广泛。最常见的使用场景包括:
-
将复杂逻辑移出组件
-
发起异步请求或执行其他异步逻辑
-
编写需要连续或分时调度多个 action 的逻辑
-
编写需要访问
getState来决策或在 action 中包含其他状态值的逻辑
Thunk 是"一次性"函数,没有生命周期概念,也无法感知其他已分发的 action。因此通常不适用于初始化持久连接(如 WebSocket),也不能用于响应其他 action。
Thunk 最适合处理复杂的同步逻辑,以及简单到中等复杂度的异步逻辑(例如发起标准 AJAX 请求并根据结果分发 action)。
Redux Thunk 中间件
分发 thunk 函数需要将 redux-thunk 中间件作为配置项添加到 Redux store 中。
添加中间件
Redux Toolkit 的 configureStore API 在创建 store 时会自动添加 thunk 中间件,因此通常无需额外配置。
如需手动添加 thunk 中间件,可在初始化时将 thunk 中间件传入 applyMiddleware()。
中间件工作原理
首先回顾 Redux 中间件的通用工作原理。
-
外层函数接收包含
{dispatch, getState}的"store API"对象 -
中间层函数接收链中的
next中间件(或实际的store.dispatch方法) -
内层函数在每个
action通过中间件链时被调用
关键点:中间件允许向 store.dispatch() 传递非 action 对象的值,只要中间件拦截这些值阻止其到达 reducer 即可。
基于此,我们来解析 thunk 中间件的具体实现。
thunk 中间件的实际实现非常简洁——仅约 10 行代码。以下是添加注释后的源码:
// 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 服务层,避免硬编码依赖:
import { withExtraArgument } from 'redux-thunk'
const serviceApi = createServiceApi('/some/url')
const thunkMiddlewareWithArg = withExtraArgument({ serviceApi })
Redux Toolkit 的 configureStore 在 getDefaultMiddleware 的中间件定制中支持此功能:
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { serviceApi }
}
})
})
只能有一个额外参数值。如需传递多个值,请传递包含这些值的对象。
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 使用模式
分发 Actions
Thunks 可以访问 dispatch 方法,用于分发 actions 或其他 thunks。这在需要连续分发多个 actions(尽管应尽量减少此模式)或编排需在流程中多次分发的复杂逻辑时非常有用。
// 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())
}
}
访问状态
与组件不同,thunks 还可访问 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))
}
}
}
建议将尽可能多的逻辑放入 reducers,但 thunks 中包含额外逻辑也是合理的。
由于 reducers 处理 action 后会立即同步更新状态,您可在分发后调用 getState 获取更新后的状态。
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())
const secondState = getState()
if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}
在 thunk 中访问状态的另一考虑是为 action 补充额外信息。有时切片 reducer 需要读取不属于自身切片的状态值。变通方案是:分发 thunk → 从状态提取所需值 → 分发包含额外信息的普通 action。
// 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))
}
}
异步逻辑与副作用
Thunks 可包含异步逻辑和副作用(如更新 localStorage)。此类逻辑可使用 Promise 链(如 someResponsePromise.then()),但通常 async/await 语法更具可读性。
发起异步请求时,标准做法是在请求前后分发 actions 以跟踪加载状态。典型模式:请求前分发"pending" action → 标记加载状态为"进行中"。请求成功则分发含结果数据的"fulfilled" action,失败则分发含错误信息的"rejected" action。
错误处理比多数人预想的更复杂。若使用 resPromise.then(dispatchFulfilled).catch(dispatchRejected) 链式调用,可能在处理"fulfilled" action 时因非网络错误触发"rejected" action。更佳实践是使用 .then() 的第二个参数确保仅处理请求相关错误:
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,"fulfilled" action 仅在最后触发:
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 或 thunks 独有——即使仅使用 React 组件状态或任何需对成功结果进行额外处理的逻辑时同样存在。
必须承认此模式编写和阅读较笨拙。多数情况下,更典型的 try/catch 模式(请求与 dispatch(requestSucceeded()) 紧邻)可能足够。但仍需知晓此潜在问题。
从 Thunks 返回值
默认情况下,store.dispatch(action) 返回实际的 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 的异步工作是否完成。组件通常利用这一点协调额外工作:
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}
这里还有个巧妙技巧:当只能访问 dispatch 时,可将 thunk 改造成从 Redux 状态中一次性选择数据的工具。由于分发 thunk 会返回 thunk 的返回值,因此可以编写接受选择器的 thunk,立即用状态调用选择器并返回结果。这在 React 组件中特别有用(能访问 dispatch 但无法访问 getState):
// 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 通常需要定义三种 action 类型(pending/fulfilled/rejected)及对应 action 创建器,外加实际的 thunk action 创建器和 thunk 函数。还需处理错误处理的边界情况。
Redux Toolkit 提供了 一个 createAsyncThunk API,它抽象了生成这些 action 的过程:基于 Promise 生命周期分发 action 并正确处理错误。它接受部分 action 类型字符串(用于生成 pending/fulfilled/rejected 的 action 类型)和"有效载荷创建回调"(执行实际异步请求并返回 Promise)。该 API 会自动在请求前后分发带正确参数的 action。
由于这是针对异步请求的特定抽象,createAsyncThunk 无法覆盖 thunk 的所有用例。如需编写同步逻辑或其他自定义行为,仍需手动编写"普通" thunk。
thunk action 创建器会附加 pending/fulfilled/rejected 的 action 创建器。可通过 createSlice 中的 extraReducers 选项监听这些 action 类型并更新对应切片状态。
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):
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 和自定义中间件添加到 store:
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,它们将共享缓存结果:
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,看看它能否简化您应用中的数据获取代码。
扩展阅读
-
中间件和副作用的缘由:
-
Thunk 教程: