跳至主内容

副作用处理方案

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

你将学到
  • 什么是"副作用"及其在 Redux 中的定位
  • 使用 Redux 管理副作用的常用工具
  • 针对不同场景的工具选用建议

Redux 与副作用

副作用概述

Redux store 本身并不感知异步逻辑。它仅知道如何同步分发 action、通过调用根 reducer 函数更新状态,以及通知 UI 变更。所有异步操作必须在 store 外部处理。

Redux reducer 绝不能包含"副作用"。"副作用"指函数返回值之外可观察到的任何状态或行为变更。常见副作用包括:

  • 在控制台打印值

  • 保存文件

  • 设置异步定时器

  • 发起 AJAX HTTP 请求

  • 修改函数外部的状态,或更改函数参数

  • 生成随机数或唯一随机 ID(如 Math.random()Date.now()

然而,任何实际应用都_需要_在某个地方执行此类操作。那么,既然不能将副作用放入 reducer,我们该将它们放在_哪里_呢?

中间件与副作用

Redux 中间件的设计初衷正是为了支持编写包含副作用的逻辑

当检测到派发的 action 时,Redux 中间件几乎可以执行_任何操作_:记录日志、修改 action、延迟 action、发起异步调用等。由于中间件围绕真正的 store.dispatch 函数形成处理管道,这意味着只要中间件拦截并阻止该值到达 reducer,我们实际上可以向 dispatch 传递_非_普通 action 对象的内容。

中间件还可以访问dispatchgetState。这意味着您可以在中间件中编写异步逻辑,同时仍能通过分发action与Redux store交互。

因此,Redux 副作用和异步逻辑通常通过中间件实现

副作用用例

实践中,典型 Redux 应用最常见的副作用用例是从服务器获取并缓存数据

另一个特定于 Redux 的用例是:通过执行附加逻辑(如派发更多 action)来_响应_已派发的 action 或状态变更。

推荐方案

我们推荐选用最贴合各场景的工具(推荐理由及各工具详情见下文):

技巧

数据获取

  • 默认采用 RTK Query 处理数据获取与缓存
  • 若 RTKQ 不完全适用,改用 createAsyncThunk
  • 仅当其他方案无效时才回退到手写 thunk
  • _避免_使用 sagas 或 observables 处理数据获取!

响应 Action/状态变更与异步工作流

  • 默认采用 RTK listener 响应 store 更新及编写长时异步工作流
  • 仅当 listener 无法满足需求时才使用 sagas/observables

涉及状态访问的逻辑

  • 采用 thunk 处理复杂同步及适度异步逻辑,包括访问 getState 和派发多个 action

为何选用 RTK Query 处理数据获取

根据 React 文档关于"Effect 数据获取替代方案",你_应当_使用服务端框架内置的数据获取方案或客户端缓存。你_不应_自行编写数据获取与缓存管理代码

RTK Query 专为成为 Redux 应用的完整数据获取与缓存层而设计。它替你管理所有获取、缓存及加载状态逻辑,覆盖了自行实现时易忽略或难处理的边界情况,并内置缓存生命周期管理。通过自动生成的 React hooks,它能轻松实现数据获取与使用。

我们明确_不推荐_使用 sagas 处理数据获取,因其复杂度无实质帮助,且你仍需自行实现所有缓存与加载状态管理逻辑。

为何选用 Listener 处理响应式逻辑

我们特意将 RTK 监听器中间件设计得简单易用。它采用标准的 async/await 语法,覆盖了大多数常见响应式用例(响应 action 或状态变更、防抖、延迟),甚至包含多个高级用例(启动子任务)。其包体积小巧(约 3KB),已集成在 Redux Toolkit 中,且与 TypeScript 配合良好。

我们明确建议在大多数响应式逻辑中_避免使用_ sagas 或 observables,原因如下:

  • Sagas:需要理解生成器函数语法及 saga effects 行为;因需派发额外 action 导致多层间接性;TypeScript 支持较差;其强大功能和复杂性在多数 Redux 用例中并无必要。

  • Observables:需要掌握 RxJS API 和思维模型;调试困难;可能显著增加包体积。

常用副作用处理方案

使用 Redux 管理副作用的最底层方案是编写自定义中间件来监听特定 action 并执行逻辑,但这种方式极少使用。实践中,多数应用历来采用生态系统中常见的预置 Redux 副作用中间件:thunks、sagas 或 observables。每种方案各有其适用场景与权衡。

近期,我们的官方 Redux Toolkit 包新增了两个副作用管理 API:用于编写响应式逻辑的"监听器"中间件,以及用于获取和缓存服务端状态的 RTK Query。

Thunks

Redux "thunk" 中间件 历来是编写异步逻辑最广泛使用的方案。

Thunks 通过向 dispatch 传入函数实现。thunk 中间件会拦截该函数,调用时传入 theThunkFunction(dispatch, getState)。thunk 函数随后可执行任意同步/异步逻辑并与 store 交互。

Thunk 适用场景

Thunks 最适用于需要访问 dispatchgetState 的复杂同步逻辑,或中等复杂度异步逻辑(如一次性"获取异步数据并派发结果 action"请求)。

我们历来推荐 thunks 作为默认方案,Redux Toolkit 专门提供了 createAsyncThunk API 处理"请求与派发"场景。其他用例可编写自定义 thunk 函数。

Thunk 权衡考量

  • 👍:只需编写函数;可包含任意逻辑

  • 👎:无法响应已派发 action;命令式风格;不可取消

Thunk Examples
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 中间件 历来是仅次于 thunks 的第二常用副作用工具,其设计灵感源于后端"saga"模式——长时运行的工作流可响应全系统触发的事件。

概念上可将 sagas 视为 Redux 应用内的"后台线程",能够监听派发的 action 并执行额外逻辑。

Sagas 使用生成器函数编写。Saga 函数返回副作用的_描述_并自行暂停,saga 中间件负责执行副作用并使用结果恢复 saga 函数。redux-saga 库包含多种 effects 定义,例如:

  • call:执行异步函数并在 Promise 解析后返回结果:

  • put:派发 Redux action

  • fork:生成"子 saga",类似可执行更多任务的附加线程

  • takeLatest:监听指定 Redux action,触发 saga 执行,并在重复派发时取消前序运行实例

Saga 适用场景

Sagas 功能极其强大,最适用于需要"后台线程"类行为或防抖/取消机制的高度复杂异步工作流。

Saga 用户常强调一个关键优势:saga 函数仅返回预期效果的_描述_,这种特性显著提升了可测试性。

Saga 的权衡考量

  • 👍:Saga 具备可测试性(仅返回效果描述);强大的效果模型;支持暂停/取消能力

  • 👎:生成器函数复杂度高;独特的 saga effects API;测试常仅验证实现结果,修改逻辑后需重写测试,价值有限;TypeScript 支持不佳

Saga Examples
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))
}

响应式编程(Observables)

Redux-Observable 中间件通过 RxJS 可观察对象构建名为 "epics" 的处理流水线。

由于 RxJS 是框架无关库,其用户认为跨平台复用知识是核心优势。此外,RxJS 能构建声明式流水线,优雅处理取消/防抖等时序场景。

响应式编程适用场景

与 saga 类似,响应式编程擅长处理需要"后台线程"行为或防抖/取消机制的复杂异步工作流。

响应式编程的权衡考量

  • 👍:强大的数据流模型;RxJS 知识可脱离 Redux 复用;声明式语法

  • 👎:RxJS API 复杂;心智模型理解成本高;调试困难;包体积较大

Observable Examples
// 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)))
)
)
)
)
)

监听器(Listeners)

Redux Toolkit 内置了createListenerMiddleware API处理"响应式"逻辑。它专为替代 saga/observable 设计:覆盖 90% 相同场景,但包体积更小、API 更简洁、TypeScript 支持更佳。

概念上类似于 React 的 useEffect 钩子,但作用于 Redux store 更新。

监听器中间件允许添加匹配特定 action 的条目,从而决定何时执行 effect 回调。与 thunk 类似,effect 回调可同步或异步运行,并能访问 dispatchgetState。它们还接收包含异步工作流构建原语的 listenerApi 对象,例如:

  • condition():暂停直至指定 action 分发或状态变更

  • cancelActiveListeners():取消当前正在运行的 effect 实例

  • fork():创建可执行附加工作的"子任务"

这些原语使监听器能复现 Redux-Saga 的几乎所有效果行为。

监听器适用场景

监听器适用于多样化任务:轻量级 store 持久化、action 分发时触发附加逻辑、状态变更监听,以及复杂的长期运行"后台线程"式异步工作流。

此外,通过分发特殊的 add/removeListener action,可在运行时动态增删监听器条目。这与 React 的 useEffect 钩子完美契合,便于实现与组件生命周期对应的附加行为。

监听器的权衡考量

  • 👍:Redux Toolkit 内置;async/await 语法更熟悉;类 thunk 设计;概念轻量且体积小;TypeScript 支持优秀

  • 👎:相对较新,尚未充分"实战检验";灵活性_略逊_于 saga/observable

Listener Examples
// 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 包含专为 Redux 应用设计的 RTK Query,这是开箱即用的数据获取与缓存方案。它旨在简化 Web 应用中的数据加载场景,无需手动编写数据获取和缓存逻辑。

RTK Query 的核心是创建包含多个"端点"的 API 定义。端点可以是获取数据的"查询",也可以是向服务器发送更新的"变更"。RTKQ 内部管理数据获取和缓存机制,包括跟踪每个缓存条目的使用情况,并在不再需要时自动清除缓存数据。其独特的"标签"系统能在服务器状态通过变更更新后,自动触发数据的重新获取。

与 Redux 其他部分相同,RTKQ 的核心是 UI 无关的,可与任何 UI 框架配合使用。但它也内置了 React 集成方案,能为每个端点自动生成 React hooks。这为 React 组件提供了熟悉且简洁的数据获取与更新 API。

RTKQ 默认提供基于 fetch 的实现方案,在 REST API 场景中表现优异。其灵活性也支持与 GraphQL API 协同工作,甚至可配置为兼容任意异步函数,实现与 Firebase、Supabase 等外部 SDK 或自定义异步逻辑的集成。

RTKQ 还具备强大的端点"生命周期方法"能力,允许在缓存条目添加/移除时执行逻辑。这适用于聊天室等场景:先获取初始数据,再通过 socket 订阅新消息并实时更新缓存。

RTK Query 适用场景

RTK Query 专为解决服务器状态的数据获取与缓存场景而设计。

RTK Query 权衡考量

  • 👍:内置于 RTK;完全消除数据获取和加载状态管理代码(thunks/selectors/effects/reducers);完美支持 TS;与 Redux store 深度集成;内置 React hooks

  • 👎:采用"文档"式缓存而非"规范化"缓存;带来一次性包体积增加

RTK Query Examples
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
}

其他方案

自定义中间件

鉴于 thunks、sagas、observables 和 listeners 都是 Redux 中间件形式(且 RTK Query 包含专属中间件),当现有工具无法满足需求时,编写自定义中间件始终是可行方案。

注意:我们明确反对将自定义中间件作为管理应用逻辑的主要手段! 部分用户曾尝试为每个功能创建独立中间件,导致每次 dispatch 调用都需要执行大量中间件,显著增加性能开销。更推荐使用 thunks 或 listeners 等通用中间件——单个中间件实例即可处理多种逻辑。

Custom Middleware Example
const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}

return next(action)
}

WebSockets

许多应用使用 WebSockets 或其他持久连接方案,主要用于接收服务器的流式更新。

我们建议在 Redux 应用中通过自定义中间件管理 WebSockets,原因如下:

  • 中间件的生命周期与整个应用保持一致

  • 如同 store 本身,整个应用通常只需要单个连接实例

  • 中间件能捕获所有派发的 action 并自行派发新 action。这意味着中间件可以将派发的 action 转换为 WebSocket 消息发送,并在收到 WebSocket 消息时派发新 action。

  • WebSocket 连接实例不可序列化,因此不应置于 store 状态中

根据应用需求,可在中间件初始化时创建 socket,通过分发初始化动作按需创建,或在独立模块中创建以便全局访问。

WebSockets 也可在 RTK Query 生命周期回调中使用,通过响应消息更新 RTKQ 缓存。

XState

状态机在定义系统状态及状态转换逻辑方面极具价值,还能在状态转换时触发副作用。

Redux reducer 可以 被编写为真正的有限状态机(Finite State Machine),但 RTK 并未内置相关支持。实际上,它们往往是_部分_状态机,仅关注派发的 action 以决定如何更新状态。监听器(listeners)、sagas 和 observables 可用于实现"派发后运行副作用"的功能,但有时需要额外工作来确保副作用仅在特定时刻执行。

XState 是一个强大的库,用于定义和执行真正的状态机,包括基于事件管理状态转换以及触发相关副作用。它还提供了通过图形化编辑器创建状态机定义的相关工具,这些定义随后可加载到 XState 逻辑中执行。

尽管目前 XState 与 Redux 之间尚无官方集成方案,但_可以_将 XState 状态机用作 Redux reducer。XState 的开发者已创建了一个实用的概念验证(POC),演示如何将 XState 用作 Redux 副作用中间件:

扩展阅读