文章目录
-
- 一、项目起航:项目初始化与配置
- [二、React 与 Hook 应用:实现项目列表](#二、React 与 Hook 应用:实现项目列表)
- [三、TS 应用:JS神助攻 - 强类型](#三、TS 应用:JS神助攻 - 强类型)
- 四、JWT、用户认证与异步请求
- [五、CSS 其实很简单 - 用 CSS-in-JS 添加样式](#五、CSS 其实很简单 - 用 CSS-in-JS 添加样式)
- [六、用户体验优化 - 加载中和错误状态处理](#六、用户体验优化 - 加载中和错误状态处理)
- [七、Hook,路由,与 URL 状态管理](#七、Hook,路由,与 URL 状态管理)
- 八、用户选择器与项目编辑功能
- [九、深入React 状态管理与Redux机制](#九、深入React 状态管理与Redux机制)
- [十、用 react-query 获取数据,管理缓存](#十、用 react-query 获取数据,管理缓存)
- 十一、看板页面及任务组页面开发
相对原教程,我在学习开始时(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~6
7&8
9.拖拽实现
接下来的内容将会是整个课程最难的部分,相关知识也不是很常用
- 安装拖拽功能库
react-beautiful-dnd
及相关类型文件库
bash
npm i react-beautiful-dnd ## --force
npm i @types/react-beautiful-dnd -D ## --force
接下来在原功能库的基础上自行二次封装
新建 src\components\grag-and-drop.tsx
:
js
import React, { ReactNode } from "react"
import { Draggable, DraggableProps, Droppable, DroppableProps, DroppableProvided, DroppableProvidedProps } from "react-beautiful-dnd"
// 删除原有 "函数类型" children,使用 ReactNode 类型的 children
type DropProps = Omit<DroppableProps, 'children'> & { children: ReactNode }
export const Drop = ({ children, ...props }: DropProps) => {
return (
<Droppable {...props}>
{(provided) => {
if (React.isValidElement(children)) {
return React.cloneElement(children, {
...provided.droppableProps,
ref: provided.innerRef,
provided,
});
}
return <div />;
}}
</Droppable>
);
};
type DropChildProps =
Partial<{ provided: DroppableProvided } & DroppableProvidedProps> &
React.HTMLAttributes<HTMLDivElement>
export const DropChild = React.forwardRef<HTMLDivElement, DropChildProps>(
({children, ...props}, ref) => <div ref={ref} {...props} >
{children}
{props.provided?.placeholder}
</div>
);
type DragProps = Omit<DraggableProps, 'children'> & { children: ReactNode }
export const Drag = ({children, ...props}: DragProps) => {
return <Draggable {...props}>
{
(provided => {
if(React.isValidElement(children)) {
return React.cloneElement(children, {
...provided.draggableProps,
...provided.dragHandleProps,
ref: provided.innerRef
})
}
return <div/>
})
}
</Draggable>
}
forwardRef 是用作转发的,经过包裹后的组件可以传入 ref 属性
这步报错:ref: provided.innerRef
bash
Argument of type '{ ref: (element: HTMLElement | null) => void; 'data-rbd-drag-handle-draggable-id'?: string | undefined; 'data-rbd-drag-handle-context-id'?: string | undefined; 'aria-describedby'?: string | undefined; ... 7 more ...; onTransitionEnd?: React.TransitionEventHandler<...> | undefined; }' is not assignable to parameter of type 'Partial<unknown> & Attributes'.
Object literal may only specify known properties, and 'ref' does not exist in type 'Partial<unknown> & Attributes'.
接下来使用这个组件
编辑 src\screens\ViewBoard\index.tsx
:
js
...
import { DragDropContext } from "react-beautiful-dnd";
import { Drag, Drop, DropChild } from "components/grag-and-drop";
export const ViewBoard = () => {
useDocumentTitle("看板列表");
const { data: currentProject } = useProjectInUrl();
const { data: viewboards, isLoading: viewBoardIsLoading } = useViewboards(
useViewBoardSearchParams()
);
const { isLoading: taskIsLoading } = useTasks(useTasksSearchParams());
const isLoading = taskIsLoading || viewBoardIsLoading;
return (
<DragDropContext onDragEnd={() => {}}>
<ViewContainer>
<h1>{currentProject?.name}看板</h1>
<SearchPanel />
{isLoading ? (
<Spin />
) : (
<Drop type='COLUMN' direction='horizontal' droppableId="viewboard">
<ColumnsContainer>
{viewboards?.map((vbd, index) => (
<Drag key={vbd.id} draggableId={'viewboard' + vbd.id} index={index}>
<ViewboardColumn viewboard={vbd} key={vbd.id} />
</Drag>
))}
<CreateViewBoard />
</ColumnsContainer>
</Drop>
)}
<TaskModal />
</ViewContainer>
</DragDropContext>
);
};
export const ColumnsContainer = styled(DropChild)`
display: flex;
overflow-x: scroll;
flex: 1;
`;
编辑 src\screens\ViewBoard\components\ViewboardCloumn.tsx
使组件可以透传 props
以及通过 forwardRef
转发 传入 ref
:
js
...
export const ViewboardColumn = React.forwardRef<HTMLDivElement, { viewboard: Viewboard }>(({ viewboard, ...props }, ref) => {
const { data: allTasks } = useTasks(useTasksSearchParams());
const tasks = allTasks?.filter((task) => task.kanbanId === viewboard.id);
return (
<Container {...props} ref={ref}>
<Row>
<h3>{viewboard.name}</h3>
<More viewboard={viewboard} key={viewboard.id}/>
</Row>
<TasksContainer>
{tasks?.map((task) => (
<TaskCard key={task.id} task={task} />
))}
<CreateTask kanbanId={viewboard.id} />
</TasksContainer>
</Container>
);
});
10.拖拽持久化
拖拽的时候 看板之间的间隔应该是不变的
编辑 src\screens\ViewBoard\index.tsx
(调整组件层级并显式使用 DropChild
):
js
...
export const ViewBoard = () => {
...
return (
<DragDropContext onDragEnd={() => {}}>
<ViewContainer>
<h1>{currentProject?.name}看板</h1>
<SearchPanel />
{isLoading ? (
<Spin />
) : (
<ColumnsContainer>
<Drop type="COLUMN" direction="horizontal" droppableId="viewboard">
<DropChild style={{display: 'flex'}}>
{viewboards?.map((vbd, index) => (
<Drag
key={vbd.id}
draggableId={"viewboard" + vbd.id}
index={index}
>
<ViewboardColumn viewboard={vbd} key={vbd.id} />
</Drag>
))}
</DropChild>
</Drop>
<CreateViewBoard />
</ColumnsContainer>
)}
<TaskModal />
</ViewContainer>
</DragDropContext>
);
};
export const ColumnsContainer = styled.div`
display: flex;
overflow-x: scroll;
flex: 1;
`;
接下来做 任务拖拽排序
编辑 src\screens\ViewBoard\components\ViewboardCloumn.tsx
:
js
...
import { Drag, Drop, DropChild } from "components/grag-and-drop";
...
export const ViewboardColumn = React.forwardRef<
HTMLDivElement,
{ viewboard: Viewboard }
>(({ viewboard, ...props }, ref) => {
const { data: allTasks } = useTasks(useTasksSearchParams());
const tasks = allTasks?.filter((task) => task.kanbanId === viewboard.id);
return (
<Container {...props} ref={ref}>
<Row>
<h3>{viewboard.name}</h3>
<More viewboard={viewboard} key={viewboard.id} />
</Row>
<TasksContainer>
<Drop type="Row" direction="vertical" droppableId={'task' + viewboard.id}>
<DropChild>
{tasks?.map((task, taskIndex) => (
<Drag
key={task.id}
draggableId={"task" + task.id}
index={taskIndex}>
<TaskCard key={task.id} task={task} />
</Drag>
))}
</DropChild>
</Drop>
<CreateTask kanbanId={viewboard.id} />
</TasksContainer>
</Container>
);
});
拖拽功能好了,接下来将拖拽结果持久化到数据库中
编辑 src\utils\use-optimistic-options.ts
(看板和任务排序 获取URL参数,为后续乐观更新做准备):
js
...
export const useReorderViewboardConfig = (queryKey: QueryKey) =>
useConfig(queryKey, (target, old) => old ? [old, ...target] : []);
export const useReorderTaskConfig = (queryKey: QueryKey) =>
useConfig(queryKey, (target, old) => old || []);
编辑 src\utils\viewboard.ts
(新增看板排序接口的 Custom Hook
, SortProps
与 useReorderTask
共用):
js
...
export interface SortProps {
// 要重新排序的 item
fromId: number;
// 目标 item
referenceId: number;
// 放在目标 Item 的前还是后
type: 'before' | 'after';
fromKanbanId?: number;
toKanbanId?: number;
}
export const useReorderViewboard = () => {
const client = useHttp();
return useMutation((params: SortProps) => {
return client("kanbans/reorder", {
data: params,
method: "POST",
});
}, useReorderViewboardConfig(queryKey));
};
编辑 src\utils\task.ts
(新增看板排序接口的 Custom Hook
):
js
...
export const useReorderTask = (queryKey: QueryKey) => {
const client = useHttp();
return useMutation((params: SortProps) => {
return client("tasks/reorder", {
data: params,
method: "POST",
});
}, useReorderTaskConfig(queryKey));
};
编辑 src\screens\ViewBoard\index.tsx
(完善之前预留的 onDragEnd
):
js
...
import { useReorderViewboard, useViewboards } from "utils/viewboard";
import {
useProjectInUrl,
useTasksQueryKey,
useTasksSearchParams,
useViewBoardQueryKey,
useViewBoardSearchParams,
} from "./utils";
...
import { useReorderTask, useTasks } from "utils/task";
...
import { useCallback } from "react";
export const ViewBoard = () => {
...
const onDragEnd = useDragEnd();
return (
<DragDropContext onDragEnd={onDragEnd}>
...
</DragDropContext>
);
};
export const useDragEnd = () => {
const { data: viewboards } = useViewboards(useViewBoardSearchParams());
const { mutate: reorderViewBoard } = useReorderViewboard(useViewBoardQueryKey());
const { mutate: reorderTask } = useReorderTask(useTasksQueryKey());
const { data: allTasks = [] } = useTasks(useTasksSearchParams());
return useCallback(
({ source, destination, type }: DropResult) => {
if (!destination) {
return;
}
// 看板排序
if (type === "COLUMN") {
const fromId = viewboards?.[source.index].id;
const toId = viewboards?.[destination.index].id;
if (!fromId || !toId || fromId === toId) {
return;
}
const type = destination.index > source.index ? "after" : "before";
reorderViewBoard({ fromId, referenceId: toId, type });
}
if (type === "ROW") {
const fromKanbanId = +source.droppableId;
const toKanbanId = +destination.droppableId;
if (fromKanbanId === toKanbanId) {
return;
}
const fromTask = allTasks.filter((task) => task.kanbanId === fromKanbanId)[source.index];
const toTask = allTasks.filter((task) => task.kanbanId === toKanbanId)[destination.index];
if (fromTask?.id === toTask?.id) {
return;
}
reorderTask({
fromId: fromTask?.id,
referenceId: toTask?.id,
fromKanbanId,
toKanbanId,
type:
fromKanbanId === toKanbanId && destination.index > source.index
? "after"
: "before",
});
}
},
[viewboards, reorderViewBoard, allTasks, reorderTask]
);
};
...
至此,拖拽持久化完成,查看效果验证
部分引用笔记还在草稿阶段,敬请期待。。。