副作用方法
- 什么是“副作用”以及它们如何在 Redux 中发挥作用
- 使用 Redux 管理副作用的常用工具
- 我们对不同用例使用哪些工具的建议
Redux 和副作用
副作用概述
Redux 存储本身并不知道异步逻辑。它只知道如何同步地分发动作,通过调用根 reducer 函数更新状态,并通知 UI 发生了变化。任何异步操作都必须在存储之外进行。
Redux reducer 绝不能包含“副作用”。“副作用”是指任何对状态或行为的更改,这些更改可以在函数返回值之外看到。一些常见的副作用类型包括:
- 将值记录到控制台
- 保存文件
- 设置异步计时器
- 发出 AJAX HTTP 请求
- 修改函数外部存在的某些状态,或修改函数的参数
- 生成随机数或唯一的随机 ID(例如
Math.random()
或Date.now()
)
但是,任何真实的应用程序都需要在某个地方执行这些操作。因此,如果我们不能将副作用放在 reducer 中,那么我们可以将它们放在哪里呢?
中间件和副作用
Redux 中间件旨在支持编写具有副作用的逻辑.
Redux 中间件可以在看到分发的动作时做任何事情:记录某些内容,修改动作,延迟动作,发出异步调用等等。此外,由于中间件在实际的 store.dispatch
函数周围形成了一个管道,这也意味着我们可以实际传递不是普通动作对象的某些内容到 dispatch
,只要中间件拦截该值并且不将其传递到 reducer。
中间件还可以访问 dispatch
和 getState
。这意味着你可以在中间件中编写一些异步逻辑,并且仍然可以通过分发动作来与 Redux 存储交互。
因此,Redux 副作用和异步逻辑通常通过中间件实现。
副作用用例
在实践中,典型的 Redux 应用程序中副作用最常见的用例是从服务器获取和缓存数据。
另一个更特定于 Redux 的用例是编写逻辑,该逻辑响应已分派的 action 或状态更改,通过执行其他逻辑(例如分派更多 action)来响应。
建议
我们建议使用最适合每个用例的工具(有关我们建议的原因以及每个工具的更多详细信息,请参见下文)
为什么使用 RTK Query 进行数据获取
根据React 文档中关于“Effect 中数据获取的替代方案”部分,您应该使用服务器端框架内置的数据获取方法,或者使用客户端缓存。您不应该自己编写数据获取和缓存管理代码。
RTK Query 是专门为 Redux 应用程序设计的一个完整的数据获取和缓存层。它为您管理所有获取、缓存和加载状态逻辑,并涵盖了许多边缘情况,这些情况通常在您自己编写数据获取代码时会被遗忘或难以处理,并且还内置了缓存生命周期管理。它还通过自动生成的 React hook 简化了数据获取和使用。
我们特别不推荐使用 saga 进行数据获取,因为 saga 的复杂性没有帮助,而且您仍然需要自己编写所有缓存 + 加载状态管理逻辑。
为什么使用监听器进行响应式逻辑
我们有意将 RTK 监听器中间件设计为易于使用。它使用标准的 async/await
语法,涵盖了大多数常见的响应式用例(响应操作或状态更改、防抖、延迟),甚至包括一些高级用例(启动子任务)。它具有很小的包大小(约 3K),包含在 Redux Toolkit 中,并且与 TypeScript 完美配合。
我们特别不推荐使用 saga 或 observable 进行大多数响应式逻辑,原因如下:
- Sagas:需要理解生成器函数语法以及 saga 效果行为;由于需要额外分派操作,因此增加了多级间接性;TypeScript 支持较差;并且大多数 Redux 用例根本不需要这种强大而复杂的机制。
- Observables:需要理解 RxJS API 和思维模型;调试可能很困难;可能会增加包大小。
常见的副作用方法
使用 Redux 管理副作用的最低级技术是编写自己的自定义中间件,该中间件监听特定操作并运行逻辑。但是,这种情况很少见。相反,大多数应用程序历来使用生态系统中提供的常见预构建 Redux 副作用中间件之一:thunk、saga 或 observable。它们各自具有不同的用例和权衡。
最近,我们的官方 Redux Toolkit 包添加了两个用于管理副作用的新 API:“监听器”中间件用于编写响应式逻辑,以及 RTK Query 用于获取和缓存服务器状态。
Thunks
传统的 Redux "thunk" 中间件 是编写异步逻辑最常用的中间件。
Thunks 通过将一个函数传递给 dispatch
来工作。thunk 中间件会拦截该函数,调用它,并将 theThunkFunction(dispatch, getState)
传递给它。thunk 函数现在可以执行任何同步/异步逻辑并与存储交互。
Thunk 用例
Thunks 最适合用于需要访问 dispatch
和 getState
的复杂同步逻辑,或者中等程度的异步逻辑,例如一次性“获取一些异步数据并使用结果分派一个动作”请求。
我们一直推荐 thunks 作为默认方法,Redux Toolkit 特别包含了 createAsyncThunk
API 用于“请求和分派”用例。对于其他用例,您可以编写自己的 thunk 函数。
Thunk 权衡
- 👍: 只需编写函数;可以包含任何逻辑
- 👎: 无法响应分派的动作;命令式;无法取消
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 应用程序中的“后台线程”,它们能够监听分派的动作并运行额外的逻辑。
Sagas 使用生成器函数编写。Saga 函数返回副作用的描述并暂停自身,saga 中间件负责执行副作用并使用结果恢复 saga 函数。redux-saga
库包含各种效果定义,例如
call
: 执行异步函数并在 promise 解决时返回结果put
: 分派 Redux 动作fork
: 生成一个“子 saga”,就像一个可以执行更多工作的额外线程takeLatest
: 监听给定的 Redux 动作,触发 saga 函数执行,并在再次分派时取消 saga 的先前正在运行的副本
Saga 用例
Saga 功能非常强大,最适合用于需要“后台线程”类型行为或去抖动/取消的复杂异步工作流。
Saga 用户经常指出,saga 函数只返回所需效果的描述,这是一个主要优势,使它们更易于测试。
Saga 权衡
- 👍: Saga 易于测试,因为它们只返回效果描述;强大的效果模型;暂停/取消功能
- 👎: 生成器函数很复杂;独特的 saga 效果 API;saga 测试通常只测试实现结果,每次修改 saga 时都需要重写,因此价值大大降低;不适用于 TypeScript;
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))
}
可观察对象
Redux-Observable 中间件 允许您使用 RxJS 可观察对象来创建称为“史诗”的处理管道。
由于 RxJS 是一个与框架无关的库,可观察对象用户指出,您可以在不同平台上重复使用如何使用它的知识,这是一个主要卖点。此外,RxJS 允许您构建处理取消或去抖动等时序情况的声明式管道。
可观察对象用例
与 saga 类似,可观察对象功能强大,最适合用于需要“后台线程”类型行为或去抖动/取消的复杂异步工作流。
可观察对象权衡
- 👍: 可观察对象是一种功能强大的数据流模型;RxJS 知识可以独立于 Redux 使用;声明式语法
- 👎: RxJS API 很复杂;心理模型;可能难以调试;捆绑包大小
// 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)))
)
)
)
)
)
监听器
Redux Toolkit 包含 createListenerMiddleware
API 来处理“反应式”逻辑。它专门旨在成为 saga 和可观察对象的更轻量级替代方案,处理 90% 的相同用例,同时具有更小的捆绑包大小、更简单的 API 和更好的 TypeScript 支持。
从概念上讲,这类似于 React 的 useEffect
钩子,但适用于 Redux 存储更新。
监听器中间件允许您添加与操作匹配的条目,以确定何时运行effect
回调。与thunk类似,effect
回调可以是同步或异步的,并且可以访问dispatch
和getState
。它们还接收一个listenerApi
对象,其中包含用于构建异步工作流的几个原语,例如
condition()
:暂停,直到调度特定操作或状态更改发生cancelActiveListeners()
:取消现有正在进行的 effect 实例fork()
:创建一个可以执行额外工作的“子任务”
这些原语允许监听器复制 Redux-Saga 中几乎所有 effect 的行为。
监听器用例
监听器可用于各种任务,例如轻量级存储持久性、在调度操作时触发其他逻辑、监视状态更改以及复杂的长时间运行的“后台线程”式异步工作流。
此外,监听器条目可以在运行时通过调度特殊的add/removeListener
操作动态添加和删除。这与 React 的useEffect
钩子很好地集成,可用于添加与组件生命周期相对应的额外行为。
监听器权衡
- 👍:内置于 Redux Toolkit;
async/await
是更熟悉的语法;类似于 thunk;轻量级概念和大小;与 TypeScript 完美配合 - 👎:相对较新,尚未经过充分的“实战检验”;不像 saga/observable 那样灵活
// 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 包含 RTK Query,这是一个为 Redux 应用程序量身定制的数据获取和缓存解决方案。它旨在简化 Web 应用程序中加载数据的常见情况,无需您手动编写数据获取和缓存逻辑。
RTK Query 依赖于创建包含许多“端点”的 API 定义。端点可以是用于获取数据的“查询”,也可以是用于向服务器发送更新的“变异”。RTKQ 在内部管理数据获取和缓存,包括跟踪每个缓存条目的使用情况并删除不再需要的缓存数据。它具有独特的“标签”系统,用于在变异更新服务器上的状态时触发数据的自动重新获取。
与 Redux 的其他部分一样,RTKQ 的核心是 UI 独立的,可以与任何 UI 框架一起使用。但是,它还内置了 React 集成,并且可以为每个端点自动生成 React 钩子。这为从 React 组件获取和更新数据提供了熟悉且简单的 API。
RTKQ 提供了一个开箱即用的基于 fetch
的实现,并且与 REST API 完美配合。它也足够灵活,可以与 GraphQL API 一起使用,甚至可以配置为与任意异步函数一起使用,从而允许与 Firebase、Supabase 或您自己的异步逻辑等外部 SDK 集成。
RTKQ 还具有强大的功能,例如端点“生命周期方法”,允许您在缓存条目添加和删除时运行逻辑。这可以用于诸如为聊天室获取初始数据,然后订阅套接字以获取用于更新缓存的额外消息之类的场景。
RTK Query 用例
RTK Query 专为解决服务器状态的数据获取和缓存用例而构建。
RTK Query 权衡
- 👍: 内置于 RTK;无需编写任何代码(thunk、选择器、效果、reducer)来管理数据获取和加载状态;与 TS 完美配合;集成到 Redux 存储的其余部分;内置 React 钩子
- 👎: 故意采用“文档”式缓存,而不是“规范化”;增加一次性额外的捆绑包大小成本
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
}
其他方法
自定义中间件
鉴于 thunk、saga、observable 和监听器都是 Redux 中间件的形式(RTK Query 包含自己的自定义中间件),如果这些工具都不能充分处理您的用例,您始终可以编写自己的自定义中间件。
请注意,我们特别建议不要尝试使用自定义中间件作为管理应用程序大部分逻辑的技术!一些用户尝试创建数十个自定义中间件,每个中间件对应一个特定的应用程序功能。这会增加大量的开销,因为每个中间件都必须作为对 dispatch
的每次调用的部分运行。最好使用通用中间件,例如 thunk 或监听器,其中添加了一个可以处理许多不同逻辑块的单个中间件实例。
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 使用应该放在自定义中间件中,原因如下:
- 中间件在应用程序的生命周期内存在。
- 与存储本身一样,您可能只需要一个给定连接的单个实例,整个应用程序都可以使用它。
- 中间件可以查看所有分派的 action 并分派 action 本身。这意味着中间件可以接收分派的 action 并将其转换为通过 WebSocket 发送的消息,并在通过 WebSocket 接收消息时分派新的 action。
- WebSocket 连接实例不可序列化,因此它不属于存储状态本身
根据应用程序的需求,您可以在中间件初始化过程中创建套接字,在中间件中通过分派初始化 action 按需创建套接字,或者在单独的模块文件中创建它,以便在其他地方访问它。
WebSockets 也可以在 RTK Query 生命周期回调中使用,它们可以通过将更新应用于 RTKQ 缓存来响应消息。
XState
状态机对于定义系统的已知可能状态以及每个状态之间可能的转换非常有用,以及在发生转换时触发副作用。
Redux reducer 可以编写为真正的有限状态机,但 RTK 不包含任何帮助实现此目的的功能。在实践中,它们往往是部分状态机,它们实际上只关心分派的 action 来确定如何更新状态。监听器、saga 和 observable 可用于“分派后运行副作用”方面,但有时可能需要更多工作才能确保副作用仅在特定时间运行。
XState 是一个强大的库,用于定义真正的状态机并执行它们,包括根据事件管理状态转换并触发相关的副作用。它还拥有相关的工具,可以通过图形编辑器创建状态机定义,然后可以加载到 XState 逻辑中以执行。
虽然目前 XState 和 Redux 之间没有官方集成,但可以将 XState 机器用作 Redux reducer,并且 XState 开发人员创建了一个有用的 POC,演示了将 XState 用作 Redux 副作用中间件
更多信息
- 演示文稿:Redux 异步逻辑的演变
- 中间件和副作用的原因
- 文档和教程
- 文章和比较