Skip to main content

副作用处理方法

你将学到
  • 何为“副作用”,以及它们如何融入 Redux
  • 管理 Redux 副作用的常用工具
  • 针对不同使用情景的工具推荐

Redux 与副作用​

副作用概述​

Redux store 本身并不了解异步逻辑。它只知道如何同步地分发 action,通过调用根 reducer 函数更新状态,并通知 UI 有变化。任何异步操作必须在 store 外部完成。

Redux reducer 绝不可包含“副作用”。“副作用”是指函数返回值之外的任何状态或行为改变。一些常见副作用示例包括:

  • 在控制台打印日志
  • 保存文件
  • 设置异步定时器
  • 发起 AJAX HTTP 请求
  • 修改函数外部存在的状态,或修改函数参数
  • 生成随机数或唯一随机 ID(如 Math.random() 或 Date.now())

然而,任何真实应用都需要在某处进行这些操作。那么,如果不能在 reducers 中管理副作用,我们能放在哪里呢?

中间件与副作用​

Redux 中间件设计的初衷就是允许编写含有副作用的逻辑。

Redux 中间件能拦截任何被分发的 action:比如日志打印、修改 action、延迟 action、发起异步调用等。此外,因为中间件形成了一条围绕真实 store.dispatch 函数的管线,所以理论上我们甚至可以向 dispatch 传递一个非普通的 action 对象,只要中间件截获并处理它,不让它传递给 reducers 即可。

中间件也能访问 dispatch 和 getState。这意味着你可以在中间件内编写异步逻辑,同时通过 dispatch 继续与 Redux store 交互。

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

副作用使用场景​

实际上,在典型 Redux 应用中,最常见的副作用使用场景是从服务器获取和缓存数据。

另一个 Redux 特有的使用场景是针对某个分发动作或状态变化写响应逻辑,例如触发更多的 actions。

推荐方案​

我们建议针对不同使用场景选用最合适的工具(以下内容详细说明了推荐理由及各工具特性):

tip

数据获取​

  • 默认使用 RTK Query 进行数据获取与缓存
  • 如果 RTKQ 出于某些原因不适合,使用 createAsyncThunk
  • 仅当以上方案都不能满足需求时,才手写 thunk
  • 不要 用 saga 或 observable 来做数据获取!

响应动作 / 状态变化,异步流程​

  • 默认使用 RTK 监听器(listeners)响应 store 更新,处理长时间运行的异步流程
  • 仅当监听器无法很好满足需求时,再考虑 saga / observable

涉及状态访问的逻辑​

  • 使用 thunk 处理复杂的同步与中等复杂度异步逻辑,包括访问 getState 和分发多个动作

为什么用 RTK Query 进行数据获取​

根据 React 文档“Effects 中数据获取的替代方案”,你应当使用服务端框架自带的数据获取方案,或使用客户端缓存,而非手写数据获取和缓存管理代码。

RTK Query 专为 Redux 应用设计,是一套完整的数据获取与缓存解决方案。它帮你管理所有的获取、缓存及加载状态逻辑,涵盖了手写代码时常被遗漏或难以处理的边界情况,并内建缓存生命周期管理。且它方便通过自动生成的 React hooks 获取和使用数据。

我们特别不推荐用 saga 做数据获取,因为 saga 复杂度大且需要你手写所有缓存和加载状态管理逻辑。

为什么用监听器实现响应式逻辑​

我们特点设计了 RTK 监听器中间件,使其简单易用。它采用标准的 async/await 语法,覆盖大多数常见响应式使用场景(响应动作或状态变化、防抖、延迟),甚至还支持一些高级场景(启动子任务)。体积小巧(约3KB),包含于 Redux Toolkit 内,并且对 TypeScript 支持良好。

我们反对在大多数响应式逻辑中使用 saga 或 observable,原因包括:

  • Saga:需要了解 generator 语法及其 effect 行为;因需额外动作而增加多层间接调用;对 TypeScript 支持差;大多数 Redux 用例不需要其复杂能力。
  • Observable:必须掌握 RxJS API 及其思维模型;调试难度较高;显著增加包大小。

常见副作用处理方案​

管理 Redux 副作用最底层的方式是写自定义中间件监听特定动作并执行逻辑,但这极少被采用。大部分应用历史上使用生态中提供的成熟副作用中间件:thunk、saga 或 observable。它们各有特点与适用场景。

近年来,官方 Redux Toolkit 新增了两款副作用处理 API:监听器中间件用来编写响应式逻辑,以及 RTK Query 用于数据获取和缓存。

Thunk​

Redux thunk 中间件传统上是最广泛使用的异步逻辑解决方案。

thunk 使用方式是向 dispatch 传入一个函数。thunk 中间件拦截该函数,执行并传入 (dispatch, getState),使你能执行任意同步或异步逻辑并访问 store。

Thunk 使用场景​

thunk 最适合处理需要访问 dispatch 和 getState 的复杂同步逻辑,或中等复杂度异步逻辑,如一次性“异步请求数据并以结果分发动作”。

过去我们一直建议默认使用 thunk,Redux Toolkit 也专门提供了 createAsyncThunk API 来处理“请求并派发结果”的用例。其他场景则可以编写自己的 thunk 函数。

Thunk 权衡​

  • 👍 :仅需编写函数;可以包含任意逻辑
  • 👎 :无法响应已分发的动作;指令式;不可取消
Thunk 示例
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}

return next(action)
}

// 传统手写thunk异步请求模式
const fetchUserById = userId => {
return async (dispatch, getState) => {
// 分发“请求开始”动作用以跟踪加载状态
dispatch(fetchUserStarted())
// 先保存变量,方便错误处理
let lastAction
try {
const user = await userApi.getUserById(userId)
// 请求成功分发“成功”动作
lastAction = fetchUserSucceeded(user)
} catch (err) {
// 请求失败分发“失败”动作
lastAction = fetchUserFailed(err.message)
}
dispatch(lastAction)
}
}

// 使用 createAsyncThunk 实现类似请求
const fetchUserById2 = createAsyncThunk('fetchUserById', async userId => {
const user = await userApi.getUserById(userId)
return user
})

Saga​

Redux-Saga 中间件历来是第二常用的副作用工具,仅次于 thunk。它受到后端“saga”模式启发,可以让长流程异步工作流响应系统内任意事件。

概念上,你可以把 saga 理解为 Redux 应用内部的“后台线程”,能监听分发的动作并执行额外逻辑。

Saga 使用 generator 函数编写。saga 函数返回副作用的“描述”,暂停自身执行,由 saga 中间件负责执行副作用并用结果恢复 saga 函数。redux-saga 库包含多种副作用定义,如:

  • call:执行异步函数,Promise 解决后返回结果
  • put:分发 Redux 动作
  • fork:派生“子 saga”,类似额外线程做更多工作
  • takeLatest:监听特定动作,触发 saga,若动作再发则取消之前同类 saga

Saga 使用场景​

Saga 功能强大,适合极复杂的异步工作流,需后台线程式行为或防抖/取消。

许多 saga 用户强调 saga 函数仅返回副作用描述是其可测试性的主要优势。

Saga 权衡​

  • 👍 :因只返回副作用描述,易于测试;副作用模型强大;支持暂停取消
  • 👎 :generator 语法复杂;独有 saga API;测试容易变成实现细节测试,代码变动即需重写,价值降低;TypeScript 配合欠佳
Saga 示例
import { call, put, takeEvery } from 'redux-saga/effects'

// “工作” saga:响应 USER_FETCH_REQUESTED 动作
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))
}
}

// “监听” saga:监听所有 USER_FETCH_REQUESTED 动作,触发 fetchUser
function* fetchUserWatcher() {
yield takeEvery('USER_FETCH_REQUESTED', fetchUser)
}

// saga 支持复杂异步流程及子任务:
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))
}

Observable​

Redux-Observable 中间件允许你使用 RxJS observable 创建称为“epics”的处理管道。

由于 RxJS 是框架无关的库,observable 用户强调跨平台复用相关知识是亮点。此外,RxJS 可以构建声明式管道,处理如取消、去抖等定时场景。

Observable 使用场景​

类似于 saga,observable 功能强大,适合极复杂异步流程,需后台线程式行为或防抖/取消。

Observable 权衡​

  • 👍 :数据流模型非常强大;RxJS 知识独立于 Redux 可用;声明式语法
  • 👎 :RxJS API 复杂;思维负担;调试难;包体积大
Observable 示例
// 典型 AJAX 请求例子:
const fetchUserEpic = action$ =>
action$.pipe(
filter(fetchUser.match),
mergeMap(action =>
ajax
.getJSON(`https://api.github.com/users/${action.payload}`)
.pipe(map(response => fetchUserFulfilled(response)))
)
)

// 复杂异步管道示例,包括延迟、取消、去抖和错误处理:
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% 的相同用例,同时 bundle 更小、API 更简单、TypeScript 支持也更好。

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

监听器中间件允许添加条目来匹配特定动作并执行 effect 回调。类似 thunk,effect 支持同步或异步,可以访问 dispatch 和 getState。还会传入带有多个构建异步工作流原语的 listenerApi 对象,比如:

  • condition():暂停直到特定动作分发或状态变化
  • cancelActiveListeners():取消已进行中的同类型 effect 实例
  • fork():创建可额外执行工作的“子任务”

这些原语使监听器几乎可以复刻 Redux-Saga 的所有副作用行为。

监听器使用场景​

监听器适合各种任务,如轻量级 store 持久化、动作触发额外逻辑、状态变化监听,以及复杂长时间运行的“后台线程”式异步工作流。

监听器条目也可在运行时动态添加或移除,结合 React 的 useEffect 使用非常方便,可实现与组件生命周期绑定的行为。

监听器权衡​

  • 👍: Built into Redux Toolkit; async/await is more familiar syntax; similar to thunks; lightweight concepts and size; works great with TypeScript
  • 👎: Not quite as flexible as sagas/observables
Listener 示例
// 创建监听器中间件实例及方法
const listenerMiddleware = createListenerMiddleware()

// 添加监听条目匹配特定动作。
// 可包含任意同步/异步逻辑,类似 thunk。
listenerMiddleware.startListening({
actionCreator: todoAdded,
effect: async (action, listenerApi) => {
// 自由运行副作用逻辑
console.log('Todo added: ', action.payload.text)

// 可取消其它运行中实例
listenerApi.cancelActiveListeners()

// 异步逻辑示例
const data = await fetchData()

// 通过 listenerApi 分发动作、访问状态、管理监听等
listenerApi.dispatch(todoAdded('Buy pet food'))
}
})

listenerMiddleware.startListening({
// 匹配动作或状态变化内容
predicate: (action, currentState, previousState) => {
return currentState.counter.value !== previousState.counter.value
},
// 支持长时间运行异步工作流
effect: async (action, listenerApi) => {
// 等待特定动作分发或状态变化
if (await listenerApi.condition(matchSomeAction)) {
// 派生“子任务”做更多工作并返回结果
const task = listenerApi.fork(async forkApi => {
// 任务中可暂停执行
await forkApi.delay(5)
// 任务完成返回值
return 42
})

// 监听器中获取子任务结果
const result = await task.result
if (result.status === 'ok') {
console.log('子任务成功: ', result.value)
}
}
}
})

RTK Query​

Redux Toolkit 包含 RTK Query,这是专为 Redux 应用设计的数据获取和缓存解决方案。它旨在简化 Web 应用中常见的数据加载用例,让你无需手动编写数据获取和缓存逻辑。

RTK Query 借助定义 API 及“端点”实现功能。端点可以是获取数据的“查询”,或发送更新的“变更”。RTKQ 内部管理数据获取和缓存,追踪缓存条目使用情况,自动移除不再需要的缓存。采用独特的“标签”机制,能在变更更新服务器状态时自动触发重抓取。

RTKQ 核心是 UI 无关的,也可配合任何 UI 框架,但内置了 React 集成,可自动为每个端点生成 React hooks,方便从组件获取和更新数据。

RTKQ 默认基于 fetch 实现,很适合 REST API,也能灵活适配 GraphQL 和任意异步函数,方便集成 Firebase、Supabase 等 SDK 或自定义异步逻辑。

RTKQ 还支持端点“生命周期方法”,你可以在缓存条目被添加和移除时执行特定逻辑,比如为聊天室拉取初始数据后订阅 socket 接收额外消息更新缓存。

RTK Query 使用场景​

RTK Query 专门解决服务器状态的数据获取与缓存问题。

RTK Query 权衡​

  • 👍 :含于 RTK;免写任何数据获取相关 thunk、选择器、副作用、reducer 代码;TypeScript 支持优异;与 Redux store 紧密整合;内置 React hooks
  • 👎 :设计为“文档型”缓存而非“规范化”;会引入一次性包体积开销
RTK Query 示例
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'

// 通过基础 URL 和端点定义创建 API
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',
// 请求体包含整个 post 对象
body: initialPost
})
})
})
})

// 导出自动生成的 React hooks 用于函数组件调用
export const { useGetPokemonByNameQuery } = api

export default function App() {
// 使用查询 hook 自动获取数据,获取查询结果
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')

// 根据数据和加载状态渲染 UI
}

其它方案​

自定义中间件​

由于 thunk、saga、observable 和监听器全是中间件形式(且 RTK Query 也自带专属中间件),你完全可以写自己的定制中间件来处理需求。

但 **我们特别不推荐把大量应用逻辑都拆成自定义中间件!**有人试过为每个功能写一个中间件,这会严重增加开销,因为每次 dispatch 都需执行所有中间件。最好用通用中间件(如 thunk 或监听器)管理多块逻辑,只添加一个中间件实例。

自定义中间件示例
const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// 延迟一秒执行该动作
next(action)
}, 1000)
return
}

return next(action)
}

WebSocket​

许多应用使用 websocket 或其他持久化连接,主要为了接收服务器的流式更新。

我们一般建议 把大部分 websocket 逻辑放在自定义中间件中,因为:

  • 中间件生命周期长,贯穿应用运行始终
  • 通常只需一个连接实例供整个应用使用
  • 中间件可拦截所有分发动作,实现用动作发消息,接收消息后也分发动作
  • websocket 连接对象不可序列化,不应存放在 store 状态中

根据需求,可以在中间件初始化时创建连接,或基于特定初始化动作创建,或放在独立模块中集中管理。

也可以在 RTK Query 生命周期回调里启用 websocket,实时响应消息刷新缓存。

XState​

状态机有助于定义系统的可能状态以及状态间的过渡,也能在状态转移时触发副作用。

Redux reducer 可以写成真正的有限状态机,但 RTK 并未提供专门支持。实际上它们更像是针对动作判断的“部分”状态机。监听器、saga 和 observable 可以负责“派发后执行副作用”场景,但有时需花更多工夫确保副作用只在特定时刻运行。

XState 是一个功能强大的库,可用于定义和执行真正的状态机,包括根据事件管理状态转换并触发相关副作用。它还提供图形化编辑器等工具来创建状态机定义,再加载到 XState 逻辑中执行。

目前 XState 与 Redux 无官方集成,但可用 XState 机器作为 Redux reducer,且 XState 开发者有一个以 Redux 侧效果中间件形式演示的有趣 POC:

深入了解​