• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

拖拽是常见的需求,在 react 里我们会用 react-dnd 来做。

这节我们通过一个拖拽排序的案例来入门下 react-dnd。

npx create-react-app --template=typescript react-dnd-test

新建个 react 项目

安装 react-dnd 相关的包:

npm install react-dnd react-dnd-html5-backend

然后改一下 App.tsx

import "./App.css";

function Box() {
    return <div className="box"></div>;
}

function Container() {
    return <div className="container"></div>;
}

function App() {
    return (
        <div>
            <Container></Container>
            <Box></Box>
        </div>
    );
}

export default App;

css 部分如下:

.box {
    width: 50px;
    height: 50px;
    background: blue;
    margin: 10px;
}

.container {
    width: 300px;
    height: 300px;
    border: 1px solid #000;
}

把它跑起来:

npm run start

是这样的:

现在我们想把 box 拖拽到 container 里,用 react-dnd 怎么做呢?

dnd 是 drag and drop 的意思,api 也分有两个 useDrag 和 useDrop。

box 部分用 useDrag 让元素可以拖拽:

function Box() {
    const ref = useRef(null);

    const [, drag] = useDrag({
        type: "box",
        item: {
            color: "blue",
        },
    });

    drag(ref);

    return <div ref={ref} className="box"></div>;
}

用 useRef 保存 dom 引用,然后用 useDrag 返回的第二个参数处理它。

至于 type 和 item,后面再讲。

然后是 Container:

function Container() {
    const ref = useRef(null);

    const [, drop] = useDrop(() => {
        return {
            accept: "box",
            drop(item) {
                console.log(item);
            },
        };
    });
    drop(ref);

    return <div ref={ref} className="container"></div>;
}

用 useDrop 让它可以接受拖拽过来的元素。

接收什么元素呢?

就是我们 useDrag 的时候声明的 type 的元素。

在 drop 的时候会触发 drop 回调函数,第一个参数是 item,就是 drag 的元素声明的那个。

只是这样还不行,还要在根组件加上 Context:

import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";

const root = ReactDOM.createRoot(
    document.getElementById("root") as HTMLElement
);
root.render(
    <DndProvider backend={HTML5Backend}>
        <App></App>
    </DndProvider>
);

之前是直接渲染 App,现在要在外面加上 DndProvider。

这个就是设置 dnd 的 context的,用于在不同组件之间共享数据。

然后我们试试看:

确实,现在元素能 drag 了,并且拖到目标元素也能触发 drop 事件,传入 item 数据。

那如果 type 不一样呢?

那就触发不了 drop 了。

然后我们给 Box 组件添加一个 color 的 props,用来设置背景颜色:

并且给 item 的数据加上类型。

interface ItemType {
    color: string;
}
interface BoxProps {
    color: string;
}
function Box(props: BoxProps) {
    const ref = useRef(null);

    const [, drag] = useDrag({
        type: "box",
        item: {
            color: props.color,
        },
    });

    drag(ref);

    return (
        <div
            ref={ref}
            className="box"
            style={{ background: props.color || "blue" }}></div>
    );
}

添加几个 Box 组件试一下:

没啥问题。

然后我们改下 Container 组件,增加一个 boxes 数组的 state,在 drop 的时候把 item 加到数组里,并触发渲染:

function Container() {
    const [boxes, setBoxes] = useState<ItemType[]>([]);

    const ref = useRef(null);

    const [, drop] = useDrop(() => {
        return {
            accept: "box",
            drop(item: ItemType) {
                setBoxes((boxes) => [...boxes, item]);
            },
        };
    });
    drop(ref);

    return (
        <div ref={ref} className="container">
            {boxes.map((item) => {
                return <Box color={item.color}></Box>;
            })}
        </div>
    );
}

这里 setBoxes 用了函数的形式,这样能拿到最新的 boxes 数组,不然会形成闭包,始终引用最初的空数组。

测试下:

这样,拖拽到容器里的功能就实现了。

我们再加上一些拖拽过程中的效果:

useDrag 可以传一个 collect 的回调函数,它的参数是 monitor,可以拿到拖拽过程中的状态。

collect 的返回值会作为 useDrag 的返回的第一个值。

我们判断下,如果是在 dragging 就设置一个 dragging 的 className。

function Box(props: BoxProps) {
    const ref = useRef(null);

    const [{ dragging }, drag] = useDrag({
        type: "box",
        item: {
            color: props.color,
        },
        collect(monitor) {
            return {
                dragging: monitor.isDragging(),
            };
        },
    });

    drag(ref);

    return (
        <div
            ref={ref}
            className={dragging ? "box dragging" : "box"}
            style={{ background: props.color || "blue" }}></div>
    );
}

然后添加 dragging 的样式:

.dragging {
    border: 5px dashed #000;
    box-sizing: border-box;
}

测试下:

确实,这样就给拖拽中的元素加上了对应的样式。

但如果我们想把这个预览的样式也给改了呢?

这时候就要新建个组件了:

const DragLayer = () => {
    const { isDragging, item, currentOffset } = useDragLayer((monitor) => ({
        item: monitor.getItem(),
        isDragging: monitor.isDragging(),
        currentOffset: monitor.getSourceClientOffset(),
    }));

    if (!isDragging) {
        return null;
    }
    return (
        <div
            className="drag-layer"
            style={{
                left: currentOffset?.x,
                top: currentOffset?.y,
            }}>
            {item.color} 拖拖拖
        </div>
    );
};

useDragLayer 的参数是函数,能拿到 monitor,从中取出很多东西,比如 item、isDragging,还是有 clientOffset,也就是拖拽过程中的坐标。

其中 drag-layer 的样式如下:

.drag-layer {
    position: fixed;
}

引入下这个组件:

现在的效果是这样的:

确实加上了自定义的预览样式,但是原来的还保留着。

这个也可以去掉:

useDrag 的第三个参数就是处理预览元素的,我们用 getEmptyImage 替换它,就看不到了。

dragPreview(getEmptyImage());

这时候就只有我们自定义的预览样式了:

但其实这种逻辑只要执行一次就行,我们优化一下:

useEffect(() => {
    drag(ref);
    dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);

drop 的逻辑也同样:

useEffect(() => {
    drop(ref);
}, []);

这样,我们就学会了 react-dnd 的基本使用。

总结下:

  • 使用 useDrag 处理拖拽的元素,使用 useDrop 处理 drop 的元素,使用 useDragLayer 处理自定义预览元素
  • 在根组件使用 DndProvider 设置 context 来传递数据
  • useDrag 可以传入 type、item、collect 等。type 标识类型,同类型才可以 drop。item 是传递的数据。collect 接收 monitor,可以取拖拽的状态比如 isDragging 返回。
  • useDrag 返回三个值,第一个值是 collect 函数返回值,第二个是处理 drag 的元素的函数,第三个值是处理预览元素的函数
  • useDrop 可以传入 accept、drop 等。accept 是可以 drop 的类型。drop 回调函数可以拿到 item,也就是 drag 元素的数据
  • useDragLayer 的回调函数会传入 monitor,可以拿到拖拽的实时坐标,用来设置自定义预览效果

全部代码如下:

import { useDrag, useDragLayer, useDrop } from "react-dnd";
import "./App.css";
import { useEffect, useRef, useState } from "react";
import { getEmptyImage } from "react-dnd-html5-backend";

interface ItemType {
    color: string;
}
interface BoxProps {
    color: string;
}
function Box(props: BoxProps) {
    const ref = useRef(null);

    const [{ dragging }, drag, dragPreview] = useDrag({
        type: "box",
        item: {
            color: props.color,
        },
        collect(monitor) {
            return {
                dragging: monitor.isDragging(),
            };
        },
    });

    useEffect(() => {
        drag(ref);
        dragPreview(getEmptyImage());
    }, []);

    return (
        <div
            ref={ref}
            className={dragging ? "box dragging" : "box"}
            style={{ background: props.color || "blue" }}></div>
    );
}

function Container() {
    const [boxes, setBoxes] = useState<ItemType[]>([]);

    const ref = useRef(null);

    const [, drop] = useDrop(() => {
        return {
            accept: "box",
            drop(item: ItemType) {
                setBoxes((boxes) => [...boxes, item]);
            },
        };
    });

    useEffect(() => {
        drop(ref);
    }, []);

    return (
        <div ref={ref} className="container">
            {boxes.map((item) => {
                return <Box color={item.color}></Box>;
            })}
        </div>
    );
}

const DragLayer = () => {
    const { isDragging, item, currentOffset } = useDragLayer((monitor) => ({
        item: monitor.getItem(),
        isDragging: monitor.isDragging(),
        currentOffset: monitor.getSourceClientOffset(),
    }));

    if (!isDragging) {
        return null;
    }
    return (
        <div
            className="drag-layer"
            style={{
                left: currentOffset?.x,
                top: currentOffset?.y,
            }}>
            {item.color}拖拖拖
        </div>
    );
};

function App() {
    return (
        <div>
            <Container></Container>
            <Box color="blue"></Box>
            <Box color="red"></Box>
            <Box color="green"></Box>
            <DragLayer></DragLayer>
        </div>
    );
}

export default App;

css:

.box {
    width: 50px;
    height: 50px;
    background: blue;
    margin: 10px;
}

.dragging {
    border: 5px dashed #000;
    box-sizing: border-box;
}
.drag-layer {
    position: fixed;
}

.container {
    width: 300px;
    height: 300px;
    border: 1px solid #000;
}

入了门之后,我们再来做个进阶案例:拖拽排序

我们写个 App2.tsx

import { useState } from "react";
import "./App2.css";

interface CardItem {
    id: number;
    content: string;
}

interface CardProps {
    data: CardItem;
}
function Card(props: CardProps) {
    const { data } = props;
    return <div className="card">{data.content}</div>;
}
function App() {
    const [cardList, setCardList] = useState<CardItem[]>([
        {
            id: 0,
            content: "000",
        },
        {
            id: 1,
            content: "111",
        },
        {
            id: 2,
            content: "222",
        },
        {
            id: 3,
            content: "333",
        },
        {
            id: 4,
            content: "444",
        },
    ]);

    return (
        <div className="card-list">
            {cardList.map((item: CardItem) => (
                <Card data={item} key={"card_" + item.id} />
            ))}
        </div>
    );
}

export default App;

还有 App2.css:

.card {
    width: 200px;
    line-height: 60px;
    padding: 0 20px;
    border: 1px solid #000;
    margin: 10px;
    cursor: move;
}

就是根据 cardList 的数据渲染一个列表。

把它渲染出来是这样的:

拖拽排序,显然 drag 和 drop 的都是 Card。

我们给它加上 useDrag 和 useDrop:

function Card(props: CardProps) {
    const { data } = props;

    const ref = useRef(null);

    const [, drag] = useDrag({
        type: "card",
        item: props.data,
    });
    const [, drop] = useDrop({
        accept: "card",
        drop(item) {
            console.log(item);
        },
    });

    useEffect(() => {
        drag(ref);
        drop(ref);
    }, []);

    return (
        <div ref={ref} className="card">
            {data.content}
        </div>
    );
}

接下来做的很显然就是交换位置了。

我们实现一个交换位置的方法,传入 Card 组件,并且把当前的 index 也传入:

const swapIndex = useCallback((index1: number, index2: number) => {
    const tmp = cardList[index1];
    cardList[index1] = cardList[index2];
    cardList[index2] = tmp;
    setCardList([...cardList]);
}, []);

这里 setState 时需要创建一个新的数组,才能触发渲染。

然后在 Card 组件里调用下:

增加 index 和 swapIndex 两个参数,声明 drag 传递的 item 数据的类型

在 drop 的时候互换 item.index 和当前 drop 的 index 的 Card

interface CardProps {
    data: CardItem;
    index: number;
    swapIndex: Function;
}

interface DragData {
    id: number;
    index: number;
}

function Card(props: CardProps) {
    const { data, swapIndex, index } = props;

    const ref = useRef(null);

    const [, drag] = useDrag({
        type: "card",
        item: {
            id: data.id,
            index: index,
        },
    });
    const [, drop] = useDrop({
        accept: "card",
        drop(item: DragData) {
            swapIndex(index, item.index);
        },
    });

    useEffect(() => {
        drag(ref);
        drop(ref);
    }, []);

    return (
        <div ref={ref} className="card">
            {data.content}
        </div>
    );
}

这样就实现了拖拽排序。

不过因为背景是透明的,看着不是很明显。

我们设置个背景色:

清晰多了。

但是现在是 drop 的时候才改变位置,如果希望在 hover 的时候就改变位置呢?

useDrop 有 hover 时的回调函数,我们把 drop 改成 hover就好了:

但现在你会发现它一直在换:

那是因为交换位置后,没有修改 item.index 为新的位置,导致交换逻辑一致触发:

在 hover 时就改变顺序,体验好多了。

然后我们再处理下拖拽时的样式。

样式如下:

.dragging {
    border-style: dashed;
    background: #fff;
}

效果是这样的:

这样,拖拽排序就完成了。

我们对 react-dnd 的掌握又加深了一分。

这个案例的全部代码如下:

import { useCallback, useEffect, useRef, useState } from "react";
import "./App2.css";
import { useDrag, useDrop } from "react-dnd";

interface CardItem {
    id: number;
    content: string;
}

interface CardProps {
    data: CardItem;
    index: number;
    swapIndex: Function;
}

interface DragData {
    id: number;
    index: number;
}

function Card(props: CardProps) {
    const { data, swapIndex, index } = props;

    const ref = useRef(null);

    const [{ dragging }, drag] = useDrag({
        type: "card",
        item: {
            id: data.id,
            index: index,
        },
        collect(monitor) {
            return {
                dragging: monitor.isDragging(),
            };
        },
    });
    const [, drop] = useDrop({
        accept: "card",
        hover(item: DragData) {
            swapIndex(index, item.index);
            item.index = index;
        },
        // drop(item: DragData) {
        //     swapIndex(index, item.index)
        // }
    });

    useEffect(() => {
        drag(ref);
        drop(ref);
    }, []);

    return (
        <div ref={ref} className={dragging ? "card dragging" : "card"}>
            {data.content}
        </div>
    );
}

function App() {
    const [cardList, setCardList] = useState<CardItem[]>([
        {
            id: 0,
            content: "000",
        },
        {
            id: 1,
            content: "111",
        },
        {
            id: 2,
            content: "222",
        },
        {
            id: 3,
            content: "333",
        },
        {
            id: 4,
            content: "444",
        },
    ]);

    const swapIndex = useCallback((index1: number, index2: number) => {
        const tmp = cardList[index1];
        cardList[index1] = cardList[index2];
        cardList[index2] = tmp;

        setCardList([...cardList]);
    }, []);

    return (
        <div className="card-list">
            {cardList.map((item: CardItem, index) => (
                <Card
                    data={item}
                    key={"card_" + item.id}
                    index={index}
                    swapIndex={swapIndex}
                />
            ))}
        </div>
    );
}

export default App;

css:

.card {
    width: 200px;
    line-height: 60px;
    padding: 0 20px;
    border: 1px solid #000;
    background: skyblue;
    margin: 10px;
    cursor: move;
}

.dragging {
    border-style: dashed;
    background: #fff;
}

案例代码上传了小册仓库

总结

我们学了 react-dnd 并用它实现了拖拽排序。

react-dnd 主要就是 useDrag、useDrop、useDragLayout 这 3 个 API。

useDrag 是给元素添加拖拽,指定 item、type、collect 等参数。

useDrop 是给元素添加 drop,指定 accepet、drop、hover、collect 等参数。

useDragLayout 是自定义预览,可以通过 monitor 拿到拖拽的实时位置。

此外,最外层还要加上 DndProvider,用来组件之间传递数据。

其实各种拖拽功能的实现思路比较固定:什么元素可以拖拽,什么元素可以 drop,drop 或者 hover 的时候修改数据触发重新渲染就好了。

比如拖拽排序就是 hover 的时候互换两个 index 的对应的数据,然后 setState 触发渲染。

用 react-dnd,我们能实现各种基于拖拽的功能。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
Next
52.react-dnd实战:拖拽版TodoList