使用 Thunk 编写逻辑
- 什么是“thunk”,以及为什么它们被用于编写 Redux 逻辑
- thunk 中间件的工作原理
- 在 thunk 中编写同步和异步逻辑的技术
- 常见的 thunk 使用模式
Thunk 概述
什么是“thunk”?
“thunk”这个词是一个编程术语,意思是 “一段代码,用于执行一些延迟的工作”。与其现在执行一些逻辑,不如编写一个函数体或代码,以便以后执行工作。
具体到 Redux,“thunk”是一种编写函数的模式,这些函数包含可以与 Redux 存储的 dispatch
和 getState
方法交互的逻辑。
使用 thunk 需要将 redux-thunk
中间件 添加到 Redux 存储,作为其配置的一部分。
Thunk 是 在 Redux 应用程序中编写异步逻辑的标准方法,通常用于数据获取。但是,它们可以用于各种任务,并且可以包含同步和异步逻辑。
编写 Thunk
thunk 函数是一个接受两个参数的函数:Redux 存储的 dispatch
方法和 Redux 存储的 getState
方法。thunk 函数不会被应用程序代码直接调用。相反,它们被传递给 store.dispatch()
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}
store.dispatch(thunkFunction)
thunk 函数可以包含任何任意逻辑,同步或异步,并且可以随时调用 dispatch
或 getState
。
与 Redux 代码通常使用 操作创建器来生成要调度的操作对象 而不是手动编写操作对象一样,我们通常使用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 也可以使用箭头函数编写,如下所示
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 钩子 API 的到来,这种情况发生了变化。社区已从“容器/演示”模式中普遍转向,并且 组件现在可以通过 useDispatch
钩子直接访问 dispatch
。这确实意味着可以在组件内部直接拥有更多逻辑,例如异步获取 + 结果的分派。但是,thunk 可以访问 getState
,而组件则不能,并且将该逻辑从组件中移出仍然有价值。
Thunk 使用场景
由于 Thunk 是一个通用的工具,可以包含任意逻辑,因此可以用于各种目的。最常见的用例是
- 将复杂逻辑从组件中移出
- 进行异步请求或其他异步逻辑
- 编写需要依次或随时间推移调度多个操作的逻辑
- 编写需要访问
getState
来做出决策或在操作中包含其他状态值的逻辑
Thunk 是“一次性”函数,没有生命周期的概念。它们也无法看到其他已调度的操作。因此,它们通常不应用于初始化持久连接(如 WebSockets),并且您无法使用它们来响应其他操作。
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 行。以下是源代码,并添加了额外的注释。
// 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 方法有硬编码的依赖关系。
import thunkMiddleware from 'redux-thunk'
const serviceApi = createServiceApi('/some/url')
const thunkMiddlewareWithArg = thunkMiddleware.withExtraArgument({ serviceApi })
Redux Toolkit 的 configureStore
支持将其作为 getDefaultMiddleware
中的中间件自定义的一部分
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { serviceApi }
}
})
})
只能有一个额外参数值。如果你需要传递多个值,请传递一个包含这些值的 object。
然后,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
Thunk 可以访问 dispatch
方法。这可以用来分发 actions,甚至其他 thunk。这对于依次分发多个 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())
}
}
访问状态
与组件不同,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
语法通常更易读。
在进行异步请求时,通常会在请求之前和之后调度动作以 帮助跟踪加载状态。通常,在请求之前会进行一个“pending”动作,并且加载状态枚举被标记为“in progress”。如果请求成功,则会调度一个包含结果数据的“fulfilled”动作,或者调度一个包含错误信息的“rejected”动作。
这里的错误处理比大多数人想象的要棘手。如果你将 resPromise.then(dispatchFulfilled).catch(dispatchRejected)
连在一起,你可能会在处理“fulfilled”动作的过程中发生一些非网络错误时调度一个“rejected”动作。最好使用 .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" 操作只在最后发生
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)` 返回实际的 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('')
}
这里还有一个巧妙的技巧:你可以将 thunk 重新用作从 Redux 状态中进行一次性选择的方式,当你只有访问 dispatch
的权限时。由于分发 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 通常需要定义三种不同的动作类型 + 匹配的动作创建者用于“pending/fulfilled/rejected”,以及实际的 thunk 动作创建者 + thunk 函数。还需要处理错误处理的边缘情况。
Redux Toolkit 有一个 createAsyncThunk
API,它抽象了生成这些动作、根据 Promise
生命周期分发它们以及正确处理错误的过程。它接受一个部分动作类型字符串(用于生成 pending
、fulfilled
和 rejected
的动作类型),以及一个“有效负载创建回调”,它执行实际的异步请求并返回一个 Promise
。然后它会自动在请求之前和之后分发动作,并使用正确的参数。
由于这是一个针对异步请求的特定用例的抽象,因此 createAsyncThunk
不会解决 thunk 的所有可能的用例。如果你需要编写同步逻辑或其他自定义行为,你仍然应该自己手动编写一个“普通”thunk。
thunk 动作创建者具有 pending
、fulfilled
和 rejected
的动作创建者。你可以使用 createSlice
中的 extraReducers
选项来监听这些动作类型并相应地更新切片的 state。
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 钩子,其名称基于端点和请求类型,例如 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 和自定义中间件添加到存储中
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 钩子导入到组件中并调用它。该钩子将在组件挂载时自动获取数据,如果多个组件使用相同的钩子并使用相同的参数,它们将共享缓存的结果
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 教程