迁移到 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 重构的实现的可运行示例: