构建查询

查询是React Query工作的基础,我们在一个应用使用React Query也是通过构建一个个的查询来开始的。在React Query中构建一个查询,通常是通过useQuery或者useInfiniteQuery两个Hook来定义的。查询通常都是使用GET或者POST方法从服务端获取数据的,但是就RESTful风格API定义习惯来说,查询数据一般都仅指GET方法。

定义一个查询,以下两个内容:

  • 用于代表和识别查询的唯一键,也常被称为Key。
  • 一个可以返回Promise类型值的函数,这个函数可以返回一个具体的值,或者抛出一个异常。

以下是一个比较简单的查询定义示例。

import axios from 'axios';
import { useQuery } from 'react-query';

function getThings(): Promise<Thing[]> {
  return axios.get<Thing[]>('/things');
}

function Things() {
  const { data } = useQuery(['things'], getThings);

  return (
    <ul>
      {data.map(d => (
        <li>{d.name}</li>
      ))}
    </ul>
  );
}

在这个简单的示例中,使用了与前面的示例不同的获取查询返回数据的方法,没有直接使用useQuery Hook直接返回的对象来访问返回数据,而是采用了解构赋值的方法仅从其中获取了查询所返回的数据内容。解构出来的data字段中的内容实际上非常好理解,就是查询从服务端获取到的实际数据,但是在useQuery这个Hook中,除了用于承载数据的data字段,还有以下字段可以使用。

  • status,用于获取当前查询的状态,查询的状态采用字符串表示,可以取以下值。
    • 'loading',表示查询正在请求数据,但是还未获取到任何数据。
    • 'error',表示查询在请求过程中发生了错误。
    • 'success',表示查询请求已经成功并且所获取到的数据已经可以访问了。
    • 'idle',表示查询目前处于空闲或者被禁用的状态。
  • isLoading,表示查询是否处于正在请求数据的状态,是status === 'loading'的快捷表示。
  • isError,表示查询是否发生了错误,是status === 'error'的快捷表示。
  • isSuccess,表示查询是否已经成功获取到了数据,是status === 'success'的快捷表示。
  • isIdle,表示查询是否正处于空闲或者被禁用状态,是status === 'idle'的快捷表示。
  • isFetching,表示查询在任何情况下都处于正在获取数据的状态(包括后台查询的状态),那么这个字段就会返回true
  • error,表示查询出现的具体错误,其中错误信息可以使用error.message来获取。
  • data,用于承载从服务端获取到的具体数据。

虽然useQuery提供了上面这么多可以使用的字段,但实际上在应用项目的日常使用中,最常用的还是集中在isLoadingisErrorerrordata上。

根据以上这些可以使用的状态字段,可以把前面那个简单的示例的状态显示再丰富一下。

function Things() {
  const { isLoading, isError, error, data } = useQuery(['things'], getThings);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return <div>Error: ${error.message}.</div>;
  }

  return (
    <ul>
      {data.map(d => (
        <li>{d.name}</li>
      ))}
    </ul>
  );
}

查询在使用过程中,需要牢记以下规则,否则可能会出现数据操作与期望不符的情况。

  1. 在默认情况下,useQueryuseInfiniteQuery两个Hook返回的实例,会将已经缓存的数据数据视为过期数据。如果需要更改查询实例的这个特性可以配置全局或者查询的staleTime参数,越长的参数值会指示查询实例更多的利用已经缓存的数据。
  2. 被标记为过期的查询会在以下条件下自动重新查询。
  3. 查询被挂载到了一个新的实例上。
  4. 浏览器窗口重新获得了焦点。
  5. 客户机重新连接到了网络。
  6. 查询配置了轮询时间。
  7. 一个查询结果如果在没有更多的活跃查询实例,将会被标记为失活状态并被缓存起来,被缓存的查询结果可以在后续的查询中被直接使用。
  8. 被标记为失活状态的查询结果将在五分钟后被GC回收。
  9. 如果一个查询出现错误,那么将以指数退避的形式静默重试三次,如果经过重试后,请求依旧失败,那么将会直接返回错误状态及信息。
  10. 在默认情况下,查询结果在结构上是共享的,用以检测数据是否发生了实际的更改。这个特性可以辅助使useMemouseCallback保持稳定。

useQuery中,定义查询的第一个参数即是代表查询的Key,这个Key一般都为数组,但是其元素可以是任意类型。

Warning

在React Query v3中,代表查询的Key可以是字符串,但是在React Query v4中,代表查询的Key只能是数组。

当一个查询需要更多的数据来唯一的描述其查询获得的数据时,就需要使用到数组甚至嵌套对象来作为查询Key。一般来说,对于数组或者嵌套对象的选择可以依照以下标准。

  • 如果查询结果是分层、索引等类型的,建议使用数组组合所有的条件。
  • 如果查询是跟具体条件、查询关键字等相关的,建议使用嵌套对象作为数组元素来组合查询条件。

具体的使用可以参考以下示例。

// 查询Key为 ['things', 5],通常可以用于表示获取索引ID为5的记录
const { data } = useQuery(['things', 5], fetchThings);

// 查询Key为 ['things', 5, { enabled: true }],通常可以用于获取索引ID为5但是仍带有额外附加条件的查询
const { data } = useQuery(['things', 5, { enabled: true }], fetchThings);

// 查询Key为 ['things', { keyword: 'test', page: 3 }],通常用于给定具体查询条件时使用
const { data } = useQuery(['things', { keyword: 'test', page: 3 }], fetchThings);

对于出现在查询Key中的数组元素来说,其中的元素顺序决定了React Query对于查询的识别。例如在以下几个查询中,这几个Key会被React Query认为是不相同的。

const { data } = useQuery(['things', status, page], fetchThings);

const { data } = useQuery(['things', page, status], fetchThings);

const { data } = useQuery(['things', undefined, page, status], fetchThings);

但是如果在查询Key中使用嵌套对象,那么嵌套对象中的字段顺序是不会影响React Query对于查询的识别的。例如在以下几个查询中,React Query会认为它们都是相同的查询。

const { data } = useQuery(['things', { status, page }], fetchThings);

const { data } = useQuery(['things', { page, status }], fetchThings);

const { data } = useQuery(['things', { page, status, keyword }], fetchThings);

Tip

造成这种情况的原因是,React Query会对查询Key进行散列然后对其进行确定和识别,所以作为查询Key中数组元素的内容将会直接影响查询Key的散列结果,但是嵌套对象作为一个数组元素存在,那么其中的内容就不再能影响查询Key的散列结果了。

所以如果需要唯一的对查询进行标识,就必须将查询的必要条件包含在组成查询Key的数组中。

在一般的使用中,常常会将变量作为查询Key的组成部分使用。这样一来,对于查询函数的定义就变得比较复杂,但是React Query支持将查询Key传入查询函数的方法。这样定义的查询函数就要简单的多了。查询函数所收到的参数是查询定义的时候产生的Key本身,所以在接收的时候可以采用解构赋值的方式进行提取。以下是对数组形式传入和嵌套对象方式传入的示例。

// 使用数组形式的查询Key
// 查询的定义
function DataComponent() {
  const { data } = useQuery(['things', page, status], fetchThings);
}

// 定义查询函数
function fetchThings({ queryKey }) {
  const [_key, page, status] = queryKey;
  // 这里执行具体的查询功能
  return new Promise();
}
// 使用嵌套对象形式的查询Key
// 查询的定义
function DataComponent() {
  const { data } = useQuery(['things', { page, status }], fetchThings);
}

// 定义查询函数
function fetchThings({ queryKey }) {
  const [_key, { page, status }] = querykey;
  // 这里执行具体的查询功能
  return new Promise();
}

从这两个简单的示例可以看出来,查询函数在获取查询Key的时候,都是通过解构语法从实参的queryKey字段中获取的。要理解为何这样做,只需要看一下useQuery的另一种使用形式就明白了。

function DataComponent() {
  const { data } = useQuery({
    queryKey: ['things', page, status],
    queryFn: fetchThings,
    ...config
  });
}

从上面这个简单的示例中就可以知道useQuery在定义查询的时候都向查询函数传递了哪些内容。所以如果不使用示例中的这种对象定义形式,那么config就可以作为useQuery的第三个参数存在。在这个config参数中,可以对查询的一些常见功能特性进行配置。例如以下这些参数。

  • enabled,配置查询是否可以自动被运行。如果搭配其他变量作为条件,可以实现依赖查询,在指定的依赖项就绪以后就会立刻运行查询。
  • staleTime,已经缓存的数据被视为过期的时间,取值单位为毫秒。
  • cacheTime,未被使用的和非活动数据在内存中被保持的时间,取值单位为毫秒。
  • refetchInterval,设置查询的轮询时间,取值单位也为毫秒。还可以给给定一个函数来动态的决定下一次查询的启动时间。
  • refetchOnWindowFocus,配置查询在浏览器窗口重新获得焦点以后,是否需要重新查询结果。
  • retry,配置查询的失败重试次数,如果设置为false,那么就会关闭查询的失败重试。
  • retryDelay,配置查询失败后的重试时间间隔。但是需要注意的是,React Query默认是采用指数退避方式进行重试的,所以在一般情况下并不提倡自行配置这个选项。
  • placeholderData,配置当查询尚未执行的时候可以使用的占位数据。这个选项可以配合useMemo来使用。注意,这里的占位数据并不是初始数据,初始数据是由另一个选项来配置的。
  • initialData,在查询尚未执行时,对缓存中的数据进行初始预置。initialData中的数据受staleTime影响。
  • keepPreviousData,可以接受布尔值,如果设置为true,那么在从查询获取新的数据的时候,将会保留旧的查询结果。
  • onSuccess,设置当查询执行成功以后的事件响应函数。
  • onError,设置当查询执行失败以后的事件响应函数。
  • onSettled,设置当查询执行完成以后的事件响应函数,相当于onSuccessonError的集合。
  • select,用于在查询成功完成后,对获取到的数据进行变换或者筛选。

分页查询

分页查询是所有的应用中经常会使用到的一种查询。因为应用所要呈现的数据量过大,导致数据无法在一个页面内全部展示完全,所以就需要对数据进行分页。分页查询中常常会遇到的问题是如果频繁的进行切换页面的操作,那么应用将会反复的向服务端发起请求来获取指定页面的数据。为了优化应用的这一需求,React Query通过在查询中配置keepPreviousData选项,可以将所有已经获取过的页面数据都缓存起来,就可以节省大部分的数据获取请求。

其实通过前文对于查询Key的说明,直接为不同的页面配置不同的查询Key也是可以做到将已经获取的查询结果缓存起来的。但是这样一来,使用查询的UI界面组件就会不断的响应请求成功和请求失败的事件,从而造成UI界面不必要的刷新动作。但是使用了keepPreviousData选项以后,React Query就不会在已经存在缓存的数据上抛出查询成功和查询失败的事件了,而是会静默的从服务端获取数据,并在获取的数据有新的变化时,悄悄地对缓存进行替换。

以下是一个操作分页数据的示例。

function ThingsList() {
  const [page, setPage] = useState(0);
  const { isLoading, isError, error, data, isPreviousData } = useQuery(['things', page], () => fetchThingsPaged(page), {
    keepPreviousData: true
  });

  return (
    <>
      {isLoading ? <div>加载中...</div> : null}
      {isError ? <div>数据获取错误: {error.message}</div> : null}
      <div>当前是第 {page + 1} 页</div>
      <ul>
        {data.items.map(it => (
          <li>{it.name}</li>
        ))}
      </ul>
      <button onClick={() => setPage(old => Math.max(old - 1, 0))} disabled={page === 0}>
        上一页
      </button>
      <button
        onClick={() => {
          if (!isPreviousData && data.hasMore) {
            setPage(old => old + 1);
          }
        }}
        disabled={isPreviousData || !data?.hasMore}>
        下一页
      </button>
    </>
  );
}

无限查询

另外一种操作分页数据的方法是无限获取数据。这种对于大量数据的处理方式经常可以在移动端应用中以无限滚动的形式看到。React Query对肿着无限获取数据的需求也提供了支持,只是在使用的时候并不是使用useQuery Hook函数,而是使用useInfiniteQuery Hook函数。

useQuery不同,useInfiniteQuery所返回的值和要提供的参数有很大的区别。首先看useInfiniteQuery在使用的时候需要提供的参数。

  • getNextPageParam,设置用于获取下一页数据所使用参数的函数。
  • getPreviousPageParam,设置用于获取上一页数据所使用参数的函数。

这两个参数所接收的函数的签名格式是相同的,都是(page: any, allPage: number) => any | undefined。这个函数需要返回一个可以用于生成查询参数的值,例如页码,或者是直接返回undefined以表示没有更多的数据。函数中所需要使用到的类型是anypage参数,实际上是来自于对于分页数据的记录。

那么为了使useInfiniteQuery能够正常的工作,就必须了解useInfiniteQuery返回的内容。

  • data,保存持有所有查询获取到的全部数据。
  • data.pages,以数组的形式保存每一条查询获取到的分页数据。
  • data.pageParams,以数组的形式保存每一条查询所使用的查询参数。注意,这里的内容将被传入getNextPageParamgetPreviousPageParam函数中作为其第一个参数。
  • fetchNextPagefetchPreviousPage,提供应用组件中调用获取下一页数据和上一页数据的函数。
  • hasNextPagehasPreviousPage,提供应用组件对是否存在下一页以及上一页进行判断的布尔值标志。
  • isFetchingNextPageisFetchingPreviousPage,提供应用组件获取当前查询正在获取数据的状态。
  • refetch,提供应用组件用于手动重新查询某一指定页面的数据的函数。

除了以上列出的这些返回内容以外,useInfiniteQuery还具有useQuery所返回的全部内容。具体useInfiniteQuery的使用可以参考以下示例。

function UnlimitedThings() {
  const fetchThings = ({ pageParam = 1 }) => axios.get<Things[]>(`/api/things?page=${pageParam}`);

  const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = useInfiniteQuery(
    ['things'],
    fetchThings,
    {
      getNextPageParam: (lastPage, pages) => lastPage.nextPage
    }
  );

  return (
    <>
      {isLoading ? <div>加载中...</div> : null}
      {isError ? <div>出现错误:${error.message}</div> : null}
      <ul>
        {data.pages.map((group, i) => (
          <Fragment key={i}>
            {group.items.map(it => (
              <li>{it.name}</li>
            ))}
          </Fragment>
        ))}
      </ul>
      <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
        {isFetchingNextPage ? '加载更多内容中...' : hasNextPage ? '加载更多' : '没有更多内容供加载了'}
      </button>
    </>
  );
}

如果无限查询的缓存结果变为了过期状态,那么整个查询就需要被重新加载,查询中所保存的每一个页面都会被顺序的从第一页开始完成加载。如果手动将无限查询的结果从缓存中移除了,那么无限查询再次开始时,将会重新从第一页开始获取。除此之外,还可以使用useInfiniteQuery返回的refetch函数完成手动加载的功能。refetch函数接受一个对象作为参数,其中要求使用refetchPage字段定义所要重新获取的页面。