Redux 还活着吗?
随着 SPA 应用的出现,前端需要管理大量的客户端状态与异步数据获取状态。大量的状态管理逻辑与 UI 交互逻辑耦合,项目变得难以维护。
Redux 的出现相当程度的缓解了这一现象。在 Redux 的核心思想中,动作是改变状态的唯一因素 提高了我们的状态的可控性,加之 时间旅行 的特性,可以很方便的调试应用状态。
动作可以理解为
命令与事件,一个动作可能会影响多个领域。如果你了解过 CQRS 与 Event Sourcing,你可能会觉得这些概念很相似
Redux 的诟病也很明显,那就是样板代码太多、文档难以理解,缺少开箱即用的工具。“我仅仅是想发送一个请求,但我却需要在好几个文件中穿越,编写大量的代码”。因此,也越来越多的人开始选择 Mobx 进行状态管理。
在 Redux 作者 Dan 的提议下,Redux Toolkit(简称 RTK)作为官方推荐的最佳实践出现了。样板代码的减少,Slice 概念的出现,加之完善的开发者工具,我们可以十分容易的编写可维护的客户端状态。
异步状态的难题
尽管我们可以编写易于维护的客户端状态,但我们发现,如果要编写一个完善的从服务器加载数据的逻辑,即使有 createAsyncThunk 的帮助,我们仍需手写大量的代码。
- 从服务器加载数据
- 缓存数据,避免重复请求
- 在合适的时机使缓存失效
- 数据加载指示器
- 异常错误处理
- 在不同组件间共享
- 分页、延迟加载等复杂逻辑
- GC,回收不使用的数据
- 尽可能早的在用户界面上反映数据更新
- ......
仅仅是一个简单的请求,却可能引出大量复杂的逻辑。
在内部的 toB 应用或许还好,每次涉及到数据访问重新获取即可,逻辑也十分简单。但面向市场,用户体验十分重要,因此上述列表中的随便几点可能就能将我们压垮。
RTK Query 是什么
随着 react-query 库的出现,React 社区意识到,客户端状态管理、异步数据获取与缓存,是两个不同的关注点,Redux 社区也意识到了这一点,RTK Query 基于 Redux 与 RTK,重点关注数据获取与缓存逻辑,目的是解决大部分的数据获取与缓存的常用用例。
入门
你可以在 codesandbox 观察 RTK Query 的缓存行为。
使用 RTK Query 很简单,第一步是定义端点,以声明的方式描述。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Pokemon } from './types'
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (builder) => ({
// 定义端点
getPokemonByName: builder.query<Pokemon, string>({
// name 是参数,生成传递给 fetch 的参数
query: (name) => `pokemon/${name}`,
}),
}),
})
// 自动生成的 React Hooks
export const { useGetPokemonByNameQuery } = pokemonApi
很神奇的特性,RTK Query 根据端点的名称生成的 useQuery hook,并且具有良好的 TypeScript 代码提示!
然后就可以在 React 中使用了:
function App() {
const [name, setName] = useState<string>('xxx')
const {
// 数据
data,
// 错误
error,
// 是否正在加载数据(没有缓存过,第一次获取)
isLoading,
// 是否正在获取数据
isFetching,
// 是否成功
isSuccess,
// 是否有错误
isError,
// 重新获取数据的函数
refetch,
} = useGetPokemonByNameQuery(name)
if (isLoading) {
return <div>loading...</div>
}
if (isError) {
return <div>查询出错:{error.message}</div>
}
if (!data) {
return null
}
return (
<div>
<button onClick={refetch}>重新加载宝可梦数据</button>
<Pokemon pokemon={data} />
</div>
)
}
缓存策略
什么是相同查询
RTK Query 会将查询查询参数序列化为字符串,并将相同端点、相同参数的查询视为相同的查询,他们将共享一个请求与缓存数据。
因此,下面两个调用返回结果相同(即使在不同的组件中):
useGetXXXQuery({ a: 1, b: 2 }) // 订阅 + 1
useGetXXXQuery({ b: 2, a: 1 }) // 订阅 + 1
// ...
这是因为:
- 他们使用相同的查询:GetXXX
- 查询参数的序列化结果相同:
'{"a":1,"b":2}'
你不需要担心嵌套或是字段顺序,或是不同对象不同引用会被认为是不同的查询,因为 RTK Query 已经在默认的序列化函数中处理了相关用例。同时,你也可以提供自己的序列化函数。
如图,这些妙蛙种子组件中都使用了 useGetPokemon('bulbasaur') ,所以他们将共享同一份数据,并且再次添加新的妙蛙种子时,通过开发者工具可以看到请求被条件取消,因为妙蛙种子的数据已经被加载,在一定的生命周期中可以直接复用缓存。
引用计数与垃圾回收
当在组件中使用某个查询时,该查询的引用计数会 + 1,当该组件被卸载时,引用计数会 -1。当一个查询的引用计数为 0 时,说明没有任何组件在使用这个查询。此时,经过 keepUnusedDataFor(默认为 30 )秒后,如果缓存仍为被使用过,那么他将被从缓存中移除。
缓存标签
缓存标签可以说是 RTK Query 缓存的核心之一,利用标签,可以实现自动缓存失效,自动重新获取失效数据 等特性。
对于某个 查询,我们可以为其提供一个标签,如:
{
type: 'Pokemon',
id: 'xxx',
}
然后,我们可以通过 突变 使这个标签失效,例如,我们使用突变 UpdatePokemonByName({ name:'xxx', ...body }) 告知 RTK Query 提供了上面这个标签的所有查询缓存都失效了,如果 GetPokemons() 和 GetPokemonByName('xxx') 两个查询均提供了这个标签,那么这两个查询会自动重新获取数据。
例子
在端点定义中,有两种类型的端点:
- 查询:获取并缓存数据,如
GetPokemonByName - 突变:更改数据的动作,可能使缓存无效,如
UpdatePokemonByName
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
// 声明标签类型
tagTypes: ['Pokemon'],
endpoints: (builder) => ({
// 定义查询
getPokemonByName: builder.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
// 查询接口提供的标签
providesTags: (pokemon) => [{ type: 'Pokemon', id: pokemon.name }],
}),
// 定义突变
updatePokemonByName: builder.mutation<Pokemon, Partial<Pokemon>>({
query(data) {
const { name, ...body } = data
return {
url: `post/${name}`,
method: 'post',
body,
}
},
// 更新会使提供了这些标签的查询失效...
invalidatesTags: (result, error, arg) => [
{ type: 'Pokemon', id: arg.name },
],
}),
}),
})
通过标签系统,RTK Query 做到了数据失效时机的声明以及自动的重新获取数据。
时间旅行
因为 RTK Query 基于 Redux,所以可以直接使用 Redux DevTools 观察数据获取与缓存行为,同时你可以轻松回到任意时间点,观察组件的运行状态是否正常,例如查看 Loading 状态的指示器样式是否正常。
例如上图,从最终态跳转到皮卡丘的加载态,可以看到按钮变成了 Fetching...
更多例子
RTK Query 提供了一些常见用例的在线 CodeSandbox,可以在这个网址查看:Examples
学习 RTK Query
如果这篇文章让你对 RTK Query 产生了好奇,你可以通过 RTK Query Overview 文档进行深入学习,探索 RTK Query 剩余的 90% 的特性。