跳到主要内容

 

迁移到 RTK Query

你将学到什么
  • 如何将使用 Redux Toolkit + createAsyncThunk 实现的传统数据获取逻辑转换为使用 Redux Toolkit Query

概述

在 Redux 应用中,最常见的副作用用例是获取数据。Redux 应用通常使用像 thunks、sagas 或 observables 这样的工具来发出 AJAX 请求,并根据请求的结果派发动作。然后,reducers 会监听这些动作来管理加载状态并缓存获取的数据。

RTK Query 是专门为解决数据获取用例而构建的。虽然它不能替代所有你会使用 thunks 或其他副作用方法的情况,但使用 RTK Query 应该能消除大部分手写副作用逻辑的需要

RTK Query 预计将覆盖用户以前可能已经使用 createAsyncThunk 的很多重叠行为,包括缓存目的,和请求生命周期管理(例如 isUninitializedisLoadingisError 状态)。

为了将数据获取特性从现有的 Redux 工具迁移到 RTK Query,应该将适当的端点添加到 RTK Query API 切片中,并删除之前的特性代码。这通常不会包括两者之间保留的大量公共代码,因为这些工具的工作方式不同,一个将替代另一个。

如果你想从头开始使用 RTK Query,你可能也希望查看 RTK Query 快速入门

示例 - 从 Redux Toolkit 迁移到 RTK Query 的数据获取逻辑

一个常用的方法是使用 createSlice 设置一个切片,状态包含查询的相关 datastatus,使用 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 名称的当前数据

考虑到以上规格,让我们首先看一下如何使用 createAsyncThunkcreateSlice 传统地实现这个。

使用 createSlicecreateAsyncThunk 的实现

切片文件

下面的三个片段构成了我们的切片文件。这个文件负责管理我们的异步请求生命周期,以及存储我们的数据和给定 pokemon 名称的请求状态。

Thunk 动作创建器

下面我们使用 createAsyncThunk 创建一个 thunk 动作创建器,以管理异步请求生命周期。这将在组件和钩子中可访问,可以被派发,以便发出对某些 pokemon 数据的请求。createAsyncThunk 本身将处理我们的请求的生命周期方法的派发:pendingfulfilledrejected,我们将在我们的切片中处理这些。

src/services/pokemonSlice.ts - Thunk 动作创建器
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'。

src/services/pokemonSlice.ts - 切片逻辑
// 导入和 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'
})
},
})

// 选择器省略

选择器

下面我们定义了我们的选择器,以便我们稍后能够访问给定宝可梦名称的适当状态和数据。

src/services/pokemonSlice.ts - selectors
// 省略了导入,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/store.ts
// 文件: 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-reduxProvider组件来包裹我们的App组件。

src/index.ts
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获取适当的数据和状态。useDispatchuseSelector 是从 react-redux 中使用的,以便与Redux store进行通信。在我们的钩子的最后,我们返回一个整洁的,打包的对象,以便在组件中访问。

src/hooks.ts
// 文件: 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 }
}

使用自定义钩子

我们上面的代码满足了所有的设计规范,所以让我们使用它!下面我们可以看到如何在一个组件中调用钩子,并返回相关的数据和状态布尔值。

我们下面的实现为组件提供了以下行为:

  • 当我们的组件被挂载时,如果还没有为提供的宝可梦名称发送请求,那么就发送请求
  • 钩子总是在可用时提供最新接收的数据,以及请求状态布尔值isUninitializedisPendingisFulfilledisRejected,以便在任何给定的时刻确定当前的UI作为我们状态的函数。
src/App.tsx
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 定义,选择器, 我们的自定义钩子!

src/services/api.ts
// 文件: 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。为了做到这一点,我们需要使用我们创建的 apireducerPathmiddleware 属性。这将允许 store 处理生成的钩子使用的内部操作,允许生成的 API 逻辑正确地找到状态,并添加管理缓存、失效、订阅、轮询等的逻辑。

src/store.ts
// 文件: 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>

使用我们的自动生成钩子

在这个基本层面上,自动生成的钩子的使用与我们的自定义钩子完全相同!我们只需要改变我们的导入路径就可以了!

src/App.tsx
  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 定义已经替换了我们之前使用 createAsyncThunkcreateSlice 和我们的自定义钩子定义实现的所有逻辑。

鉴于我们不再使用那个 slice,我们可以从我们的 store 中移除导入和 reducer:

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>

我们也可以完全删除 整个 slice 和钩子文件

- src/services/pokemonSlice.ts (-51 行)
- src/hooks.ts (-34 行)

我们现在已经用不到 20 行的代码重新实现了完整的设计规范(甚至更多!),并且可以通过在我们的 api 定义上添加额外的端点来轻松扩展。

下面是我们使用 RTK Query 重构的实现的可运行示例: