准点下班!管理 React Query 缓存如此简单,快把 Query Key 装进马克杯里

328 阅读4分钟

大家好,我是 OQ(Open Quoll),是一名 React Query 的爱好者,也是 React Mug 的作者。React Query 是很实用的前端同步数据的方案,其核心是以 Query Key 为索引的缓存机制,可以说,管理好了 Query Key 就等于管理好了缓存,就等于管理好了数据同步。React Mug 则是一款简洁的函数式状态库。今天通过实现案例为大家带来一种简单而高效管理 Query Key 的实践。

(友情提示:本文中部分 API 仅适用于 react-mug@0.3.x 及以下版本。)

关于案例

这个案例来源于我过去开发的实际项目,包含比较常见的数据同步逻辑。界面的中心区域是分页列表,每个条目展示一个文档的元数据。顶部是设置搜索条件的区域,默认为空,当修改搜索条件时列表随之刷新。点击列表条目右侧会弹出一个抽屉加载预览当前文档,并且可以编辑文档元数据。

界面结构.jpg

这里有一个跨前后端的数据结构,即文档的元数据:

interface Doc {
  id: string;
  title: string;
  author: string;
  labels: string[];
  createdAt: number;
}

案例的主要挑战在于每个界面区域看似独立、实际上数据是相互关联的。尽管有多种方法实现,但是想要做到简单而高效却不容易。

下面着手实现。

页面层中的 Query Key

先从页面层的 分页列表 和 搜索条件 写起,查询文档元数据列表的接口调用如下:

async function getDocList(params: {
  title: string;
  authors: string[];
  labels: string[];
  pageIndex: number;
}): Promise<{ docList: Doc[]; pageCount: number }> {
  // 调用后端接口获取数据
}

对应的 Query Hook 如下:

import { keepPreviousData, useQuery } from '@tanstack/react-query';

function useDocListQuery(...args: Parameters<typeof getDocList>) {
  return useQuery({
    queryKey: ['docList', ...args],
    queryFn: async () => {
      return await getDocList(...args);
    },
    placeholderData: keepPreviousData,
  });
}

然后稍微改造一下 Query Hook,用 React Mug 将 Query Key 转变成状态管理起来。这简化了 Query Hook 的调用,方便了当前 Query Key 的查询,而且让状态变化能够直接触发新的查询:

import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { check, construction, Mug, useOperator } from 'react-mug';

const keyOfDocListQueryMug: Mug<['docList', ...Parameters<typeof getDocList>]> = {
  [construction]: ['docList', { title: '', authors: [], labels: [], pageIndex: 0 }],
};

export function useDocListQuery() {
  const queryKey = useOperator(check, keyOfDocListQueryMug);
  return useQuery({
    queryKey,
    queryFn: async () => {
      const [, ...args] = queryKey;
      return await getDocList(...args);
    },
    placeholderData: keepPreviousData,
  });
}

这里的 Mug,马克杯,是 React Mug 盛装状态的基本模块,[construction] 字段既表示了对象是一个 Mug 也设置了 Mug 所盛装状态的初始值,而 Mug 帮助类型只是辅助定义类型,useOperatorcheck 则用来持续读取 Mug 里的状态。这里 keyOfDocListQueryMug 盛装的状态便是 Query Key,一个以字面量 'docList' 为首个元素的 元组,后面这个 Mug 就可以代表这个动态变化的 Query Key 用在任何地方了。

之后是分别实现 分页列表 和 搜索条件 的逻辑主体:

import { check, swirl, useOperator } from 'react-mug';

function DocPaginatedList() {
  const { data, isLoading } = useDocListQuery();
  const [, { pageIndex }] = useOperator(check, keyOfDocListQueryMug);

  return (
    <>
      {isLoading && <LoadingSpinner />}
      <Table rows={data?.docList ?? []} />
      <Pagination
        pageIndex={pageIndex}
        pageCount={data?.pageCount ?? 0}
        onPageIndexChange={(pageIndex) => swirl(keyOfDocListQueryMug, [, { pageIndex }])}
      />
    </>
  );
}
import { check, swirl, useOperator } from 'react-mug';

function DocSearchCriteria() {
  const [, { title, authors, labels }] = useOperator(check, keyOfDocListQueryMug);

  return (
    <>
      <TitleTextInput
        value={title}
        onThrottledChange={(title) => swirl(keyOfDocListQueryMug, [, { title }])}
      />
      <AuthorMultiSelect
        value={authors}
        onChange={(authors) => swirl(keyOfDocListQueryMug, [, { authors }])}
      />
      <LabelMultiSelect
        value={labels}
        onChange={(labels) => swirl(keyOfDocListQueryMug, [, { labels }])}
      />
    </>
  );
}

这里 swirl 能够以 合并逻辑 修改目标 Mug 里的状态,空着的字段则会保持原值。当 TitleTextInputAuthorMultiSelectLabelMultiSelectPagination 改变 keyOfDocListQueryMug 里的 Query Key 时,useDocListQuery 中的 Query Key 和查询参数会随之发生变化,进而触发新的查询更新列表,十分便捷。

弹出层里的 Query Key

接下来开始写弹出层的 文档元数据 和 文档预览,更新文档元数据和查询文档二进制内容的接口调用如下:

async function putDoc(doc: Doc): Promise<void> {
  // 调用后端接口推送数据
}

async function getDocBinaryContent(docId: string): Promise<string> {
  // 调用后端接口获取数据
}

现在处理一下文档元数据条目的点击事件:

import { construction, Mug } from 'react-mug';

const selectedDocIdMug: Mug<string | null> = {
  [construction]: null,
};
import { swirl } from 'react-mug';

function DocPaginatedList() {
  // ...

  return (
    <>
      {/* ... */}
      <Table rows={data?.docList ?? []} onRowClick={(docId) => swirl(selectedDocIdMug, docId)} />
      {/* ... */}
    </>
  );
}

这里声明了 selectedDocIdMug 来盛装状态 “被点中条目的文档 ID”,方便后面抽屉组件的访问。

最后就是抽屉的逻辑主体了:

import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { check, construction, useOperator } from 'react-mug';

function DocDrawer() {
  const queryClient = useQueryClient();

  const selectedDocId = useOperator(check, selectedDocIdMug);
  const [editingDoc, setEditingDoc] = useState(false);

  const drawerOpen = !!selectedDocId;

  const selectedDoc = useMemo(() => {
    const queryData = queryClient.getQueryData<Awaited<ReturnType<typeof getDocList>>>(
      check(keyOfDocListQueryMug)
    );
    return queryData?.docList.find((doc) => doc.id === selectedDocId);
  }, [selectedDocId]);

  const { data: docBinaryContent } = useQuery({
    queryKey: ['docBinaryContent', selectedDocId],
    queryFn: async () => {
      if (!selectedDocId) {
        return;
      }
      return await getDocBinaryContent(selectedDocId);
    },
    staleTime: Infinity,
  });

  return (
    <Drawer open={drawerOpen}>
      {editingDoc
        ? selectedDoc && <DocDisplay doc={selectedDoc} onClick={() => setEditingDoc(true)} />
        : selectedDoc && (
            <DocEdit
              initialDoc={selectedDoc}
              onCancel={() => setEditingDoc(false)}
              onSubmit={async (doc) => {
                await putDoc(doc);
                queryClient.invalidateQueries({ queryKey: check(keyOfDocListQueryMug) });
                setEditingDoc(false);
              }}
            />
          )}
      {docBinaryContent && <DocPreview docBinaryContent={docBinaryContent} />}
    </Drawer>
  );
}

这里 check 用来读取目标 Mug 里的状态。由于 keyOfDocListQueryMug 中的状态就是当前文档元数据列表查询的 Query Key ,所以对应的 check 返回值就可以直接分别用作 queryClient.getQueryDataqueryClient.invalidateQueries 的参数来查询和更新 React Query 缓存了,非常简单。

结语

以上便是简单而高效管理 Query Key 进而管理 React Query 缓存的实践了,欢迎 jym 多多交流。对 React Mug 感兴趣的话还可以移步至 把状态装进马克杯里,给你一个不烫手的状态库 进一步阅读,谢谢!