【实战】十、用 react-query 获取数据,管理缓存(下) —— React17+React Hook+TS4 最佳实践,仿 Jira 企业级项目(二十二)


学习内容来源:React + React Hook + TS 最佳实践-慕课网


相对原教程,我在学习开始时(2023.03)采用的是当前最新版本:

版本
react & react-dom ^18.2.0
react-router & react-router-dom ^6.11.2
antd ^4.24.8
@commitlint/cli & @commitlint/config-conventional ^17.4.4
eslint-config-prettier ^8.6.0
husky ^8.0.3
lint-staged ^13.1.2
prettier 2.8.4
json-server 0.17.2
craco-less ^2.0.0
@craco/craco ^7.1.0
qs ^6.11.0
dayjs ^1.11.7
react-helmet ^6.1.0
@types/react-helmet ^6.1.6
react-query ^6.1.0
@welldone-software/why-did-you-render ^7.0.1
@emotion/react & @emotion/styled ^11.10.6

具体配置、操作和内容会有差异,“坑”也会有所不同。。。


一、项目起航:项目初始化与配置

二、React 与 Hook 应用:实现项目列表

三、TS 应用:JS神助攻 - 强类型

四、JWT、用户认证与异步请求


五、CSS 其实很简单 - 用 CSS-in-JS 添加样式


六、用户体验优化 - 加载中和错误状态处理



七、Hook,路由,与 URL 状态管理



八、用户选择器与项目编辑功能


九、深入React 状态管理与Redux机制





十、用 react-query 获取数据,管理缓存

1~3

4.编辑和添加项目功能

接下来使用 react-query 改造编辑和添加工程功能,并新增获取项目详情 hook

编辑 src\utils\project.ts

...
import {
    
     useMutation, useQuery, useQueryClient } from "react-query";

export const useEditProject = () => {
    
    
  const client = useHttp();
  const queryClient = useQueryClient();
  return useMutation(
    (params: Partial<Project>) =>
      client(`projects/${
      
      params.id}`, {
    
    
        method: "PATCH",
        data: params,
      }),
    {
    
    
      onSuccess: () => queryClient.invalidateQueries("projects"),
    }
  );
};

export const useAddProject = () => {
    
    
  const client = useHttp();
  const queryClient = useQueryClient();
  return useMutation(
    (params: Partial<Project>) =>
      client(`projects`, {
    
    
        method: "POST",
        data: params,
      }),
    {
    
    
      onSuccess: () => queryClient.invalidateQueries("projects"),
    }
  );
};

export const useProject = (id?: number) => {
    
    
  const client = useHttp();
  return useQuery<Project>(['project', id], () => client(`projects/${
      
      id}`), {
    
     enabled: Boolean(id) });
};
  • invalidateQueries: 使指定key的数据过期(不指定就是所有都过期)
  • useMutation:处理写操作,比如create/update/delete等;
  • useIsMutation 和 useIsFetching:应用程序中是否存在获取请求或突变请求正在进行。

接下来完善 新增/编辑模态框

编辑 src\screens\ProjectList\components\ProjectModal.tsx(之前相当于是空页面):

import {
    
     Button, Drawer, Form, Input, Spin } from "antd";
import {
    
     useProjectModal } from "../utils";
import {
    
     UserSelect } from "components/user-select";
import {
    
     useAddProject, useEditProject } from "utils/project";
import {
    
     useForm } from "antd/lib/form/Form";
import {
    
     useEffect } from "react";
import {
    
     ErrorBox } from "components/lib";
import styled from "@emotion/styled";

export const ProjectModal = () => {
    
    
  const {
    
     projectModalOpen, close, editingProject, isLoading } = useProjectModal();
  const useMutateProject = editingProject ? useEditProject : useAddProject

  const {
    
     mutateAsync, error, isLoading: mutateLoading} = useMutateProject()
  const [form] = useForm()
  const onFinish = (values: any) => {
    
    
    mutateAsync({
    
    ...editingProject, ...values}).then(() => {
    
    
      form.resetFields()
      close()
    })
  }

  const title = editingProject ? '编辑项目' : '创建项目'

  useEffect(() => {
    
    
    form.setFieldsValue(editingProject)
    return () => form.resetFields()
  }, [editingProject, form])

  return (
    <Drawer forceRender onClose={
    
    close} open={
    
    projectModalOpen} width="100%">
      <Container>
        {
    
    
          isLoading ? <Spin size="large"/> : <>
            <h1>{
    
    title}</h1>
            <ErrorBox error={
    
    error}/>
            <Form form={
    
    form} layout="vertical" style={
    
    {
    
    width: '40rem'}} onFinish={
    
    onFinish}>
              <Form.Item label='名称' name='name' rules={
    
    [{
    
    required: true, message: '请输入项目名称'}]}>
                <Input placeholder="请输入项目名称"/>
              </Form.Item>
              <Form.Item label='部门' name='organization' rules={
    
    [{
    
    required: true, message: '请输入部门名称'}]}>
                <Input placeholder="请输入部门名称"/>
              </Form.Item>
              <Form.Item label='负责人' name='personId'>
                <UserSelect defaultOptionName="负责人"/>
              </Form.Item>
              <Form.Item style={
    
    {
    
    textAlign: 'right'}}>
                <Button loading={
    
    mutateLoading} type="primary" htmlType="submit">提交</Button>
              </Form.Item>
            </Form>
          </>
        }
      </Container>
    </Drawer>
  );
};

const Container = styled.div`
  height: 80vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
`

注意:

由于负责人是选填项,因此对应组件 UserSelect 的子组件 IdSelect 的参数改为选填 src\components\id-select.tsx

...
// 类型不是简单的后来者居上,而是寻求"最大公约数"的方式
interface IdSelectProps extends Omit<SelectProps, "value" | "onChange" | "options"> {
    
    
  value?: SN | null | undefined;
  onChange?: (value?: number) => void;
  ...
}

...
export const IdSelect = (props: IdSelectProps) => {
    
    
  const {
    
     value, onChange, defaultOptionName, options, ...restProps } = props;
  return (
    <Select
      value={
    
    options?.length ? toNumber(value) : 0}
      onChange={
    
    (value) => onChange?.(toNumber(value) || undefined)}
      {
    
    ...restProps}
    >
      ...
    </Select>
  );
};
...

页面搭好了,接下来完善数据逻辑

编辑 src\screens\ProjectList\utils.ts

import {
    
     useMemo } from "react";
import {
    
     useProject } from "utils/project";
import {
    
     useUrlQueryParam } from "utils/url";
...
export const useProjectModal = () => {
    
    
  const [{
    
     projectCreate, editingProjectId }, setUrlParams] = useUrlQueryParam([
    "projectCreate", 'editingProjectId'
  ]);
  const {
    
     data: editingProject, isLoading } = useProject(
    Number(editingProjectId)
  );

  const open = () => setUrlParams({
    
     projectCreate: true });
  const close = () => setUrlParams({
    
     projectCreate: false, editingProjectId: undefined }); // 若在url显示 false,可以改为 undefined 隐藏
  const startEdit = (id: number) => setUrlParams({
    
     editingProjectId: id });

  return {
    
    
    projectModalOpen: projectCreate === "true" || Boolean(editingProjectId),
    open,
    close,
    startEdit,
    editingProject,
    isLoading
  };
};

将编辑功能使用到 List 组件上,完成最后一步

编辑 src\screens\ProjectList\components\List.tsx

...
export const List = ({
     
      users, ...props }: ListProps) => {
    
    
  const {
    
     startEdit } = useProjectModal();
  ...
  const editProject = (id: number) => () => startEdit(id);
  return (
    <Table
      pagination={
    
    false}
      columns={
    
    [
        ...
        {
    
    
          render: (text, project) => {
    
    
            const items: MenuProps["items"] = [
              {
    
    
                key: "edit",
                label: "编辑",
                onClick: editProject(project.id),
              },
              ...
            ];
            ...
          },
        },
      ]}
      {
    
    ...props}
    ></Table>
  );
};

查看页面,新增和编辑功能均正常

5.用 react-query 实现乐观更新

在网络情况不是很好的时候,为了提高用户体验可以使用“乐观更新”,即直接按用户意愿在请求成功之前更新本地缓存数据,若是请求失败则自动执行数据回滚

乐观更新 Optimistic Updates | TanStack Query 中文文档

编辑 src\utils\project.ts

...
import {
    
     useProjectsSearchParams } from "screens/ProjectList/utils";
...
export const useEditProject = () => {
    
    
  const client = useHttp();
  const queryClient = useQueryClient();
  const [searchParams] = useProjectsSearchParams()
  const queryKey = ['projects', searchParams]
  return useMutation(
    (params: Partial<Project>) =>
      client(`projects/${
      
      params.id}`, {
    
    
        method: "PATCH",
        data: params,
      }),
    {
    
    
      onSuccess: () => queryClient.invalidateQueries(queryKey),
      // async 
      onMutate: (target) => {
    
    
        const previousItems = queryClient.getQueryData(queryKey)
        queryClient.setQueryData(queryKey, (old: Project[] = []) => {
    
    
          return old?.map(project => project.id === target.id ? {
    
     ...project, ...target } : project)
        })
        return {
    
    previousItems}
      },
      onError: (error, newItem, context) => {
    
    
        queryClient.setQueryData(queryKey, context?.previousItems)
      }
    }
  );
};
...

查看页面,调整开发工具响应时间为 2000ms,star 某个项目,查看效果;
再把失败率调为 100%,再次 star 某个项目,查看效果

功能好使,但是发现这部分好多代码(useMutation 的第二参数)是可以复用的,接下来便提炼一个专门处理乐观更新的 hook

新建 src\utils\use-optimistic-options.ts

import {
    
     QueryKey, useQueryClient } from 'react-query'

export const useConfig = (queryKey: QueryKey, callback: (target: any, old?: any[]) => any[]) => {
    
    
  const queryClient = useQueryClient()
  return {
    
    
    onSuccess: () => queryClient.invalidateQueries(queryKey),
    // async 
    onMutate: (target: any) => {
    
    
      const previousItems = queryClient.getQueryData(queryKey)
      queryClient.setQueryData(queryKey, (old?: any[]) => callback(target, old))
      return {
    
    previousItems}
    },
    onError: (error: any, newItem: any, context: any) => {
    
    
      queryClient.setQueryData(queryKey, context?.previousItems)
    }
  }
}

export const useDeleteConfig = (queryKey: QueryKey) => 
  useConfig(queryKey, (id: any, old?:any[]) => old?.filter(item => item.id !== id) || [])
export const useEditConfig = (queryKey: QueryKey) => 
  useConfig(queryKey, (target: any, old?:any[]) => old?.map(item => item.id === target.id ? {
    
     ...item, ...target } : item) || [])
export const useAddConfig = (queryKey: QueryKey) => 
  useConfig(queryKey, (target: any, old?:any[]) => old ? [...old, target] : [])
  • 由于 react-query 的类型机制比较复杂,强制适配性价比不高,且此部分独立性较高,因此多处类型使用 any

因为 项目列表搜索的参数 useProjectsSearchParams 是和 ProjectList 页面绑定的,而 useEditProject 这种很有可能在其他页面使用,为了保持其通用性,queryKey 单独提取出来,在使用到的时候作为参数传入

编辑 src\screens\ProjectList\utils.ts

...
import {
    
     QueryKey, useMutation, useQuery } from "react-query";
import {
    
     useAddConfig, useEditConfig } from "./use-optimistic-options";
...
export const useEditProject = (queryKey: QueryKey) => {
    
    
  const client = useHttp();
  return useMutation(
    ...,
    useEditConfig(queryKey)
  );
};

export const useAddProject = (queryKey: QueryKey) => {
    
    
  const client = useHttp();
  return useMutation(
    ...,
      useAddConfig(queryKey)
  );
};
...

编辑 src\screens\ProjectList\utils.ts

...
export const useProjectQueryKey = () => ['projects', useProjectsSearchParams()[0]]
...

使用到的地方传入 queryKey

编辑 src\screens\ProjectList\components\List.tsx

...
import {
    
     useProjectModal, useProjectQueryKey } from "../utils";
...
export const List = (...) => {
    
    
  ...
  const {
    
     mutate } = useEditProject(useProjectQueryKey());
  ...
};

编辑 src\screens\ProjectList\components\ProjectModal.tsx

...
import {
    
     useProjectModal, useProjectQueryKey } from "../utils";

export const ProjectModal = () => {
    
    
  ...
  const {
    
     mutateAsync, error, isLoading: mutateLoading } = useMutateProject(useProjectQueryKey());
  ...
};
...

再次测试功能,正常!

接下来把删除功能完成一下

编辑 src\utils\project.ts

...
import {
    
     useAddConfig, useDeleteConfig, useEditConfig } from "./use-optimistic-options";
...
export const useDeleteProject = (queryKey: QueryKey) => {
    
    
  const client = useHttp();
  return useMutation(
    (id?: number) =>
      client(`projects/${
      
      id}`, {
    
    
        method: "DELETE"
      }),
      useDeleteConfig(queryKey)
  );
};
...

编辑 src\screens\ProjectList\components\List.tsx

...
import {
    
     Dropdown, MenuProps, Modal, Table, TableProps } from "antd";
import {
    
     useProjectModal, useProjectQueryKey } from "../utils";
...
export const List = ({
     
      users, ...props }: ListProps) => {
    
    
  const {
    
     mutate } = useEditProject(useProjectQueryKey());
  // 函数式编程 柯里化
  const starProject = (id: number) => (star: boolean) => mutate({
    
     id, star });
  return (
    <Table
      pagination={
    
    false}
      columns={
    
    [
        ...
        {
    
    
          render: (text, project) => <More project={
    
    project}/>
        },
      ]}
      {
    
    ...props}
    ></Table>
  );
};

const More = ({
     
     project}: {
     
     project: Project}) => {
    
    
  const {
    
     startEdit } = useProjectModal();
  const editProject = (id: number) => () => startEdit(id);
  const {
    
    mutate: deleteProject} = useDeleteProject(useProjectQueryKey())
  const confirmDeleteProject = (id: number) => {
    
    
    Modal.confirm({
    
    
      title: '确定删除这个项目吗?',
      content: '点击确定删除',
      onOk: () => deleteProject(id)
    })
  }
  const items: MenuProps["items"] = [
    {
    
    
      key: "edit",
      label: "编辑",
      onClick: editProject(project.id),
    },
    {
    
    
      key: "delete",
      label: "删除",
      onClick: () => confirmDeleteProject(project.id),
    },
  ];
  return (
    <Dropdown menu={
    
    {
    
     items }}>
      <ButtonNoPadding
        type="link"
        onClick={
    
    (e) => e.preventDefault()}
      >
        ...
      </ButtonNoPadding>
    </Dropdown>
  );
}
  • 编辑删除这部分功能可以单独摘出来
  • 注意这里并未按照视频中删除时传入 {id} 而是在 useDeleteConfig 中 直接使用 id(target)

删除某项测试功能(注意:测试乐观更新需要调整开发工具响应时间哦!)

新增的乐观更新并不明显

6.单独提取url参数逻辑

按照视频之前的写法是有问题的,因此这里需要单独将 提取 url 参数逻辑 抽离

编辑 src\utils\url.ts

...
export const useUrlQueryParam = <K extends string>(keys: K[]) => {
    
    
  const [searchParams] = useSearchParams();
  const setSearchParams = useSetUrlSearchParam()
  return [
    useMemo(
      () =>
        keys.reduce((prev, key) => {
    
    
          // searchParams.get 可能会返回 null,需要预设值来兼容
          return {
    
     ...prev, [key]: searchParams.get(key) || "" };
          // 初始值会对类型造成影响,需要手动指定
        }, {
    
    } as {
    
     [key in K]: string }),
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [searchParams]
    ),
    (params: Partial<{
     
      [key in K]: unknown }>) => setSearchParams(params)
  ] as const;
};

export const useSetUrlSearchParam = () => {
    
    
  const [searchParams, setSearchParams] = useSearchParams();
  return (params: {
     
     [key in string]: unknown}) => {
    
    
    const o = cleanObject({
    
    
      ...Object.fromEntries(searchParams),
      ...params,
    }) as URLSearchParamsInit;
    return setSearchParams(o);
  }
}

编辑 src\screens\ProjectList\utils.ts

...
import {
    
     useSetUrlSearchParam, useUrlQueryParam } from "utils/url";
...
export const useProjectModal = () => {
    
    
  const [{
    
     projectCreate, editingProjectId }] = useUrlQueryParam([
    "projectCreate",
    "editingProjectId",
  ]);
  const setUrlParams = useSetUrlSearchParam()
  const {
    
     data: editingProject, isLoading } = useProject(
    Number(editingProjectId)
  );

  const open = () => setUrlParams({
    
     projectCreate: true });
  const close = () =>
    setUrlParams({
    
     projectCreate: false, editingProjectId: undefined }); // 若在url显示 false,可以改为 undefined 隐藏
  ...
};

其实按博主的理解,重点不是抽离,而是 setUrlParamsprojectCreateeditingProjectId 要同时,而不是按照视频之前一个一个设置(还是感觉怪怪的,不知道这是不是之前按视频操作 projectCreate 无法设置为 false 的原因)

最后,记得在登出后清除 react-query 的缓存,避免下一个用户登录后,新数据请求回来之前旧数据依然展示的bug

编辑 src\context\auth-context.tsx

...
import {
    
     useQueryClient } from "react-query";
...
export const AuthProvider = ({
     
      children }: {
     
      children: ReactNode }) => {
    
    
  ...
  const queryClient = useQueryClient()

  ...
  const logout = () => auth.logout().then(() => {
    
    
    setUser(null)
    queryClient.clear()
  });

  ...
};

7.跨组件状态管理方案总结

小场面

  • 状态提升、组合组件

缓存状态

  • react-query、swr

客户端状态

  • url、redux、context

部分引用笔记还在草稿阶段,敬请期待。。。

猜你喜欢

转载自blog.csdn.net/qq_32682301/article/details/132269313