使用 Thunks 编写逻辑
- 什么是 “thunks”,以及为何使用它们编写 Redux 逻辑
- thunk 中间件如何工作
- 在 thunk 中编写同步和异步逻辑的技巧
- 常见的 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) => {
// 这里写逻辑,可以派发 action 或读取 state
}
store.dispatch(thunkFunction)
thunk 函数可以包含任意逻辑,同步或异步,并且可以随时调用 dispatch 或 getState。
与 Redux 代码通常使用动作创建函数(action creators)来生成调度的动作对象,而不是手写动作对象类似,我们通常使用 thunk 动作创建函数 来生成被派发的 thunk 函数。thunk 动作创建函数是一个可以接收参数并返回新的 thunk 函数的函数。thunk 通常会闭包传入动作创建函数的参数,以便逻辑中使用:
// fetchTodoById 是“thunk 动作创建函数”
export function fetchTodoById(todoId) {
// fetchTodoByIdThunk 是“thunk 函数”
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 = () => {
// 调用 thunk 动作创建函数,并将 thunk 函数传递给 dispatch
dispatch(fetchTodoById(todoId))
}
}
为什么使用 Thunks?
Thunks 允许我们编写与 Redux 相关但独立于 UI 层的额外逻辑。这些逻辑可以包含副作用,比如异步请求或生成随机值,也可以包含需要多次派发动作或访问 Redux store 状态的逻辑。
Redux reducers不得包含副作用,但实际应用中必然存在副作用的逻辑。有些可以放在组件中,但有些需要放在 UI 层之外。Thunks(以及其他 Redux 中间件)为我们提供了放置这些副作用的位置。
通常组件内会写逻辑,比如在点击事件处理或 useEffect 钩子中发起异步请求并处理结果,但通常需要尽可能将这些逻辑移出 UI 层,以提高逻辑的可测试性,让 UI 层尽量薄且“纯展示”,或增进代码复用和共享。
从某种意义上说,thunk 是一个“漏洞”,允许你提前编写任何需要与 Redux store 交互的代码,而无需知道最终使用的是哪个 Redux store。这使逻辑不会绑定到特定 store 实例,从而保持重用性。
详细说明:Thunks、Connect 和“容器组件”
历史上,使用 thunks 的另一个原因是帮助保持 React 组件“不了解 Redux”。connect API 允许传递动作创建函数并“绑定”它们,使得调用时自动派发动作。由于组件内部通常无法访问 dispatch,将 thunks 传给 connect 能让组件直接调用 this.props.doSomething(),而不必知道它是父组件的回调、派发普通 Redux 动作、派发执行同步或异步逻辑的 thunk,或者测试中的 mock 函数。
随着 React-Redux hooks API 的出现,这种情况发生了变化。社区总体上放弃了“容器/展示”模式,组件现在可以直接通过 useDispatch 钩子访问 dispatch。这确实使组件内可以写入更多逻辑,比如发起异步请求并派发结果。然而,thunks 可以访问 getState,而组件不行,这使得将这部分逻辑移出组件依然很有价值。
Thunk 使用场景
因为 thunks 是通用工具,可以包含任意逻辑,所以用途广泛。最常见的使用场景包括:
- 将复杂逻辑移出组件
- 发起异步请求或其他异步逻辑
- 写需要连续或多次派发动作的逻辑
- 写需要访问
getState判断或在动作中包含其他状态的逻辑
Thunks 是“即用即抛”函数,没有生命周期概念,也不能看到其他已派发动作。因此,通常不应用于初始化长连接(如 websocket),也不能用来响应其他动作。
Thunk 最适合用于复杂同步逻辑,以及中简单异步逻辑,如发起标准 AJAX 请求并根据结果派发动作。
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(),只要它们被中间件拦截且不会传递给 reducers。
考虑这一点,我们可以看看 thunk 中间件的具体实现。
thunk 中间件的实现非常简短,只有约 10 行代码。以下是源码,加了额外注释:
// 标准中间件定义,3 层嵌套函数:
// 1) 接收 `{dispatch, getState}`
// 2) 接收 `next`
// 3) 接收 `action`
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
// 如果“action”实际上是一个函数……
if (typeof action === 'function') {
// 则调用该函数,并传入 `dispatch` 与 `getState`
return action(dispatch, getState)
}
// 否则是普通动作,传递下去
return next(action)
}
换句话说:
- 如果你传给
dispatch的是函数,thunk 中间件就会识别它是函数而非动作对象,拦截它并调用该函数,传入(dispatch, getState) - 如果是普通动作对象(或其他),则传递给下一个中间件
给 Thunks 注入配置值
thunk 中间件有一个自定义选项。你可以在设置阶段创建自定义实例,并注入一个“额外参数”。中间件会将该额外参数作为第三个参数传递给每个 thunk 函数。最常见的用途是注入 API 服务层,使 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) => {
// 这里 extraArgument 是包含 API 服务的对象
const { serviceApi } = extraArgument
const response = await serviceApi.getTodo(todoId)
dispatch(todosLoaded(response.todos))
}
Thunk 的使用模式
派发动作
thunks 可访问 dispatch 方法。这可用于派发动作,甚至派发其他 thunks。可以用于连续派发多个动作(虽然应尽量避免这种模式),亦可用来协调复杂逻辑中在多个时点的派发。
// 这是一个 thunk 派发其他动作创建函数的例子,
// 这些动作创建函数可能也是 thunk。无异步代码,
// 仅仅是同步逻辑的调度。
function complexSynchronousThunk(someValue) {
return (dispatch, getState) => {
dispatch(someBasicActionCreator(someValue))
dispatch(someThunkActionCreator())
}
}
访问状态
与组件不同,thunks 还能访问 getState。它能随时调用以获取当前的根 Redux 状态。很有用来基于当前状态运行条件逻辑。通常会在 thunk 内使用 selector 函数读取状态,而非直接访问嵌套的状态字段,但两种方式都可。
const MAX_TODOS = 5
function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()
// 也可以用 `state.todos.length < MAX_TODOS`
if (selectCanAddNewTodo(state, MAX_TODOS)) {
dispatch(todoAdded(todoText))
}
}
}
推荐尽量把大部分逻辑放入 reducers,但 thunk 内有额外逻辑也是可接受的。
由于状态在 reducers 处理动作后同步更新,你可以在派发后调用 getState 获取最新状态。
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())
const secondState = getState()
if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}
访问 state 的另一个理由是补充动作额外信息。有时 slice reducer 需要读取不在本 slice 里的值,一个解决方法是先派发 thunk,从 state 中提取数据,再派发普通动作包含这些额外信息。
// 解决“reducers 中跨切片状态”问题的一种方法:
// 在 thunk 中读取当前状态,并把所有需要的数据包含在动作中
function crossSliceActionThunk() {
return (dispatch, getState) => {
const state = getState()
// 读取 state 中的两个切片
const { a, b } = state
// 在动作中包含两个切片的数据
dispatch(actionThatNeedsMoreData(a, b))
}
}
异步逻辑和副作用
thunks 里可以包含异步逻辑和副作用,比如更新 localStorage。可以用 Promise 链(如 someResponsePromise.then()),不过通常为可读性更好用 async/await。
发起异步请求时,通常会在请求前后派发动作帮助跟踪加载状态。一般请求开始前先派“pending”动作,标记加载状态为进行中。成功后派“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())
// 需要在 try 块外声明 response 变量
let response
try {
response = await myAjaxLib.post('/someEndpoint', { data: someValue })
} catch (error) {
// 确保只能捕获网络错误
dispatch(requestFailed(error.message))
// 失败时提前退出
return
}
// 请求成功且无错误,派发 “fulfilled”
dispatch(requestSucceeded(response.data))
}
}
请注意,这个问题不仅限于 Redux 或 thunks,甚至 React 组件状态或任何需要对成功结果做额外处理的逻辑都可能遇到。
这种写法确实有点别扭。在大多数情况下,你可能依然可以用更典型的 try/catch,将请求和 dispatch(requestSucceeded()) 紧挨着写。但知道这个问题存在还是很有帮助的。
从 Thunks 返回值
默认情况下,store.dispatch(action) 会返回实际的动作对象。中间件可以覆盖 dispatch 的返回值,改为返回任何其他值。例如,中间件可以总是返回 42:
const return42Middleware = storeAPI => next => action => {
const originalReturnValue = next(action)
return 42
}
// 后续
const result = dispatch(anyAction())
console.log(result) // 42
thunk 中间件就是通过返回被调用的 thunk 函数的返回值来实现的。
最常见用例是 thunk 返回一个 Promise。这样派发 thunk 的代码可以等待 Promise,知道异步工作完成。组件常利用此协调后续工作:
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}
还有一个有趣技巧,可以用 thunk 做一次性基于 Redux state 的选择。由于派发 thunk 会返回 thunk 返回值,你可以写一个接受 selector 的 thunk,立即调用 selector 并返回结果。这在只有 dispatch 而无 getState 时的 React 组件中很有用。
// 在 Redux slices 中:
const getSelectedData = selector => (dispatch, getState) => {
return selector(getState())
}
// 组件中
const onClick = () => {
const todos = dispatch(getSelectedData(selectTodos))
// 用这些数据做其他逻辑
}
这不是推荐做法,但语义上合法且能正常工作。
使用 createAsyncThunk
用 thunks 编写异步逻辑较繁琐。每个 thunk 通常需要定义三种动作类型和匹配的动作创建函数(“pending/fulfilled/rejected”),加上 thunk 动作创建函数和 thunk 函数。错误处理也是一大问题。
Redux Toolkit 提供了一个 createAsyncThunk API,封装了生成这些动作、根据 Promise 生命周期派发它们,以及正确错误处理的过程。它接受一个动作类型部分字符串(用来生成三种动作类型),和一个“有效载荷创建回调”用来执行实际的异步请求并返回 Promise。然后自动在请求前后派发对应动作及参数。
由于此 API 是针对异步请求的特定用例,createAsyncThunk 并不涵盖所有 thunk 用例。如果你需要编写同步逻辑或其他自定义行为,仍应手写普通 thunk。
thunk 动作创建函数附带了 pending、fulfilled 和 rejected 的动作创建函数。你可用 createSlice 的 extraReducers 监听这些动作类型并更新切片状态。
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
// 忽略 imports 和状态定义
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// 省略其它 reducers
},
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 slice”,定义你的应用将访问的服务器端点。每个端点会自动生成一个 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 slice reducer 和自定义中间件添加到 store:
import { configureStore } from '@reduxjs/toolkit'
// 或者从 '@reduxjs/toolkit/query/react' 导入
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'
export const store = configureStore({
reducer: {
// 将生成的 reducer 作为顶级切片添加
[pokemonApi.reducerPath]: pokemonApi.reducer
},
// 添加 api 中间件启用缓存、失效、轮询
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(pokemonApi.middleware)
})
最后,在组件中导入自动生成的 React 钩子并调用。钩子会在组件挂载时自动请求数据,且如果多个组件用相同参数调用相同钩子,它们会共享缓存结果:
import { useGetPokemonByNameQuery } from './services/pokemon'
export default function Pokemon() {
// 使用查询钩子自动请求数据,返回查询状态及数据
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// 渲染逻辑
}
我们鼓励你试用 RTK Query,看它是否能简化你的应用中的数据请求代码。
更多资料
- 中间件和副作用原因:
- Thunk 教程: