迁移到 RTK Query
- 如何将使用 Redux Toolkit +
createAsyncThunk
实现的传统数据获取逻辑转换为使用 Redux Toolkit Query
概述
Redux 应用中最常见的副作用用例是获取数据。Redux 应用通常使用像 thunk、saga 或 observable 这样的工具来发出 AJAX 请求,并根据请求结果分发操作。然后,Reducer 会监听这些操作来管理加载状态并缓存获取的数据。
RTK Query 专为解决数据获取用例而设计。虽然它不能替代所有使用 thunk 或其他副作用方法的情况,但**使用 RTK Query 应该可以消除大多数手动编写的副作用逻辑的需要**。
RTK Query 预计将涵盖许多用户以前可能使用createAsyncThunk
实现的重叠行为,包括缓存目的和请求生命周期管理(例如isUninitialized
、isLoading
、isError
状态)。
为了将数据获取功能从现有的 Redux 工具迁移到 RTK Query,应该将适当的端点添加到 RTK Query API 切片中,并删除以前的功能代码。这通常不包括两种工具之间保留的许多通用代码,因为它们的工作方式不同,一个将取代另一个。
如果您想从头开始使用 RTK Query,您可能还想查看RTK Query 快速入门
。
示例 - 将数据获取逻辑从 Redux Toolkit 迁移到 RTK Query
使用 Redux 实现简单、缓存、数据获取逻辑的常用方法是使用createSlice
设置一个切片,其中状态包含与查询相关的data
和status
,使用createAsyncThunk
处理异步请求生命周期。下面我们将探讨此类实现的一个示例,以及我们如何随后迁移该代码以使用 RTK Query。
RTK Query 还提供了比下面显示的 thunk 示例创建的更多功能。该示例仅用于演示如何用 RTK Query 替换特定实现。
设计规范
对于我们的示例,工具所需的设计规范如下
- 提供一个钩子,使用 api 获取
pokemon
的数据:https://pokeapi.co/api/v2/pokemon/bulbasaur,其中 bulbasaur 可以是任何宝可梦名称 - 对于任何给定名称的请求,只有在该会话尚未执行过该请求的情况下才会发送。
- 该钩子应为我们提供所提供宝可梦名称的请求的当前状态;无论它是处于“未初始化”、“挂起”、“已完成”还是“拒绝”状态。
- 该钩子应为我们提供所提供宝可梦名称的当前数据。
考虑到上述规范,让我们首先概述一下如何使用createAsyncThunk
结合createSlice
传统地实现它。
使用createSlice
& createAsyncThunk
实现
切片文件
以下三个代码片段构成了我们的切片文件。该文件负责管理我们的异步请求生命周期,以及为给定的神奇宝贝名称存储我们的数据和请求状态。
Thunk 动作创建器
下面我们使用createAsyncThunk
创建一个 thunk 动作创建器,以管理异步请求生命周期。这将在组件和钩子中可用,以便调度,以触发对某些神奇宝贝数据的请求。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 & selectors omitted
切片
下面我们使用createSlice
创建了我们的slice
。我们在这里定义了包含我们的请求处理逻辑的 reducer,根据我们搜索的名称将适当的“状态”和“数据”存储在我们的状态中。
// imports & thunk action creator omitted
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) => {
// When our request is pending:
// - store the 'pending' state as the status for the corresponding pokemon name
builder.addCase(fetchPokemonByName.pending, (state, action) => {
state.statusByName[action.meta.arg] = 'pending'
})
// When our request is fulfilled:
// - store the 'fulfilled' state as the status for the corresponding pokemon name
// - and store the received payload as the data for the corresponding pokemon name
builder.addCase(fetchPokemonByName.fulfilled, (state, action) => {
state.statusByName[action.meta.arg] = 'fulfilled'
state.dataByName[action.meta.arg] = action.payload
})
// When our request is rejected:
// - store the 'rejected' state as the status for the corresponding pokemon name
builder.addCase(fetchPokemonByName.rejected, (state, action) => {
state.statusByName[action.meta.arg] = 'rejected'
})
},
})
// selectors omitted
选择器
下面我们定义了我们的选择器,允许我们稍后访问任何给定神奇宝贝名称的适当状态和数据。
// imports, thunk action creator & slice omitted
export const selectStatusByName = (state: RootState, name: string) =>
state.pokemon.statusByName[name]
export const selectDataByName = (state: RootState, name: string) =>
state.pokemon.dataByName[name]
存储
在我们的应用程序的store
中,我们在状态树的pokemon
分支下包含了来自我们切片的相应 reducer。这使我们的存储能够处理我们将在运行应用程序时调度请求的适当操作,使用之前定义的逻辑。
- TypeScript
- JavaScript
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>
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
},
})
为了使存储在我们的应用程序中可用,我们将使用来自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,
)
自定义钩子
下面我们创建一个钩子来管理在适当的时间发送请求,以及从存储中获取适当的数据和状态。 useDispatch
和 useSelector
来自 react-redux
用于与 Redux 存储进行通信。在我们的钩子结束时,我们将信息以一个整洁的打包对象的形式返回,以便在组件中访问。
- TypeScript
- JavaScript
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()
// select the current status from the store state for the provided name
const status = useSelector((state: RootState) =>
selectStatusByName(state, name)
)
// select the current data from the store state for the provided name
const data = useSelector((state: RootState) => selectDataByName(state, name))
useEffect(() => {
// upon mount or name change, if status is uninitialized, send a request
// for the pokemon name
if (status === undefined) {
dispatch(fetchPokemonByName(name))
}
}, [status, name, dispatch])
// derive status booleans for ease of use
const isUninitialized = status === undefined
const isLoading = status === 'pending' || status === undefined
const isError = status === 'rejected'
const isSuccess = status === 'fulfilled'
// return the import data for the caller of the hook to use
return { data, isUninitialized, isLoading, isError, isSuccess }
}
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useAppDispatch } from './store'
import {
fetchPokemonByName,
selectStatusByName,
selectDataByName,
} from './services/pokemonSlice'
export function useGetPokemonByNameQuery(name) {
const dispatch = useAppDispatch()
// select the current status from the store state for the provided name
const status = useSelector((state) => selectStatusByName(state, name))
// select the current data from the store state for the provided name
const data = useSelector((state) => selectDataByName(state, name))
useEffect(() => {
// upon mount or name change, if status is uninitialized, send a request
// for the pokemon name
if (status === undefined) {
dispatch(fetchPokemonByName(name))
}
}, [status, name, dispatch])
// derive status booleans for ease of use
const isUninitialized = status === undefined
const isLoading = status === 'pending' || status === undefined
const isError = status === 'rejected'
const isSuccess = status === 'fulfilled'
// return the import data for the caller of the hook to use
return { data, isUninitialized, isLoading, isError, isSuccess }
}
使用自定义钩子
我们上面的代码满足了所有设计规范,所以让我们使用它!下面我们可以看到如何在组件中调用钩子,并返回相关数据和状态布尔值。
我们下面的实现为组件提供了以下行为
- 当我们的组件被挂载时,如果还没有为当前会话发送提供的宝可梦名称的请求,则发送请求
- 钩子始终提供最新的接收到的
data
(如果可用),以及请求状态布尔值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 ? (
<>Oh no, there was an error</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}
下面可以看到上面代码的可运行示例
转换为 RTK Query
我们上面的实现确实完全满足了指定的规范,但是,扩展代码以包含更多端点可能会涉及很多重复。它也有一些可能不会立即显而易见的限制。例如,多个组件同时渲染调用我们的钩子,每个组件都会同时发送对妙蛙种子的请求!
下面我们将逐步介绍如何通过将上面的代码迁移到使用 RTK Query 来避免大量样板代码。RTK Query 还将为我们处理许多其他情况,包括在更细粒度的级别上对请求进行去重,以防止发送不必要的重复请求,就像上面提到的那样。
API 切片文件
以下代码是我们的 API 切片定义。它充当我们的网络 API 接口层,使用 createApi
创建。此文件将包含我们的端点定义,createApi
将为我们提供一个自动生成的钩子,该钩子仅在必要时管理触发我们的请求,并为我们提供请求状态生命周期布尔值。
这将完全涵盖我们上面为整个切片文件实现的逻辑,包括 thunk、切片定义、选择器以及我们的自定义钩子!
- TypeScript
- JavaScript
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
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
reducerPath: 'pokemonApi',
endpoints: (build) => ({
getPokemonByName: build.query({
query: (name) => `pokemon/${name}`,
}),
}),
})
export const { useGetPokemonByNameQuery } = api
将 API 切片连接到存储
现在我们已经创建了 API 定义,我们需要将其连接到我们的存储。为此,我们需要使用我们创建的 api
中的 reducerPath
和 middleware
属性。这将允许存储处理生成的钩子使用的内部操作,允许生成的 API 逻辑正确找到状态,并添加管理缓存、失效、订阅、轮询等的逻辑。
- TypeScript
- JavaScript
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 { 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),
})
使用我们的自动生成的钩子
在这个基本层面上,自动生成的钩子的使用与我们的自定义钩子相同!我们只需要更改导入路径,就可以开始了!
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 ? (
<>Oh no, there was an error</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}
清理未使用的代码
如前所述,我们的 api
定义已替换了我们之前使用 createAsyncThunk
、createSlice
和我们的自定义钩子定义实现的所有逻辑。
鉴于我们不再使用该切片,我们可以从我们的存储中删除导入和 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>
我们还可以完全删除整个切片和钩子文件!
- src/services/pokemonSlice.ts (-51 lines)
- src/hooks.ts (-34 lines)
我们现在在不到 20 行代码中重新实现了完整的套件设计规范(以及更多!),并且可以通过在我们的 api 定义中添加额外的端点来轻松扩展。
下面是使用 RTK Query 的重构实现的运行示例