副作用处理方法
- 何为“副作用”,以及它们如何融入 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。
推荐方案
我们建议针对不同使用场景选用最合适的工具(以下内容详细说明了推荐理由及各工具特性):
为什么用 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 最适合处理需要访问 dispatch 和 getState 的复杂同步逻辑,或中等复杂度异步逻辑,如一次性“异步请求数据并以结果分发动作”。
我们传统上推荐 thunk 作为默认方法,Redux Toolkit 也专门提供了 createAsyncThunk API 以简化“请求并分发”场景。其他场景可自行编写 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 配合欠佳
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 复杂;思维负担;调试难;包体积大
// 典型 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 支持同步或异步,可以访问 dispatch 和 getState。还会传入带有多个构建异步工作流原语的 listenerApi 对象,比如:
condition():暂停直到特定动作分发或状态变化cancelActiveListeners():取消已进行中的同类型 effect 实例fork():创建可额外执行工作的“子任务”
这些原语使监听器几乎可以复刻 Redux-Saga 的所有副作用行为。
监听器使用场景
监听器适合各种任务,如轻量级 store 持久化、动作触发额外逻辑、状态变化监听,以及复杂长时间运行的“后台线程”式异步工作流。
监听器条目也可在运行时动态添加或移除,结合 React 的 useEffect 使用非常方便,可实现与组件生命周期绑定的行为。
监听器权衡
- 👍 :含于 Redux Toolkit ;
async/await更易用;类似 thunk;轻量;TypeScript 支持优良 - 👎 :相对较新,尚未广泛“实战检验”;灵活度略逊 saga/observable
// 创建监听器中间件实例及方法
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
- 👎 :设计为“文档型”缓存而非“规范化”;会引入一次性包体积开销
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:
深入了解
- 演讲:Redux 异步逻辑的演化
- 关于中间件和副作用的理由:
- 文档与教程:
- 文章与对比