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 即可。

中间件也能访问 dispatchgetState。这意味着你可以在中间件内编写异步逻辑,同时通过 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 用于数据获取和缓存。

Thunks

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

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

Thunk 使用场景

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

我们传统上推荐 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
})

Sagas

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

Observables

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% 的常见用例,包体更小,API 简单,TypeScript 支持更佳。

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

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

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

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

监听器使用场景

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

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

监听器权衡

  • 👍 :含于 Redux Toolkit ;async/await 更易用;类似 thunk;轻量;TypeScript 支持优良
  • 👎 :相对较新,尚未广泛“实战检验”;灵活度略逊 saga/observable
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 应用设计的数据获取与缓存方案。它简化了常见数据加载场景,免去手写数据获取和缓存逻辑。

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

Websockets

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

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

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

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

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

XState

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

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

XState 是个强大的状态机库,支持事件驱动状态转移和副作用触发。还带图形化编辑工具,帮助创建状态机定义,后续加载运行。

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

深入了解