迁移到 RTK Query
- 如何将使用 Redux Toolkit +
createAsyncThunk
实现的传统数据获取逻辑转换为使用 Redux Toolkit Query
概述
在 Redux 应用中,最常见的副作用用例是获取数据。Redux 应用通常使用像 thunks、sagas 或 observables 这样的工具来发出 AJAX 请求,并根据请求的结果派发动作。然后,reducers 会监 听这些动作来管理加载状态并缓存获取的数据。
RTK Query 是专门为解决数据获取用例而构建的。虽然它不能替代所有你会使用 thunks 或其他副作用方法的情况,但使用 RTK Query 应该能消除大部分手写副作用逻辑的需要。
RTK Query 预计将覆盖用户以前可能已经使用 createAsyncThunk
的很多重叠行为,包括缓存目的,和请求生命周期管理(例如 isUninitialized
、isLoading
、isError
状态)。
为了将数据获取特性从现有的 Redux 工具迁移到 RTK Query,应该将适当的端点添加到 RTK Query API 切片中,并删除之前的特性代码。这通常不会包括两者之间保留的大量公共代码,因为这些工具的工作方式不同,一个将替代另一个。
如果你想从头开始使用 RTK Query,你可能也希望查看 RTK Query 快速入门
。
示例 - 从 Redux Toolkit 迁移到 RTK Query 的数据获取逻辑
一个常用的方法是使用 createSlice
设置一个切片,状态包含查询的相关 data
和 status
,使用 createAsyncThunk
来处理异步请求生命周期。下面我们将探讨这样一个实现的例子,以及我们如何可以后来将该代码迁移到 RTK Query。
RTK Query 还提供了比下面显示的 thunk 示例创建的更多功能。这个例子只是为了演示如何用 RTK Query 替换特定的实现。
设计规格
对于我们的例子,工具所需的设计规格如下:
- 提供一个钩子来使用 api 获取
pokemon
的数据:https://pokeapi.co/api/v2/pokemon/bulbasaur,其中 bulbasaur 可以是任何 pokemon 名称 - 对任何给定的名称的请求只应在尚未为该会话发送过的情况下发送
- 钩子应为我们提供供应的 pokemon 名称的当前请求状态;无论它是处于 'uninitialized'、'pending'、'fulfilled' 还是 'rejected' 状态
- 钩子应为我们提供供应的 pokemon 名称的当前数据
考虑到以上规格,让我们首先看一下如何使用 createAsyncThunk
和 createSlice
传统地实现这个。
使用 createSlice
和 createAsyncThunk
的实现
切片文件
下面的三个片段构成了我们的切片文件。这个文件负责管理我们的异步请求生命周期,以及存储我们的数据和给定 pokemon 名称的请求状态。
Thunk 动作创建器
下面我们使用 createAsyncThunk
创建一个 thunk 动作创建器,以管理异步请求生命周期。这将在组件和钩子中可访问,可以被派发,以便发出对某些 pokemon 数据的请求。createAsyncThunk
本身将处理我们的请求的生命周期方法的派发:pending
、fulfilled
和 rejected
,我们将在我们的切片中处理这些。
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import type { Pokemon } from './types'
import type { RootState } from '../store'
export const fetchPokemonByName = createAsyncThunk<Pokemon, string>(
'pokemon/fetchByName',
async (name, { rejectWithValue }) => {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`)
const data = await response.json()
if (response.status < 200 || response.status >= 300) {
return rejectWithValue(data)
}
return data
},
)
// 切片和选择器省略
切片
下面我们有我们的 slice
,使用 createSlice
创建。我们在这里定义了包含请求处理逻辑的 reducers,根据我们搜索的名称在我们的状态中存储适当的 'status' 和 'data'。
// 导入和 thunk 动作创建器省略
type RequestState = 'pending' | 'fulfilled' | 'rejected'
export const pokemonSlice = createSlice({
name: 'pokemon',
initialState: {
dataByName: {} as Record<string, Pokemon | undefined>,
statusByName: {} as Record<string, RequestState | undefined>,
},
reducers: {},
extraReducers: (builder) => {
// 当我们的请求处于 pending 状态 时:
// - 将 'pending' 状态存储为对应 pokemon 名称的状态
builder.addCase(fetchPokemonByName.pending, (state, action) => {
state.statusByName[action.meta.arg] = 'pending'
})
// 当我们的请求被 fulfilled 时:
// - 将 'fulfilled' 状态存储为对应 pokemon 名称的状态
// - 并将接收到的 payload 存储为对应 pokemon 名称的数据
builder.addCase(fetchPokemonByName.fulfilled, (state, action) => {
state.statusByName[action.meta.arg] = 'fulfilled'
state.dataByName[action.meta.arg] = action.payload
})
// 当我们的请求被 rejected 时:
// - 将 'rejected' 状态存储为对应 pokemon 名称的状态
builder.addCase(fetchPokemonByName.rejected, (state, action) => {
state.statusByName[action.meta.arg] = 'rejected'
})
},
})
// 选择器省略
选择器
下面我们定义了我们的选择器,以便我们稍后能够访问给定宝可梦名称的适当状态和数据。
// 省略了导入,thunk action创建器和切片
export const selectStatusByName = (state: RootState, name: string) =>
state.pokemon.statusByName[name]
export const selectDataByName = (state: RootState, name: string) =>
state.pokemon.dataByName[name]
存储
在我们的应用的store
中,我们包含了来自我们切片的相应的reducer,这个reducer在我们的状态树的pokemon
分支下。这让我们的store能够处理我们在运行应用时将要派发的请求的适当的动作,使用之前定义的逻辑。
// 文件: src/services/pokemonSlice.ts noEmit
import type { Reducer } from '@reduxjs/toolkit'
declare const reducer: Reducer<{}>
export const pokemonSlice = {
reducer,
}
// 文件: src/store.ts
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
},
})
export type RootState = ReturnType<typeof store.getState>
为了让我们的应用能够访问到store,我们将用react-redux
的Provider
组件来包裹我们的App
组件。
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import App from './App'
import { store } from './store'
const rootElement = document.getElementById('root')
render(
<Provider store={store}>
<App />
</Provider>,
rootElement,
)
自定义钩子
下面我们创建一个钩子来管理在适当的时间发送我们的请求,以及从store获取适当的数据和状态。useDispatch
和 useSelector
是从 react-redux
中使用的,以便与Redux store进行通信。在我们的钩子的最后,我们返回一个整洁的,打包的对象,以便在组件中访问。
// 文件: src/services/pokemonSlice.ts noEmit
import { AsyncThunkAction } from '@reduxjs/toolkit'
import { RootState } from '../store'
interface Pokemon {}
export declare const fetchPokemonByName: (
arg: string,
) => AsyncThunkAction<Pokemon, string, {}>
export const selectStatusByName = (state: RootState, name: string) =>
state.pokemon.statusByName[name]
export const selectDataByName = (state: RootState, name: string) =>
state.pokemon.dataByName[name]
// 文件: src/store.ts noEmit
import { useDispatch } from 'react-redux'
import { EnhancedStore } from '@reduxjs/toolkit'
interface Pokemon {}
type RequestState = 'pending' | 'fulfilled' | 'rejected'
const initialPokemonSlice = {
dataByName: {} as Record<string, Pokemon | undefined>,
statusByName: {} as Record<string, RequestState | undefined>,
}
export type RootState = {
pokemon: typeof initialPokemonSlice
}
export declare const store: EnhancedStore<RootState>
export type AppDispatch = typeof store.dispatch
export declare const useAppDispatch: () => (...args: any[]) => any
// 文件: src/hooks.ts
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useAppDispatch } from './store'
import type { RootState } from './store'
import {
fetchPokemonByName,
selectStatusByName,
selectDataByName,
} from './services/pokemonSlice'
export function useGetPokemonByNameQuery(name: string) {
const dispatch = useAppDispatch()
// 从store状态中选择提供的名称的当前状态
const status = useSelector((state: RootState) =>
selectStatusByName(state, name),
)
// 从store状态中选择提供的名称的当前数据
const data = useSelector((state: RootState) => selectDataByName(state, name))
useEffect(() => {
// 在挂载或名称改变时,如果状态未初始化,发送一个请求
// 对于宝可梦的名称
if (status === undefined) {
dispatch(fetchPokemonByName(name))
}
}, [status, name, dispatch])
// 为了方便使用,派生状态布尔值
const isUninitialized = status === undefined
const isLoading = status === 'pending' || status === undefined
const isError = status === 'rejected'
const isSuccess = status === 'fulfilled'
// 返回重要的数据,供钩子的调用者使用
return { data, isUninitialized, isLoading, isError, isSuccess }
}
使用自定义钩子
我们上面的代码满足了所有的设计规范,所以让我们使用它!下面我们可以看到如何在一个组件中调用钩子,并返回相关的数据和状态布尔值。
我们下面的实现为组件提供了以下行为:
- 当我们的组件被挂载时,如果还没有为提供的宝可梦名称发送请求,那么就发送请求
- 钩子总是在可用时提供最新接收的
数据
,以及请求状态布尔值isUninitialized
,isPending
,isFulfilled
和isRejected
,以便在任何给定的时刻确定当前的UI作为我们状态的函数。
import * as React from 'react'
import { useGetPokemonByNameQuery } from './hooks'
export default function App() {
const { data, isError, isLoading } = useGetPokemonByNameQuery('bulbasaur')
return (
<div className="App">
{isError ? (
<>哦,出错了</>
) : isLoading ? (
<>加载中...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}
上述代码的可运行示例可以在下面看到:
转换为 RTK Query
我们上面的实现完全满足了指定的需求,然而,如果要扩展代码以包含更多的端点,可能会涉及到大量的重复。它也有一些可能不立即显现的限制。例如,同时渲染的多个组件调用我们的钩子,每个都会同时发送一个请求获取 bulbasaur!
下面我们将介绍如何通过将上述代码迁移到 RTK Query 来避免大量的样板代码。RTK Query 还会为我们处理许多其他情况,包括在更细粒度的层面上去重请求,以防止发送不必要的重复请求,就像上面提到的那样。
API Slice 文件
我们下面的代码是我们的 API slice 定义。这充当我们的网络 API 接口层 ,并使用 createApi
创建。这个文件将包含我们的端点定义,createApi
将为我们提供一个自动生成的钩子,该钩子管理在必要时触发我们的请求,以及为我们提供请求状态生命周期布尔值。
这将完全覆盖我们上面为整个 slice 文件实现的逻辑,包括 thunk,slice 定义,选择器,和 我们的自定义钩子!
// 文件: types.ts noEmit
export interface Pokemon {}
// 文件: api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
reducerPath: 'pokemonApi',
endpoints: (build) => ({
getPokemonByName: build.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
}),
}),
})
export const { useGetPokemonByNameQuery } = api
将 API slice 连接到 store
现在我们已经创建了我们的 API 定义,我们需要将它连接到我们的 store。为了做到这一点,我们需要使用我们创建的 api
的 reducerPath
和 middleware
属性。这将允许 store 处理生成的钩子使用的内部操作,允 许生成的 API 逻辑正确地找到状态,并添加管理缓存、失效、订阅、轮询等的逻辑。
// 文件: src/services/pokemonSlice.ts noEmit
import type { Reducer } from '@reduxjs/toolkit'
declare const reducer: Reducer<{}>
export const pokemonSlice = {
reducer,
}
// 文件: src/services/api.ts noEmit
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
interface Pokemon {}
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (builder) => ({
getPokemonByName: builder.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
}),
}),
})
// 文件: src/store.ts
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
import { api } from './services/api'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (gDM) => gDM().concat(api.middleware),
})
export type RootState = ReturnType<typeof store.getState>
使用我们的自动生成钩子
在这个基本层面上,自动生成的钩子的使用与我们的自定义钩子完全相同!我们只需要改变我们的导入路径就可以了!
import * as React from 'react'
- import { useGetPokemonByNameQuery } from './hooks'
+ import { useGetPokemonByNameQuery } from './services/api'
export default function App() {
const { data, isError, isLoading } = useGetPokemonByNameQuery('bulbasaur')
return (
<div className="App">
{isError ? (
<>哦,出错了</>
) : isLoading ? (
<>加载中...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}
清理未使用的代码
如前所述,我们的 api
定义已经替换了我们之前使用 createAsyncThunk
、createSlice
和我们的自定义钩子定义实现的所有逻辑。
鉴于我们不再使用那个 slice,我们可以从我们的 store 中移除导入和 reducer:
import { configureStore } from '@reduxjs/toolkit'
- import { pokemonSlice } from './services/pokemonSlice'
import { api } from './services/api'
export const store = configureStore({
reducer: {
- pokemon: pokemonSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (gDM) => gDM().concat(api.middleware),
})
export type RootState = ReturnType<typeof store.getState>
我们也可以完全删除 整个 slice 和钩子文件!
- src/services/pokemonSlice.ts (-51 行)
- src/hooks.ts (-34 行)
我们现在已经用不到 20 行的代码重新实现了完整的设计规范(甚至更多!),并且可以通过在我们的 api 定义上添加额外的端点来轻松扩展。
下面是我们使用 RTK Query 重构的实现的可运行示例: