【实战】十、用 react-query 获取数据,管理缓存(下) —— React17+React Hook+TS4 最佳实践,仿 Jira 企业级项目(二十二)
文章目录
相对原教程,我在学习开始时(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;
`
注意:
Drawer
组件中使用通过useForm()
提取的form
绑定的Form
时,需要添加forceRender
属性,否则在页面打开时绑定不到会有报错,参见:【实战】React 实战项目常见报错 —— Instance created by ‘useForm’ is not connected to any Form element. Forget…useEditProject
和useAddProject
参数同且互斥,因此可合并为一个hook
使用- 视频中在后续有编辑并关闭模态框后进行清除表单处理,博主这里提前使用
useEffect
的callback
做了处理
由于负责人是选填项,因此对应组件 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 实现乐观更新
在网络情况不是很好的时候,为了提高用户体验可以使用“乐观更新”,即直接按用户意愿在请求成功之前更新本地缓存数据,若是请求失败则自动执行数据回滚
编辑 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 隐藏
...
};
其实按博主的理解,重点不是抽离,而是
setUrlParams
时projectCreate
和editingProjectId
要同时,而不是按照视频之前一个一个设置(还是感觉怪怪的,不知道这是不是之前按视频操作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
部分引用笔记还在草稿阶段,敬请期待。。。
版权声明:
本文为[程序边界]所创,转载请带上原文链接,感谢
https://blog.csdn.net/qq_32682301/article/details/132269313