异步数据的状态管理:React-Query
2022-11-11 发布2,312 次点击2 条评论需阅读36分钟

异步数据的状态管理:React-Query


本文首发于个人博客
在自己博客中也用到了 React-Query,然后再看到公司项目是使用的 Mobx,发现了所有的状态以及逻辑处理都放在了 mobx 中,整体看起来比较乱,不是很好管理,然后想着能不能把 React-Query 应用到公司项目中,在最近的一次需求中,也是成功使用 React-Query 来管理接口请求的数据,成功的把 Mobx 中的很多状态全部都删除了(600 行 -> 200 行),我想这是 React-Query 的胜利。
所以本文主要是来说一说,React-Query 是什么? 为什么要用它?用它有什么好处?

状态

在讲解 React-Query 之前,我们来谈论一个老生常谈的问题: 状态管理。 它一直是我认为是前端内最难的一个问题,本地状态如何管理,兄弟组件之间状态如何共享,该不该把这个状态放到全局,整个数据流又是什么样子的,这一系列问题,在写代码的时候无时无刻不在考虑。

全局状态

说到状态管理就不得不说一下全局状态,一些需要在全局共享的数据,比如说当前用户的登陆信息、主题等等一些状态,而对于需要全局共享的状态,我们可能会采用 Redux、Mobx 或者使用 Context 的方式来进行数据的共享,可以避免在不同层级的组件进行 props 的读取和共享数据。
优点:
  1. 解决 props 钻取,不同层级组件状态共享的问题。
  2. 可以单独读写全局状态数据。
  3. 在不同页面组件和 hook 之间进行通信。
一个刻意为之的小 ?:
const GlobalContext = createContext({
theme: 'dark',
user: {},
list: [],
fetching: 'idle',
error: null,
setUser: (user) => {},
fetch: () => {},
});
const App = () => {
return (
<GlobalContext.Provider>
<Page />
</GlobalContext.Provider>
);
};
const Page = () => {
return <Container />;
};
const Container = () => {
return <Content />;
};
const Content = () => {
const { user } = useContext(GlobalContext);
return user ? <div>{user}</div> : 'need login';
};

成也全局状态、败也全局状态

如果我们把所有的状态都放在全局,对于越来越庞大的全局状态会有什么问题呢? 再来看一个真实小 ?。
xxx:森林,详情页接口的时间戳传错了,你那边看一下。
我:好的,我这边排查一下。
然后开始了半个小时的看代码以及 console.log 之旅,半个小时后,终于找到问题的原因了。
我从 Page -> List -> Card -> DetailDrawer -> DateRanger 一路打console.log,最后发现了这个 bug 的原因所在。
在某一个组件(Card)内部设置了代码,两行代码写反了。
const Card = () => {
const handleClick = () => {
// mobx代码
globalState.updateDate();
globalState.resetState();
};
return <button onClick={handleClick}>详情页</button>;
};
数据流转不清晰:
其次就是全局状态优点很明显,任何地方都可以使用全局状态,避免了 props 钻取,但是缺点也很明显,数据流转不清晰,因为它是全局的,这就意味着在哪个组件都可以修改它,永远不知道在某一个组件的某一段更新全局状态的代码会有问题,所以 debug 的方式就是一堆 console.log 或者查看 devtools(如果有的话)。
逻辑复杂:
通过全局状态共享其他页面、组件需要使用的状态,这看起来没有问题,当一个列表数据需要多个页面使用时,没有办法,只能放到全局状态,但是应用程序变得越来越庞大的时候,我们可能会将所有的数据都放到全局(UI 部分、服务端请求数据这一部分),所以这个时候全局状态需要处理 UI 交互逻辑以及请求接口的逻辑,往往一个文件导致非常庞大,一个文件写着写着就几百行代码的逻辑(控制某一个抽屉的 state,前端请求后端的 list,详情页的数据 detail)。
牵一发而动全身:
我们一般会写全局方法来更新全局状态(action -> state),所以对其一些全局方法修改的时候,需要花费额外的精力去测试,因为可能其他组件也用到了这个全局方法来更新某一个全局状态,需要在保证之前代码的基础上还要保证新功能的正确性,增加代码修改的复杂度以及添加了心智负担。
global-state-flow
global-state-flow
全局状态没有问题,但是全局状态的滥用,以及没有制定相关的标准,随着应用程序越来越大,上面的缺点也会越来越明显。 所以我将状态拆分为: 客户端状态和服务端状态。
而区分这两个就是其数据来源: 一个来自于前端、一个来自于后端。

客户端状态

客户端状态:很好理解,它可以一个 checkbox 的 value,也可以是一个简单的主题切换等等。
特点:
  1. 不需要持久化。
  2. 没有任何延时,同步更新。
  3. 仅在客户端使用。
const ThemeSwith = () => {
const [theme,setTheme] = useState('light');
return (
<div>
<button onClick={() => setTheme(v => (v === 'light' ? 'dark''light'))}>
{theme === 'light' ? '浅色''深色'}
</button>
</div>
);
};
一个小栗子,上面的 theme 就是一个客户端状态。

服务端状态

服务端状态一般就是发送请求,将接口请求拿到的数据存到 state 里面,这个 state 相当于接口请求数据的一个快照,需要进行 UI 上的数据展示。
特点:
  1. 需要持久化。
  2. 异步更新。
  3. 服务端数据的一个快照。
const App = () => {
const [list,setList] = useState([]);
useEffect(() => {
fetch('xxx').then(async res => {
const data = await res.json();
setList(data);
});
}[]);
return (
<ul>
{list.map(item => (
<li>{item}</li>
))}
</ul>
);
};

React-Query

通过前面状态那一小节的铺垫,终于来到了 React-Query 的环节。
一句话总结:我们使用 Redux、Mobx 是用来解决客户端状态的话,可以理解 React-Query 用来解决服务端状态更加友好。

它是什么?

引用官方文档:
React-Query 被称为 React 中缺少的数据获取的代码库,它使 React 应用中获取、缓存、同步和更新服务器状态变得轻而易举。
React-Query 并没有规定数据获取方式,只要数据获取是一个返回Promise的函数就可以了,它把数据获取的选择权交到开发人员手中,所以我们可以使用 axios、fetch、graphql,这取决于开发者。
这通常意味着使用 React Hook 将基于组件的状态和效果组合在一起,或者使用更通用的状态管理库来存储和提供整个应用程序中的异步数据。

queryKey

在介绍 React-Query 如何使用之前,先介绍一个概念:queryKey,在提供的 useQuery(queryKey,fetchFn,options?) hook 中,第一个参数是 queryKey,它是由数组组成的,可以理解成为它是每一个 query 的唯一 id,我们可以通过这个唯一 id 进行读写操作。
它可以是静态的:
const query = useQuery(['todos'], () => fetch('xxx'));
它也可以是动态的:
const query = useQuery(['todo', id], () => fetch('xxx'));
queryKey 是 query 的一个依赖项(类似于 useEffect 的依赖项),所以每当 queryKey 改变的时候,都会重新执行这个 fetchFn 请求接口刷新数据。

使用 React-Query 来实现 Todo

通过实现一个 Todo 功能: 创建 Todo、删除 Todo 以及完成 Todo 三个操作,熟悉 React-Query 的基本使用。

初始化一个 QueryClient

  1. 使用npm install @tanstack/query
  2. 创建一个客户端实例。
  3. 将实例使用Context(QueryClientProvider)的方式提供给整个 App。
  4. 安装一个@tanstack/react-query-devtools开发者工具。
// App.jsx
import { QueryClient,QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// 创建一个客户端实例
const queryClient = new QueryClient();
function App() {
return (
// 将实例使用Context的方式提供给整个App
<QueryClientProvider client={queryClient}>
<div>...</div>
<ReactQueryDevtools />
</QueryClientProvider>
);
}
通过以上代码,我们就初始化了一个 Query 的客户端,然后就可以在开发的时候看到所有的 query 查询以及相对应的状态。

TodoList 组件

TodoList 组件用来展示 todo 列表,我们需要进行请求接口拿到 todos 数据然后进行一个展示。
所以我们需要创建一个获取 todo 的 fetchFn,这里新建了一个 api 文件夹进行统一的 api 管理。
// api/todo.js
const API_PATH = 'https://jsonplaceholder.typicode.com';
export const getTodos = () =>
fetch(API_PATH + '/posts').then((res) => res.json());
将 Todo 封装成一个组件,因为后面需要进行切换、删除等操作。
// TodoItem.jsx
const TodoItem = ({ todo }) => {
// TODO:
const handleDelete = () => {};
// TODO:
const handleToggle = () => {};
return (
<li>
<input type="checkbox" value={todo.completed} onChange={handleToggle} />
<span>{todo.title}</span>
<button onClick={handleDelete}>删除</button>
</li>
);
};
export default TodoItem;
做好了这些前置工作之后,在TodoList.jsx组件使用useQuery传入getTodos函数进行一个 todo 列表的展示。
// TodoList.jsx
import { useQuery } from '@tanstack/React-Query';
import { getTodos } from '../api/todo';
import TodoItem from './TodoItem';
const TodoList = () => {
const { data = [], isLoading } = useQuery(['todos'], () => getTodos());
if (isLoading) {
return <div>Loading...</div>;
}
return (
<ul>
{data.map((item) => (
<TodoItem todo={item} />
))}
</ul>
);
};
export default TodoList;
这里使用了useQuery这个 hook 来进行数据的获取:
  1. queryKey: ['todos']
  2. fetchFn: () => getTodos()
然后它会返回一个对象,包含了几个常用的属性:
  1. data: 数据。
  2. isLoading: 是否加载态。
  3. isError: 是否错误态。
  4. ...
最后在App.jsx里面使用这个组件。
import { QueryClient, QueryClientProvider } from '@tanstack/React-Query';
import TodoList from './components/TodoList';
const queryClient = new QueryClient();
function App() {
return (
// 将实例使用Context的方式提供给整个App
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>
);
}

添加 Todo

下一步就是实现创建一个 todo。
先把页面画出来:
// CreateTodo.jsx
import { useState } from 'react';
const CreateTodo = ({ onCreate }) => {
const [value, setValue] = useState('');
const createTodo = () => {};
const handleKeyUp = (e) => {
if (e.key === 'Enter') {
createTodo();
}
};
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyUp={handleKeyUp}
/>
<button onClick={createTodo}>添加</button>
</div>
);
};
export default CreateTodo;
通过以上代码就创建了 UI 层,现在要做的就是如果实现逻辑呢? 所有关于更新、删除和添加的操作都是突变,所以使用useMutation这个 hook,来看看createTodo这个函数怎么写。
同样添加 Todo 也需要请求接口,所以需要在api/todo.js中添加一个 fetchFn。
// api/todo.js
// 省略相同代码...
export const createTodo = body =>
fetch(API_PATH + '/todo'{
method: 'POST',
body: JSON。stringify(body),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});
有了这样子的 fetchFn 函数了,再使用useMutation来实现一个突变。
import { createTodo as createTodoApi } from '../api/todo';
const mutation = useMutation((body) => createTodoApi(body));
useMutation 这个 hook 会返回一个 mutation 对象,然后mutation它有以下一些属性:
  1. mutate: 同步调用 mutate 函数。
  2. mutateAsync: 异步调用 mutate 函数。
  3. isLoading: 是否加载态。
  4. isError: 是否错误态。
  5. ...
所以在createTodo这个方法里面就可以直接调用 mutation.mutate/mutateAsync 函数。
const createTodo = () => {
mutation.mutate({
userId: '1',
title,
completed: false,
});
};
mutate 传递的参数会透传给 fetchFn,也就是 createTodoApi 函数。

更新列表页

然后这里思考一点,创建一个新 todo 的时候,列表页需要进行更新数据,如果不使用 React-Query 的情况下,有两种方式:
  1. 不请求接口,直接将 create 的返回值添加到列表数组中。
const createTodo = async () => {
const newTodo = await createTodoApi();
setList((prevTodos) => prevTodos.concat(newTodo));
};
  1. 重新请求一次列表接口。
const createTodo = async () => {
const newTodo = await createTodoApi();
const newTodos = await getTodos();
setList(newTodos);
};
如果在 React-Query 当中如何更新列表页的数据呢?
  1. 通过useQueryClient拿到 query 客户端。
  2. 在 useMutation 中第三个参数有一个 onSuccess 选项,表示成功的回调,在里面编写其请求成功的逻辑。
然后有两种方式:
  1. 不想请求接口: 使用 setQueryData(queryKey, newData) 方法,对指定的 query 设置数据,第一个参数是 queryKey,第二个参数是新数据或者一个函数prevData => newData
  2. 想要请求接口: 使用queryClient.invalidateQueries(queryKey),传入需要重新认证的 queryKey,React-Query 会把指定的 query 状态设置成失效,然后重新请求接口拉取数据。
const queryClient = useQueryClient(); // 拿到query客户端
const mutation = useMutation((body) => createTodoApi(body), {
onSuccess: (data, body) => {
// 不请求接口
queryClient.setQueryData(['todos'], (prevTodos) =>
prevTodos.concat({
...data,
id: prevTodos.length + 1,
})
);
// 请求接口
// queryClient。invalidateQueries(["todos"]);
},
});
这里通过不请求接口的方式,来更新列表页的数据(具体想不想请求接口,选择权在你)。

如何处理 UI 侧逻辑呢?

如果还需要处理 UI 侧逻辑呢? 比如说在添加成功之后弹出一个 toast 显示创建成功,这个关于 UI 侧的逻辑应该放在哪里呢?
mutate方法支持第二个参数:
const useCreateTodo = () =>
useMutation((body) => createTodoApi(body), {
onSuccess: () => {
queryClient.invalidateQueries(['todos', 'list']);
},
});
// 组件里面使用
const createTodo = useCreateTodo();
createTodo.mutate(newTodo, {
// 当mutate完成并且还在当前页面时才会触发
onSuccess: () => {
alert('创建成功');
},
});
如果对 useMutation 以及 mutate 本身添加回调。 useMutation 的回调会在 mutate 上面的回调之前触发,如果组件在突变完成之前卸载了,那么 mutate 上面的回调可能根本不会触发。
这有两个好处:
  1. 在 useMutation 回调中执行绝对必要且与逻辑相关的事情(如查询失效,更新数据) 。
  2. 在 mutate 回调中做 UI 相关的事情,比如重定向或显示 toast 通知。如果用户在突变完成之前离开当前屏幕,那么该回调就不会触发。
如果 useMutation 来自自定义钩子,则这种分离尤其简洁,因为这会将查询相关逻辑保留在自定义钩子中,而 UI 相关操作仍在 UI 中。
这也使自定义钩子重用性更高,因为我们与 UI 交互的方式可能会因具体情况而异,可能是一个 message 或者是一个 toast,但失效逻辑可能总是相同的。
useMutation 和 mutate 的 callback 并不是覆盖的关系,而是两个都会被调用,并且会先调用 useMutation 的 callback 然后再执行 mutate 中的 callback。

删除 Todo

有了上面创建 Todo 的基础,我们来依葫芦画瓢写一个删除 Todo。
先来创建一个删除 Todo 的 fetchFn。
// api/todo.js
export const deleteTodo = id =>
fetch(API_PATH + '/todos' + id,{
method: 'DELETE',
})。then(res => res.json());
然后使用 useMutation 这个 hook,传入对应的 fetchFn 即可。
// TodoItem.jsx
import { useMutation,useQueryClient } from "@tanstack/React-Query";
import { deleteTodo as deleteTodoApi } from "../api/todo";
const TodoItem = ({ todo }) => {
const queryClient = useQueryClient();
const deleteTodo = useMutation(
(id) => deleteTodoApi(id),
{
onSuccess: (_,id) => {
// 不请求接口
queryClient.setQueryData(['todos'],(prevData) => prevData.filter(data => data.id !== id))
// 请求接口
// queryClient.invalidateQueries(['todos'])
}
}
);
const handleDelete = () => {
deleteTodo.mutate(todo.id);
};
return (
// ...
);
};
这里需要注意的是,onSuccess(data,variables,context)这个函数支持三个参数,在这里我们需要读取传入变量,所以在第二个参数中可以读取到这个todo.id,然后在使用setQueryData在指定的 query 中把它删除掉。

完成 Todo

完成某一个 Todo 也是如此,这里可以贴一下代码。
import { deleteTodo as deleteTodoApi,patchTodo as patchTodoApi } from '../api/todo';
import { useMutation,useQueryClient } from '@tanstack/React-Query';
const TodoItem = ({ todo }) => {
const queryClient = useQueryClient();
const patchTodo = useMutation(body => patchTodoApi(body){
onSuccess: (_,{ id,completed }) => {
// 不请求接口
queryClient.setQueryData(['todos'],prevData =>
prevData.map(data => {
if (data.id === id) {
return { ...data,completed };
}
return data;
})
);
// 请求接口
// queryClient.invalidateQueries(['todos']);
},
});
const handleToggle = e => {
const completed = e.target.checked;
patchTodo.mutate({ id: todo.id,completed });
};
return (
<li>
<input type='checkbox' value={todo.completed} onChange={handleToggle} />
{/* ... */}
</li>
);
};
export default TodoItem;
这就是完成 Todo 的代码,你会发现它很简单,可以在这里看到所有的代码。

小小优化

自定义 Hook

OK,正如上面所说的,我们可以基于useMutationuseQuery两个 Hook 的基础上制造新的 Hook,我觉得这样子有一个好处就是,可以把这个 Hook 当成一个数据源,比如说useTodos(),通过调用这个 Hook,就可以拿到 todo 列表页的数据,而其他调用者并不需要关心这个 hook 内部是如何实现和设计的, 而使用useDeleteTodo()这个 hook,就实现了删除操作,将视图和数据逻辑分隔开,这本质上是 hook 的胜利。
所以在完成了上面的基本操作之后,来对代码进行一点点的封装,目前所有的逻辑都是放在组件里面,把关于数据侧的逻辑操作抽离成一个个的 Hook,这样子 UI 层的代码就会很精简。
这里就展示 TodoItem 的相关代码:
// hooks/todo.js
import { useMutation,useQueryClient } from '@tanstack/React-Query';
import { createTodo,deleteTodo,patchTodo } from '../api/todo';
// 这里只写一个...
// 其他的都是一样
export const useDeleteTodo = id => {
const queryClient = useQueryClient();
const deleteTodo = useMutation(id => deleteTodo(id),{
onSuccess: (_,id) => {
// 不请求接口
queryClient.setQueryData(['todos'],prevData => prevData.filter(data => data.id !== id));
// 请求接口
// queryClient.invalidateQueries(["todos"]);
},
});
};
// TodoItem.jsx
const TodoItem = ({ todo }) => {
const { deleteTodo,loading } = useDeleteTodo(todo.id);
const { patchTodo,loading } = usePatchTodo(todo.id);
const handleDelete = () => {
deleteTodo.mutate(todo.id,{
onSuccess: () => {
alert('删除成功');
},
});
};
const handleToggle = e => {
const completed = e.target.checked;
patchTodo.mutate({ id: todo.id,completed });
};
return (
<li>
<input type='checkbox' value={todo.completed} onChange={handleToggle} />
<span>{todo.title}</span>
<button onClick={handleDelete}>删除</button>
</li>
);
};
会发现 UI 层的代码很简单,只需要处理 UI 层的相关 UI 展示,而关于数据逻辑的逻辑全都在一个个 Hook 中被处理。

queryKey 统一管理

在上面的例子中,会频繁使用到 queryKey,如果应用程序复杂起来,每个 queryKey 散落在这个地方,不好进行维护,可以使用一个统一的文件来存储 queryKey。
const todoQueryKeys = {
list: () => ['todos'],
detail: (id) => ['todo', 'detail'.id],
};
const useTodo = (id) => {
return useQuery(todoQueryKeys.detail(id), fetchFn);
};
const useTodos = () => {
return useQuery(todoQueryKeys.list(), fetchFn);
};

其他功能

通过上面的这一个 Todo 例子,我们已经学会了 React-Query 基本的使用了,这里还介绍一些其他的功能,具体的可以查看 React-Query 文档,React-Query 很强大!!!

后台偷偷更新

React-Query 如果满足下面的某一个条件时,会在后台自动更新,触发 Query 的重新执行发送请求获取最新的数据:
  1. 组件挂载
  2. 访问重复的一个 Query
  3. 窗口重新聚焦
  4. 网络已重新连接
  5. 轮训
我们可以通过isFetching这个字段或者使用useIsFetching这个 hook 来判断是否处于后台更新。
const Content = () => {
const isFetching = useIsFetching();
return <div>{isFetching ? 'background update' : ''}</div>;
};

数据啥时候过期取决于你

staleTime:表示数据多久才会过期,同时也决定了什么时候向服务端发送请求更新数据,默认值是 0,也就是在每次执行的 query 中都会发送请求来更新数据。
React-Query 并不会在该 query 数据过期就立马向服务端发送请求拉取最新的数据,还需要一个触发时机( window 再次被聚焦、组件重新 mount 等等),具体可以看上一小节[后台偷偷更新]:
有两种配置方式:全局配置,局部设置:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// A: 全局设置
staleTime: 1000 * 15, // 15秒数据才过期
},
},
});
// B: 批量局部设置
queryClient.setQueryDefaults(todoKeys.list(), { staleTime: 1000 * 60 }); // 60秒数据设置成过期
// C:对某一个Query设置
const useTodos = useQuery(todoKeys.list(), getTodos, { staleTime: 1000 * 60 });
全局配置(A)的含义就是:在 15 秒内的请求,都认为该 query 的数据没有过期,这个数据可以拿过来直接用,所以不需要发送请求,如果超过 15 秒内的请求,表示这个数据是过期的,所以需要重新请求接口拉取最新的数据。
局部配置(B、C)就把这个时间拉的更长了,只有超过 60 秒之后才会发送请求更新缓存数据。
以下是一个staleTime: 0staleTime: 1000 * 5CodeSandbox Demo
react-query-staletime-demo
react-query-staletime-demo
对于一些稳定的接口数据可以试试设置这个值,以达到缓存数据的效果,避免过多的请求。

分页查询

在 queryKey 部分阐述了可以构建静态、动态的 key,所以这里只需要构建一个动态的 page key,每当 page 变化时,React-Query 会认为是一个新的查询,然后重新构建一个新的Query发送请求。
const Pagination = () => {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery(['page', page], fetchFn);
if (isLoading) {
return 'loading';
}
return (
<div>
<button onClick={() => setPage(page - 1)}>上一页</button>
<button onClick={() => setPage(page + 1)}>下一页</button>
<ul>
{data.map((item) => (
<li>{item}</li>
))}
</ul>
</div>
);
};
是不是很简单呐?

数据转化

如果拿到数据需要做一次转化,可以使用select
export const useTodos = () =>
useQuery(['todos'], fetchTodos, {
select: (data) => data.map((todo) => todo.title.toUpperCase()),
});
/**
[ { title: 'TEST', id: 1, completed: false } ]
*/
select 只有在数据存在的時候才会被调用,因此不用担心数据是 undefined 的问题。上面的 useTodos 的 select 函数会在每次渲染时重新执行,因为 select 函数的引用发生了变化(它是一个内联函数), 如果 select 执行比较昂贵的操作时,执行多次可能会使得计算变慢。
因此,通过将其提取到组件外部的稳定函数引用,或者通过使用useCallback的方式来记忆它,这样子可以避免了 select 内联函数执行多次。
// 方法一:将转化函数放到组件外
const transformTodoTitles = (data: Todos) =>
data.map((todo) => todo.title.toUpperCase());
export const useTodos = () =>
useQuery(['todos'], fetchTodos, {
select: transformTodoTitles, // 如果是 undefined 则 select 不会执行
});
// 方法二:使用 useCallback
export const useTodos = () => {
const transformTodoTitles = React.useCallback(
(data: Todos) => data.map((todo) => todo.title.toUpperCase()),
[]
);
return useQuery(['todos'], fetchTodos, {
select: transformTodoTitles,
});
};

依赖 query

依赖 query 是指满足特定条件的时候才会执行的 query,比如说,只有 todoId 存在时才会发送请求。
在 React-Query 中要实现依赖 query 很简单,只需要传入 disabled 属性即可。
  • enabled: false时,该 query 不会执行。
const { data } = useQuery(['todo', todoId], fetchFn, {
// 当 todoId 存在时,该 query 才会执行
enabled: !!todoId,
});

更多

  1. 乐观更新:我们友好相处,每次更新都是乐观的,不会有任何问题,如果有任何的问题,我们回到最初的模样
  2. 加载更多:无限滚动,无限查询
  3. 取消请求
  4. 占位符数据

它的优势

拒绝重复

第一个比较显而易见的点就是减少了很多重复的代码,我们不妨看一段代码:
const List = () => {
const [list, setList] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState('idle');
useEffect(() => {
setFetching('fetching');
fetch('url').then(
(res) => {
const data = await res.json();
setList(data);
setLoading('success');
setError(null);
},
(err) => {
setList([]);
setLoading('idle');
setError(err);
}
);
}, []);
// ...
};
这段代码我们经常可以碰到,用来接口请求,我们需要处理了不同的状态(加载、错误、成功等),可能还需要使用相同的 API 在组件之间共享状态以及保持同步,为了刷新视图,我们需要定义 useState 和 useEffect,从 API 中获取数据,将数据使用 setState 更新状态,修改加载状态,处理错误等等这一系列操作,这可能是我们最常见的内容了。
如果我们使用 React-Query 来重写一下上面的代码,我们只需要提供一个 queryKey 以及 fetchFn 函数就可以了,你会发现,它很简洁。
const List = () => {
const { data, isLoading, isError } = useQuery(() => fetch('url'));
// ...
};

将服务端状态从状态管理中分离出来,精简状态管理

第二个点我是觉得很重要的一点,接管服务端状态,精简状态管理。
const globalState = {
theme: 'dark',
language: 'en',
list: [],
loading: [],
error: [],
pagination: {},
};
当我们去查看相关全局状态的代码的时候,发现它包含了上面定义的客户端状态和服务端状态,如果将服务端状态从全局状态中分离出来,会发现客户端状态很简单,所以对于 Mobx、Redux 等状态管理的库,对于客户端状态来说是一个福音,但是处理服务端状态,显得有点力不从心。
const globalState = {
theme: 'dark',
language: 'en',
};
const useList = useQuery(['list'], () => fetch('/list'));
所以不妨试试使用 Context、Mobx、Redux 来管理客户端状态,将服务端状态交给 React-Query 来处理,整个数据流会非常清晰(太好了,我再也不需要跳跃多个文件夹,打无数个 log 来排查问题了)。

更好的 UI 交互方式

使用缓存快速向用户显示信息,特别是在分页、列表的场景,从第 1 页访问到第 5 页,然后再从第 5 页返回的时候,已经访问过前面的页码数据,其实可以不需要直接请求接口进入硬加载状态然后再展示出来。
react-query-page-cache-demo
react-query-page-cache-demo
具体效果可以看看上面的 gif。
  1. 右侧的正常请求:不管是否之前访问过都会直接进入到 Loading 状态,等待请求完成之后才能进行交互处理。
  2. 左侧的 ReactQuery :每一个新的 Query 会进入 Loading 状态,如果再次访问时,会直接提供缓存的数据,并且此时启动后台自动更新(默认行为,尊重 staleTime)。
React-Query:如果不想进入 Loading 状态,可以使用keepPreviousData:true,它的意思就是使用之前的数据做一个占位符,当有新数据的时候再替换掉。
使用 useQuery,从服务器获取数据时,默认会在缓存中保存 5 分钟cacheTime: 1000 * 60 * 5。如果保存了缓存,如果挂载一次已经卸载的组件或者访问之前的 Query,就会从缓存中取回数据,这样就可以立即在浏览器中渲染数据。
换句话说,当返回一个曾经访问过的页面时,缓存数据会快速显示出来,这样用户就可以毫无压力地浏览。 此外,默认情况下,它会在提供缓存后立即在后台从服务器获取数据,并将任何更新的数据呈现给用户。(这个值可以通过选项 staleTime 改变,默认 0 显示后立即获取)
说到cacheTime的话,如果 Query 的状态是inactive的话并且没有 observer 的话,会在cacheTime后在缓存里面删除掉。
react-query-devtool-inactive-status
react-query-devtool-inactive-status
React-Query-devtools开发工具上看到,queryKey 为["todos"]的 Query,左边的 0 表示没有 observer,然后是一个inactive状态,所以会在cacheTime后给清理掉。

总结

说了这么多,希望这篇文章对你有所帮助,想讲的东西太多,奈何一篇文章内容放不下,最后希望大家都试试这个库。

更多资料

  1. 官方文档
  2. 源码也写得很好,可以读一读
  3. 维护者 Tkdodo 的个人博客,讲解了很多 React-Query 的相关知识