新鲜出炉的 React Query
终极教程系列来啦,结合实际业务,探索最佳实践 ~!
TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.
TanStack Query
(FKA React Query
)经常被描述为网络应用程序中缺失的数据抓取库,但用更专业的术语来说,它能让网络应用程序中的服务器状态的抓取、缓存、同步和更新变得轻而易举。Motivation:Most core web frameworks do not come with an opinionated way of fetching or updating data in a holistic way. Because of this developers end up building either meta-frameworks which encapsulate strict opinions about data-fetching, or they invent their own ways of fetching data. This usually means cobbling together component-based state and side-effects, or using more general purpose state management libraries to store and provide asynchronous data throughout their apps.
动机:大多数核心网络框架都没有提供全面获取或更新数据的方法。因此,开发人员最终要么构建元框架,封装关于数据获取的严格意见,要么自己发明获取数据的方法。这通常意味着将基于组件的状态和副作用拼凑在一起,或者使用更通用的状态管理库在整个应用程序中存储和提供异步数据。 ------ from tanstack
注意:在 v4 之后,React Query
这种实现扩展到了其他的框架(比如Vue
、Solid
、Svelte
),如果继续叫 React Query,显然不合适了,所以官方改名为TanStack Query
,本系列着重研究在 React 中的应用,所以,后面的默认都称作 React Query。
前言
React 开发中,状态管理应该算是核心了,虽然 React 提供了各种库和工具来处理状态,不同的库和工具都有不同的方法和视角,但是用于处理客户端状态的状态管理的解决方案并没有针对服务端状态进行优化,而 React Query
则是为了解决服务端状态管理问题而生的,本系列将会深入研究怎么有效使用 React Query
进行状态管理。
本系列的研究重点:
- 掌握状态及其通常的管理方式
- 深入理解
React Query
配置 - 掌握
React Query devtools
在开发和生产环境下的用法 - 使用
useQuery
管理服务器状态数据获取 - 使用
useQuery
高级用法:并行查询、查询取消、分页查询、无限查询...高级功能 - 使用
useMutation
创建、更新和删除数据 - 使用
useMutation
高级用法:乐观更新... - 将
React Query
与Next.js
和Remix
等框架结合使用 - 探索
MSW
和React Testing Library
,使用组件和钩子测试React Query
什么是 React 状态?
就拿我们司空见惯的计数器 demo
来看:
jsx
const App = () => {
const [count, setCount] = useState(0);
const increment = () => setCount((currentCount) => currentCount + 1) ;
const decrement = () => setCount((currentCount) => currentCount - 1);
const reset = () => setCount(0);
return (
<div className="App">
<div>Counter: {count}</div>
<div>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
</div>
);
};
这里面的
count
就是状态,用户之所以可以通过页面交互,就是基于状态,如果app
是人,那状态就是血液。
在比较复杂的场景中,更好的处理状态的办法是使用 useReducer
:
jsx
const initialState = { count: 0 };
const types = {
INCREMENT: "increment",
DECREMENT: "decrement",
RESET: "reset",
};
const reducer = (state, action) => {
switch (action) {
case types.INCREMENT:
return { count: state.count + 1 };
case types.DECREMENT:
return { count: state.count - 1 };
case types.RESET:
return { count: 0 };
default:
throw new Error("This type does not exist");
}
};
const AppWithReducer = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const increment = () => dispatch(types.INCREMENT) ;
const decrement = () => dispatch(types.DECREMENT) ;
const reset = () => dispatch(types.RESET) ;
return (
<div className="App">
<div>Counter: {state.count}</div>
<div>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
</div>
);
};
如何共享状态?
在 React 中,Context 可以让我们在组件之间轻松地共享状态,而不用傻瓜式地在多层级嵌套组件之中逐层透传(也就是所谓的 Props Drilling
)。我们改造一下上面的 demo:
jsx
import { useState, createContext } from "react";
export const CountContext = createContext();
export const CountStore = () => {
const [count, setCount] = useState(0);
const increment = () => setCount((currentCount) => currentCount + 1);
const decrement = () => setCount((currentCount) => currentCount - 1);
const reset = () => setCount(0);
return {
count,
increment,
decrement,
reset,
};
};
const CountProvider = (children) => {
return <CountContext.Provider value={CountStore()} {...children} />;
};
export default CountProvider;
然后在 index.js
中:
js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import CountProvider from "./components/CountProvider";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<CountProvider>
<App />
</CountProvider>
</React.StrictMode>
);
用 CountProvider
来包裹我们的顶层 App 组件。这样,App 中的每个组件都能使用我们的 Context,比如,在 App.js
中:
js
import { useContext } from "react";
import { CountContext } from "./components/CountProvider";
function App() {
const { count, increment, decrement, reset } = useContext(CountContext);
return (
<div className="App">
<div>Counter: {count}</div>
<div>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
</div>
);
}
export default App;
需要注意的是:每次状态在 Context 中状态更新时,使用 Context 的每个组件都会重新渲染,并接收状态更新。这会导致一些不必要的重新渲染,因为如果你只在状态中消费了一个变量,而由于某种原因另一个变量发生了变化,那么有可能会导致所有消费者重新渲染。
服务端状态和客户端状态
React 组件树中所有组件可访问的状态通常称为全局状态,它是一个或多个组件在应用程序中全局共享的状态。虽然全局状态并不是 React Query 诞生的原因,但它却对 React Query 的创建产生了影响。全局状态中具有许多不太好管理的特殊状态,也就是所谓的服务端状态,这促使 Tanner Linsley
创建了 React Query。
这里需要提出一个挑战很多人的认知 ------ 全局状态分为两种:
- 一种是在应用程序外部持久存在的状态,叫做服务端状态
- 一种是只存在于应用程序内部的状态,叫做客户端状态
这两类状态都有其特点,需要不同的工具来帮助管理。
全局状态
我们来看一个应用程序中全局状态的示例,既有服务端状态,也有客户端状态:
jsx
import { useState, useEffect } from "react";
const theme = {
DARK: "dark",
LIGHT: "light",
};
export const GlobalStore = () => {
const [selectedTheme, setSelectedTheme] = useState(theme.LIGHT);
const [serverData, setServerData] = useState(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const toggleTheme = () => {
setSelectedTheme((currentTheme) =>
currentTheme === theme.LIGHT ? theme.DARK : theme.LIGHT
);
};
const fetchData = (name = "ian") => {
setIsLoadingData(true);
fetch(`<url>/${name}`)
.then((response) => response.json())
.then((responseData) => {
setServerData(responseData);
})
.finally(() => {
setIsLoadingData(false);
})
.catch(() => setIsLoadingData(false));
};
useEffect(() => {
fetchData();
}, []);
return {
selectedTheme,
toggleTheme,
serverData,
isLoadingData,
fetchData,
};
};
这个 store 里面包括了:
selectedTheme
:用于管理选定的主题serverData
:用于保存从 API 请求返回的数据isLoadingData
:用于显示 API 请求的当前加载状态是否仍在加载中toggleTheme
:在浅色和深色模式之间切换fetchData
:获取数据,并根据请求的结果设置isLoading
状态
只需要将这些状态放到 Provider 中,所有订阅的组件都可以消费,这是我们日常的代码逻辑。但问题是,很多时候由于新的开发需求,这里面的状态会不断增长。想象一下,我们需要一个二级主题,我们需要添加另一个名为 secondaryTheme 的状态变量:
jsx
// ...
export const GlobalStore = () => {
// ...
+ const [secondaryTheme, setSecondaryTheme] = useState(theme.LIGHT);
// ...
+ const toggleSecondaryTheme = () => {
+ setSecondaryTheme((currentTheme) =>
+ currentTheme === theme.LIGHT ? theme.DARK : theme.LIGHT
+ );
+ };
// ...
};
secondaryTheme
状态逻辑与 selectedTheme
相似。这里使用 Context,这意味着每次我们触发状态更新时,任何使用该状态的组件都将被迫重新渲染。
想象一下,有两个组件(我们称它们为 组件 A
和 组件 B
)在使用这个 Context,但组件 B
只对 selectedTheme
状态进行更新,而 组件 A
则对所有状态进行更新。如果 组件 A
只更新了 secondaryTheme
,那么 组件 B
也将重新渲染。这是 React Context
的工作原理,我们是无法改变的。
当然,我们也可以拆分 Context,将订阅组件拆分成两个组件,然后用 memo 封装第二个组件,或者直接用
useMemo
封装我们的返回值。或许这可能会解决我们的问题,但我们只是在处理创建全局状态的一种状态的变化,就已经变得比较复杂了。想象一下,如果产品再来提需求,还需要添加一个 API 请求,Context 中的状态会不断增加,最终你可能会发现,状态组织将变得愈发复杂,最终会变成开发者的一场噩梦。而且我们还只是处理状态组织的问题,如果我们想要提升一下用户体验,需要缓存从 API 请求中获取的数据,这可能会让我们发疯。
从这些问题中,可以看到,在全局状态中,我们往往会遇到不同的挑战,适用于一件事的解决方案可能不适用于另一件事。这就是为什么要对我们的全局状态进行拆分,因为全局状态往往是客户端状态和服务端状态的混合体。
客户端状态
一句话:客户端状态是应用程序本身所拥有的状态。
作为一个合格的 React 前端,你必须能够识别哪些是客户端状态,这样才能完全理解在 React 中,哪些状态应该由 React Query 管理,哪些状态应该由其他状态管理工具管理。
以下是识别客户端状态的几种方法:
- 该状态是同步的,这意味着你可以使用同步方法访问该状态,无需
async/await
。 - 该状态是本地的,因此只存在于你的应用程序中。
- 该状态是临时的,因此在页面重新加载时可能会丢失,并且通常是不持久化的。
比如,在上面的示例代码 GlobalStore
中,客户端状态就是 selectedTheme
了,通过上面的方法来分析一下:
- 我们需要
async/await
才能得到它的值吗?不需要,它是同步的。 selectedTheme
是否只存在于我们的应用程序中?是的。- 页面刷新时会丢失吗?是的,如果我们不将其持久保存在本地存储中,那么它的值就会在页面刷新后丢失。
管理这类状态,我们可以使用 React Context
或第三方库(如 Redux
、Zustand
或 MobX
)等工具来组织和维护。
服务端状态
一句话:服务器状态就是存储在服务器上的状态。
在 Redux 中,我们通常会使用 Redux Saga
或 Redux Thunk
来获取数据并存储服务器状态。
以下是识别服务端状态的几种方法:
- 这种状态是异步的,这意味着你需要使用异步 API 来获取和更新它。
- 它是远程的 ------ 一般来说是在服务器或者数据库中。
- 无法保证应用程序中的状态是最新的,因为大多数情况下,其他正在使用这个状态的人也可能会更改状态。
所以在上面的 GlobalStore
中, serverData
就是服务端状态,通过上面的方法来分析一下:
- 我们是否需要异步 API 来访问该状态?是的,我们需要向服务器发送获取请求,然后等待服务器发送数据回来。
- 数据是远程持久化的吗?是的,需要从服务器获取。
- 在我们的应用程序中,这种状态是否总是最新的?不一定。任何使用相同 API 的人都可以能更新它。
那么 isLoadingData
状态变量是什么?这是一个派生状态变量。这意味着它的状态将始终取决于服务器数据获取请求的当前状态。一旦我们获取数据,那么 isLoadingData
将为 true;一旦我们完成数据获取,那么 isLoadingData
将变回 false。
实际上,每一种服务器状态变量都需要一个这样的派生状态变量。当获取请求失败时,你需要处理错误。你可能会为错误单独创建另一个状态变量。这还只是状态的冰山一角,如果哪天老板要求你需要缓存数据来提升用户体验,你又当如何处理这些缓存状态。
处理服务端状态中的一些难点
缓存
为了提高页面性能,使网站响应速度更快,有些时候需要对数据进行缓存。这样可以重复使用之前获取的数据,避免再次从服务器重新获取。缓存需要考虑以下几点:
- 在保持应用程序响应速度的同时,需要在后台更新缓存。
- 需要能够评估缓存数据何时过期,何时需要更新。
- 一旦数据有一段时间未被访问,就必须对这些数据进行垃圾回收。
- 在获取数据之前,你可能需要用一些模板数据来初始化缓存。
每一个都不是简单的问题。
乐观更新
通常,在调接口去更新服务端状态时,我们会先调用更新接口,然后重新获取数据并重新渲染页面,通常都会在这个状态切换的过程中加入一些 Loading 的状态来缓解用户的焦虑感,提升用户体验。
实际上,有更好的处理方式,那就是乐观更新。
乐观更新:指在更新服务端状态时,我们先更新用户界面,显示完成后的效果,尽管这时候接口可能仍未确认完成。
通俗点儿讲,就是乐观地认为,在更新之后,这些数据一定会变成我们所期望的样子,那就提前将它渲染到页面上,这样就节省了一些时间,当然,也可能更新失败啊,那就回滚呗。
注意 :为了与React Query
官方的术语保持一致,接下来会用"突变(Mutation)
"来代替更新服务端状态这一行为。
如何实现这一点?
- 在进行突变时,你需要将应用程序中的服务端状态更新为我们期望的突变成功后的状态。这将使用户界面对用户的响应更快,可以更早地开始与之交互。
- 突变成功后,你需要重新触发一次手动重新获取服务器状态的操作,这样你的应用程序中才能真正拥有更新后的状态。
- 如果突变失败,你需要手动将状态回滚到乐观更新之前的版本。
乐观更新会给用户带来极佳的用户体验,但要处理成功和错误的情况,还要保持服务器日期的更新,是一件很有挑战的事情。
减少重复请求
在日常开发中有这么一个场景:当用户点击一个按钮时,会触发获取请求以部分更新服务器状态。在执行获取时,按钮是禁用的。
想象一下这样的场景,在加载状态更新和按钮最终被禁用之前,用户可以再点击该按钮 10 次,就会触发 10 次不必要的请求。怎么减少这些重复请求呢?
性能优化
有时候,我们需要对服务器状态进行一些的性能优化。通常会这么做:
- 懒加载:在满足特定条件(比如滚动到某个位置)后才执行特定的数据获取请求。
- 无限滚动:在处理数据量庞大的列表时,无限滚动是一种非常常见的处理方式。
- 分页请求:构建大型数据集的时候,一般会将数据做分页请求。
对这些性能优化手段,如果我们自己手动去解决这些难题可能需要相当长的时间,而且容易出现错误,最终还可能会影响代码的可读性,增加理解项目的复杂性。
如果我告诉你,有一种东西可以在后台为你解决所有这些难题和许多其他难题,同时为你提供一个超级简洁的 API,让你的代码更易读、更易懂,让你成为是真正的服务器状态管理大师,你会不会心动?
对,我说的就是 React Query
!接下来,第二篇,我们就开始正式介绍这个神器。