前端学习之React项目实战Ⅲ
2022-03-18 22:54:43

添加项目页面

状态提升

按照项目的设计,在 Header ,项目列表以及编辑项目三处都会打开新增项目或者说是编辑项目的 Modal,这些组件都需要这个控制 Modal 的状态,常见的做法是使用状态提升,将其共同需要的状态提升至距离它们最近的父组件中,在项目中是 AuthenticateApp这个组件,即在此创建一个新的状态:

1
const [projectModalOpen,setProjectModalOpen] = useState(false)

之后即是对于 Modal 组件的大致编写:

1
2
3
4
5
6
export const ProjectModal = (props: {projectModalOpen: boolean,onClose:()=>void}) => {
return <Drawer onClose={props.onClose} visible={props.projectModalOpen}>
<h1> Project Modal </h1>
<Button onClick={props.onClose}>关闭</Button>
</Drawer>
}

然后将setProjectModalOpen进行传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Route path={"/projects"} element={<ProjectListPage setProjectModalOpen={setProjectModalOpen} />} />

<List
...otherProps
setProjectModalOpen={props.setProjectModalOpen}
/>

// project list
{
render(value,project){
return <Dropdown overlay={<Menu>
<Menu.Item>
<ButtonNoPadding type={'link'} onClick={()=>props.setProjectModalOpen(true)}>编辑</ButtonNoPadding>
</Menu.Item>
</Menu>}>
<ButtonNoPadding type={'link'}>...</ButtonNoPadding>
</Dropdown>
}
}

可以看到 props 这样传输,会根据组件的复杂程度,传递相当多次,这样的现象叫做 prop drilling;如此的下钻,使用是没有问题的,但是定义和调用相距太远,导致层级差距太大的组件耦合,如需要修改业务的时候,就需要每一处都做修改;

组件组合

React 官网提供一种方案叫做组件组合,能够避免这类情况,即它选择 JSX 组件直接作为 props 传入,修改时只需要修改最初的传入值即可:

1
2
3
4
5
6
7
8
<PageHeader projectButton={
<ButtonNoPadding
type="link"
onClick={() => setProjectModalOpen(true)}
>
创建项目
</ButtonNoPadding>
} />

URL管理全局状态

上面演示了常用的两种对于处理状态的模式,但我们都不会采用,而是会使用 URL 来管理,来写个 Hook 吧:

1
2
3
4
5
6
7
8
9
10
11
12
export const useProjectModal = ()=>{
const [{projectCreate}, setProjectCreate] = useUrlQueryParam([
'projectCreate'
])
const open = () => setProjectCreate({projectCreate: true})
const close = () => setProjectCreate({projectCreate: undefined})
return {
projectCreate: projectCreate === 'true',
open,
close
}
}

这样下来,只需要在需要打开 Modal 组件内,使用该Hook就行了,但是这里有一个小问题:

1
useLocation() may be used only in the context of a <Router> component.

当看到context这个字眼时,我们并不应该陌生,类似我们自己创建的useAuth中使用的 context,大概率是因为我们使用了useSearchParams这个 Hook,其中可能间接使用了useLocation,所以我们将用到了useSearchParams组件都放到Router组件内部,没错,猜测是正确的!

处理服务端缓存

类型守卫

1
2
3
{error ? (
<Typography.Text type="danger">{error.message}</Typography.Text>
) : null}

对于我们之前对错误的显示,也可以加以优化,如封装一个组件:

1
2
3
4
5
6
7
8
9
// 自定义类型守卫
const isError = (value:any):value is Error => value?.message

export const ErrorBox = ({error} : {error:unknown})=>{
if(isError(error)){
return <Typography.Text type="danger">{error.message}</Typography.Text>
}
return null
}

对此,可引出类型守卫这个概念,其所指的是一种类型推断的行为,能在语句的块级作用域内「收紧」变量的类型,以下是常用的四种:

  • 类型判断:typeof
  • 实例判断:instanceof
  • 属性判断:in
  • 字面量相等判断:==, ===, !=, !==

而我们上面代码中的是形如{形参} is {类型}的自定义的类型守卫,通过返回布尔值的条件函数赋予类型守卫的能力;

使用 Redux Toolkit

相较于传统的 React-Redux 状态管理模式,Redux ToolKit 再抽象封装了一层 API ,使得在面对大型状态管理时,更易读和管理:

  • configureStore:像从 Redux 中创建原始的 createStore 一样创建一个 Redux store 实例,但接受一个命名的选项对象并自动设置 Redux DevTools 扩展。
  • createAction:接受一个 Action 类型字符串,并返回一个使用该类型的 Action 创建函数。
  • createReducer:接受初始状态值和 Action 类型的查找表到 reducer 函数,并创建一个处理所有 Action类型的 reducer。
  • createSlice:接受一个初始状态和一个带有 reducer 名称和函数的查找表,并自动生成 action creator 函数、action 类型字符串和一个 reducer 函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { createSlice } from '@reduxjs/toolkit';
export const initialState = {
users: [],
loading: false,
error: false,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
getUser: (state, action) => {
state.users = action.payload;
state.loading = true;
state.error = false;
},
createUser: (state, action) => {
state.users.unshift(action.payload);
state.loading = false;
},
deleteUser: (state, action) => {
state.users.filter((user) => user.id !== action.payload.id);
state.loading = false;
},
},
});
// 导出 action 和 reducer
export const { createUser, deleteUser, getUser } = userSlice.actions;
export default userSlice.reducer;

相较于传统的redux应用,createSlice 不需要在管理和识别 action,也无需为 action 和 reducer 创建单独的目录进行管理,在导出后引入即可:

1
2
3
4
5
6
7
import { configureStore } from "@reduxjs/toolkit";
import userSlice from "./features/user/userSlice";
export default configureStore({
reducer: {
user: userSlice,
},
});

使用 React Query

React-Query是一个基于React Hooks的数据请求库,这个库将帮助你获取、同步、更新和缓存你的远程数据, 提供两个简单的 hooks,就能完成增删改查等操作;

例如,我们使用React Query中的最常用的useQuery来改造useProject

1
2
3
4
export const useProjects = (param?: Partial<Project>) => {
const request = useHttp();
return useQuery<Project[]>(['projects',param],() => request("projects", { data: cleanObject(param || {}) }))
}

useQuery用于查询数据第一个参数queryKey,指定用于查询的 key,当 key 更改时,则会再次自动查询;其次则是返回 Promise 的函数,第三个参数用于操作的配置;

下一个改造的则是useEditProject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const useEditProject = () => {
const client = useHttp();
const queryClient = useQueryClient()

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

useMutation处理数据的增/删/改,这里比查询多了第三个参数中的onSuccess回调,用于更改后刷新列表;其中返回的mutate函数,可传入参数和回调函数进行更新数据:

1
const pinProject = (id: number) => (pin: boolean) => mutate({ id, pin })

编辑和添加项目

在编辑页面前,我们需要在useProjectModal内添加一些东西:

1
2
3
4
5
6
7
8
9
const [{ projectCreate, editingProjectId }, setProjectParam] =
useUrlQueryParam(["projectCreate", "editingProjectId"]);

const { data: editingProject, isLoading } = useProject(+editingProjectId);

const open = () => setProjectParam({ projectCreate: true });
const close = () =>
setProjectParam({ projectCreate: undefined, editingProjectId: undefined });
const startEdit = (id: number) => setProjectParam({ editingProjectId: id });

editingProjectId编辑的项目的 id,也选择用 URL 来管理,并且获取到该对应项目的数据;startEdit函数用于打开编辑的页面,会将参数 id 设置为待编辑项目;

1
2
3
4
5
6
const { startEdit } = useProjectModal();
const editProject = (id: number) => () => startEdit(id);

<Menu.Item onClick={editProject(project.id)} key={"edit"}>
编辑
</Menu.Item>

再来是编辑页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { projectModalOpen, close, editingProject, isLoading } =
useProjectModal();
const title = editingProject ? "编辑项目" : "添加项目";

const useMutationProject = editingProject ? useEditProject : useAddProject;
const { isLoading: mutateLoading, mutateAsync, error } = useMutationProject();

const [form] = useForm();
const onFinish = (value: any) => {
mutateAsync({ ...editingProject, ...value }).then(() => {
form.resetFields();
close();
});
};

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

首先拿到editingProject看看是否存在,决定是添加还是编辑任务;使用useForm创建一个 form 实例用于绑定到 From 表单,继续数据操作;onFinish函数用于表单提交,且使用的是异步执行函数,确保操作成功后关闭页面,并且清空表单数据;useEffect内,初始化表单;以下是编辑页面的 JSX 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
export const ProjectModal = () => {
return (
<Drawer forceRender onClose={close} visible={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="名称"
key={"name"}
name={"name"}
rules={[{ required: true, message: "请输入项目名称" }]}
>
<Input placeholder={"请输入项目名"}></Input>
</Form.Item>
<Form.Item
label="部门"
key={"organization"}
name={"organization"}
rules={[{ required: true, message: "请输入部门名称" }]}
>
<Input placeholder={"请输入部门名"}></Input>
</Form.Item>
<Form.Item label="负责人" key={"person"} name={"personId"}>
<UserSelect defaultOptionName="负责人"></UserSelect>
</Form.Item>
<Form.Item key={"submit"} style={{ textAlign: "right" }}>
<Button
loading={mutateLoading}
type={"ghost"}
style={{ marginRight: "2rem" }}
onClick={close}
>
取消
</Button>
<Button
loading={mutateLoading}
type={"primary"}
htmlType={"submit"}
>
提交
</Button>
</Form.Item>
</Form>
</>
)}
</Container>
</Drawer>
);
};

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

乐观更新

对于项目的操作,我们在此可引入乐观更新这个概念,也就是可以达到原生应用的效果,进行操作后立即更新UI,然后根据实际服务端返回的数据进行覆盖或者回滚;先拿useEditProject下手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
return useMutation(
(params: Partial<Project>) => {
return client(`projects/${params.id}`, {
method: "PATCH",
data: params,
});
},
{
onSuccess: () => {
queryClient.invalidateQueries(queryKey)
},
async onMutate(target) {
// 撤销查询,以免覆盖乐观更新数据
await queryClient.cancelQueries(queryKey)
const previousItems = queryClient.getQueryData(queryKey)
queryClient.setQueryData(queryKey, (old?: Project[]) => {
return old?.map(project => project.id === target.id ? { ...project, ...target } : project) || []
})
// 保留上一次的快照到 context
return { previousItems }
},
onError(error, newItem, context: any) {
queryClient.setQueryData(queryKey, context.previousItems)
}
}
);

useEditProject中我们返回了useMutation进行数据操作,在其的配置参数中,传入了onMutateonError,分别是立即执行和遇到错误后执行;

onMutate中,首先撤销了对该 queryKey 的查询,之后根据当前操作的数据对原数据进行处理,最后返回将原数据保存到上下文中;

若请求数据过程出现错误,会回调onError,将上下文中保存的原数据进行回滚;

不过这样的写法,只是针对useEditProject单独做了乐观更新,完全耦合在一起的,我们也可以抽离出一个独立的工具,因为是通用方法,所以避免类型过于复杂,直接使用 Any 大法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export const useConfig = (
queryKey: QueryKey,
callback: (target: any, old?: any[]) => any[]
) => {
const queryClient = useQueryClient();
return {
onSuccess: () => {
queryClient.invalidateQueries(queryKey);
},
async onMutate(target: any) {
await queryClient.cancelQueries(queryKey);
const previousItems = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old?: any[]) => {
return callback(target, old);
});
return { previousItems };
},
onError(error: any, newItem: any, context: any) {
queryClient.setQueryData(queryKey, context.previousItems);
},
};
};

// 根据 useConfig 可编写其他的 Hook...
export const useDeleteConfig = (queryKey: QueryKey) =>
useConfig(
queryKey,
(target, old) => old?.filter((item) => item.id !== target.id) || []
);

这样一来,useEditProject则直接添加一个queryKey参数即可根据外部状态进行查询操作了:

1
2
3
4
5
6
7
8
9
10
11
12
export const useEditProject = (queryKey: QueryKey) => {
const client = useHttp();
return useMutation(
(params: Partial<Project>) => {
return client(`projects/${params.id}`, {
method: "PATCH",
data: params,
});
},
useEditConfig(queryKey)
);
};

删除项目

做了上述工作后,我们可以将编辑项目和删除项目的 popMenu 单独抽成一个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const More = ({ project }: { project: Project }) => {
const { startEdit } = useProjectModal();
const editProject = (id: number) => () => startEdit(id);
const { mutate: delProject } = useDeleteProject(useProjectQueryKey());
const confirmDeleteProject = (dProject: Project) => {
Modal.confirm({
title: `确认要删除项目 ${dProject.name} 吗?`,
content: "点击确认删除",
okText: "删除",
onOk() {
delProject(dProject);
},
});
};
return (
<Dropdown
overlay={
<Menu>
<Menu.Item onClick={editProject(project.id)} key={"edit"}>
编辑
</Menu.Item>
<Menu.Item
onClick={() => confirmDeleteProject(project)}
key={"delete"}
>
删除
</Menu.Item>
</Menu>
}
>
<ButtonNoPadding type={"link"}>...</ButtonNoPadding>
</Dropdown>
);
};

Bug 修复

  1. 编辑项目后,表单内容未清除,编写一个关闭函数,内部清除表单:

    1
    2
    3
    4
    const closeModal = () =>{
    form.resetFields()
    close()
    }
  2. 打开编辑项目Modal时,需要等待异步获取待编辑项目数据,而不是进入Loading,修改判断条件:

    1
    projectModalOpen: projectCreate === "true" || Boolean(editingProjectId),
  3. 在当前用户退出登录,其他用户登录时,也跳闪缓存的数据,在登出时对缓存数据进行清除:

    1
    2
    3
    4
    5
    const logout = () =>
    auth.logout().then(() => {
    queryClient.clear();
    setUser(null);
    });

看板列表开发

引入部分 svg 会报错,这是因为在 JSX 中属性需要使用驼峰格式,可最快速的方法是使用工具替换:

SyntaxError: unknown: Namespace tags are not supported by default. React’s JSX doesn’t support namespace tags. You can set throwIfNamespace: false to bypass this warning.

搜索关键字高亮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Mark = ({ keyword, name }: { name: string; keyword: string }) => {
if (!keyword) return <>{name}</>;
const arr = name.split(keyword);
return (
<>
{arr.map((str, index) => (
<span key={index}>
{str}
{index === arr.length - 1 ? null : (
<span style={{ color: "#257AFD" }}>{keyword}</span>
)}
</span>
))}
</>
);
};

拖拽实现

过于复杂,且不常用,使用第三包react-beautiful-dnd实现;

排序乐观更新

代码测试