Links: NextJSReact Query

为什么要在 Nextjs 中使用 React Query

在之前的 SPA 项目中,我们一般是在 useEffect 中进行数据获取,在 NextJS 项目中,我们尽可能的在服务器组件中获取数据,将获取到的数据通过 props 传递给子组件中,一切都没有问题,为什么还要使用 React Query

首先在 useEffect 中获取数据已经是 React 官方所不推荐的方式了,其次通过 React Query,我们能更好的管理请求的状态,而不用自己维护,且 React Query 提供了许多额外的功能来实现请求的功能

配置

  1. 安装 react-query 依赖
npm i @tanstack/react-query
  1. 创建 QueryClientProvider
"use client";
 
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 
export default function Providers({ children }) {
  const [queryClient] = React.useState(() => new QueryClient());
 
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
  1. layout 中使用 Provider
import Providers from "./providers";
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
  1. 在组件中使用
"use client";
 
function fetchData() {
	fetch('/api/info')
}
 
export function SomeComponent() {
	const data = useQuery({ 
		queryKey: ['getInfo'], 
		queryFn: fetchData 
	});
 
	return <div>{/*渲染数据*/}</div>;
}

服务器组件使用

NextJS 客户端组件中,使用 React Query 的方式与 SPA React 中没有什么不同,你可以直接使用 useQuery 等并且设置一些查询参数和配置。
但是在 NextJS 中可能遇到与服务器组件有关的一些疑惑,因为 useQuery 无法直接在服务器组件中使用,这样一看好像在 NextJS 中使用 React Query 好像并不是特别必须的了,但其实 React Query 提供了与服务器组件配合使用的能力。

使用 InitialData

与服务器组件一起使用的一种方式是使用 useQueryinitialData 属性,它为查询提供初始数据

 
// page.ts
 
export function async Page() {
	const data = await getData();
 
	return <div>
		<ChildComponent initialData={data} />
	</div>
}
 
const ChildComponent = ({ initialData }) => {
	const result = useQuery({
	  queryKey: ['todos'],
	  queryFn: () => fetch('/todos'),
	  initialData
	})
}

我们可以依然在服务器组件中提前获取数据,以提高首评渲染的速度,并且将获取到的数据通过 props 属性传递给子组件的 useQuery 中,设置 initialData 的值,useQuery 将其视为未来请求的初始数据,直到数据过期或者从缓存中删除。

你也可以设置initialData 为一个返回数据的函数,用来定制初始数据

const result = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetch('/todos'),
  initialData: () => {
	  return data;
  }
})

设置 initialData 可以影响 useQueryisLoading 状态,当我们没有设置 initialData 时,初始请求时 isLoading 的值为 true ,通常我们在组件内通过这个状态来控制展示一些 Loading 组件,那么效果就是当用户首次进入页面时,就会出现一个 loading 展示,然后数据获取到后展示渲染的数据,体验并不好。

const result = useQuery({
	queryKey: ['keys],
	queryFn: () => fetch('/todos')
})
 
if (result.isLoading) {
	return <div>Loading...</div>
}

当设置了 initialData 之后,因为有了初始值,所以在用户进入页面初次请求是 isLoading 的状态为 false,下一次请求时才会变更状态,这样通过在服务器组件中获取数据然后传递给客户端组件,就可以实现页面首次渲染时渲染数据是直接展示的,而不会出现加载中的状态。

如果你希望实现这样的效果,但是又希望能够知道是否在请求中,可以使用 isFetching 字段,它可以用来表示数据是否正在请求,即使数据已经渲染到页面上,isFetching 也可能为 true,因为 React Query 可能正在后台更新数据,通过这种状态的配合使用,可以很好的提高用户体验。

InitialData 与 PlaceholderData

React Query 中设置初始数据其实有两个属性可以使用,一个是 initialData,另一个是 placeholderData,这两种方式都可以实现数据的初始填充,但是使用场景和效果略有不同

  • initialData 被视为真实的数据,会被立即写入缓存,并且一直存在,直到缓存过期,并且会影响 isLoading 的值,适用于在 SSR 中或者父组件中通过 props 传递的数据。
  • placeholderData 是临时使用的假数据,只在加载请求之间显示,当获取到真实到数据后,placeholderData 会被替换,它不会影响 isLoading 的状态,一般使用于需要优化页面加载状态的场景,例如 骨架屏

staleTime 和 initialData

在默认情况下我们使用 initialData可能会有一个疑惑,就是为什么我设置了 initialData,它已经有了初始值了,为什么在页面渲染的时候,会立即执行一次?如果初始值是从服务器组件中传入的,那么就相当于打开页面时请求了两次,服务器组件请求一次,客户端组件中请求一次,如果你希望实现在有初始数据的情况下第一次加载不发送请求,可以配合 staleTime 属性实现

staleTime 用于控制数据的缓存有效期,超出设置的时间之后数据就会变成不新鲜数据,React Query 就会在下次访问时重新获取数据,它的默认值为 0, 则会在挂载时理解执行请求

const { data } = useQuery({ 
	queryKey: ['data'], 
	queryFn: () => fetch('/api/data'), 
	staleTime: 10000,
});

我们设置 staleTime 的值为 10000,也就是表示数据的有效期为 10秒,在 10秒内我们切换页面或者重新触发 useQuery,都不会发起请求,只有超出这个时间之后,才会重新发起,这个值适合那些不需要即时性的数据展示可以通过与 initialData 一起使用来达到想要的效果。

设置 queryKey

queryKeyreact-query 中用于标识一个缓存的数据,它必须是唯一的,如果大的项目情况下,每次指定一个字符串 queryKey 很容易导致设置重复,所以最好的方式是对每一个请求抽取一个公用函数,在需要的地方统一使用这个请求函数,并且可以设置请求的 path 作为 queryKey 的值。

另外,可以将 queryKey 视为 useEffect 的依赖项,当 queryKey 的值发生变化时,useQuery 会重新执行,避免使用复杂的逻辑来手动触发重新获取

const { data } = useQuery({ 
	queryKey: ['/api/data', page, pageSize], 
	queryFn: () => fetchData('/api/data', { page, pageSize })
});
 

后台重新获取数据出错时处理

我们可以给 useQuery 设置以下属性来实现自动重新获取

  • refetchOnMount - 组件每次挂载时
  • refetchOnWindowFocus - 窗口每次获取焦点
  • refetchOnReconnect - 网络重新连接时

正常情况下我们的逻辑会先判断请求是否成功,如果错误了,则会渲染一个错误的页面,成功的话就渲染数据。
考虑一个场景就是,用户切换到其他窗口,再切换回来时,如果我们设置了 refetchOnWindowFocus,这时候 useQuery 会在后台执行重新请求,如果这次请求失败了,那么展示给用户的效果就是用户一切换到当前窗口,页面就渲染了一个错误页面,在某些场景下这种情况会给用户造成很大的困扰,如果你不希望出现这种情况,可以使用 data 字段,useQuery 在请求失败时,如果有已经缓存的数据存在,则会返回已经缓存的数据,大致结构如下

{
  "status": "error",
  "error": { "message": "Something went wrong" },
  "data": [{ ... }]
}

请求状态为 error,但是 data 属性有值(缓存的之前的数据),我们可以利用这一点来优化用户体验,就是将数据的判断提前,如果有 data 存在,则渲染数据,否则判断是否错误,在展示错误的页面

const result = useData()
 
if (result.data) {
  return <div>{/*渲染数据*/}</div>
}
if (result.error) {
  return '请求失败了'
}
 
return 'Loading...'

queryFn 内联函数优化

很多网上的案例都是这么使用的

export const useTodos = () => {
  const { state, sorting } = useTodoParams()
 
  // 🚨 can you spot the mistake ⬇️
  return useQuery({
    queryKey: ['todos', state],
    queryFn: () => fetchTodos(state, sorting),
  })
}

可以看到问题存在: queryKey 依赖项与 fetchTodos 的参数不一致,这就导致,当 sorting 参数变化时,useQuery 并不会执行

最好的方式是使用 QueryFunctionContext, 它会将 queryKey 作为参数传递给 queryFn

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (state: State, sorting: Sorting) =>
    [...todoKeys.lists(), state, sorting] as const,
}
 
const fetchTodos = async ({
  queryKey,
}: // 🤯 only accept keys that come from the factory
QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
  const [, , state, sorting] = queryKey
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}
 
export const useTodos = () => {
  const { state, sorting } = useTodoParams()
 
  // ✅ build the key via the factory
  return useQuery({
    queryKey: todoKeys.list(state, sorting),
    queryFn: fetchTodos
  })
}

不喜欢 queryKey[0]queryKey[1] 的参数获取方式,可以给 queryKey 传入一个对象

const todoKeys = {
  // ✅ all keys are arrays with exactly one object
  all: [{ scope: 'todos' }] as const,
  lists: () => [{ ...todoKeys.all[0], entity: 'list' }] as const,
  list: (state: State, sorting: Sorting) =>
    [{ ...todoKeys.lists()[0], state, sorting }] as const,
}
 
const fetchTodos = async ({
  // ✅ extract named properties from the queryKey
  queryKey: [{ state, sorting }],
}: QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}
 
export const useTodos = () => {
  const { state, sorting } = useTodoParams()
 
  return useQuery({
    queryKey: todoKeys.list(state, sorting),
    queryFn: fetchTodos
  })
}

参考