web 拖拽从原生实现到 react-dnd

981 阅读16分钟

原生实现拖拽

使用浏览器端的 drap 监听来完成拖拽功能,需要以下几步:

  1. 为拖动的元素添加 drag 时间的监听,监听到元素的拖拽;并为拖拽元素增加 dragstart 事件,在元素被移出当前位置时保存被拖拽的元素本身;
  2. 为目标区域添加 dragover 事件,并阻止其默认事件行为保证 drop 事件的行为正常;
  3. 为目标区域添加 drop 事件,并将保存的拖拽元素插入目标区域,并删除原本位置的拖拽元素;

以下是借鉴自 MDN 的代码实现:

// index.tsx
import { useEffect } from 'react';

let dragged: EventTarget | null;

const OriginalDrag = () => {
  useEffect(() => {
    const source = document.getElementById('draggable');
    const target = document.getElementById('droptarget');
    source!.addEventListener('drag', () => {
      console.log('dragging');
    });

    // 保存当前拖拽元素,增加拖拽css样式
    source!.addEventListener('dragstart', (event: HTMLElementEventMap['dragstart']) => {
      dragged = event.target;
      (event.target as HTMLElement).classList.add('dragging');
    });

    // 拖拽结束动作,去除拖拽样式
    source!.addEventListener('dragend', (event: HTMLElementEventMap['dragend']) => {
      (event.target as HTMLElement).classList.remove('dragging');
    });

    target!.addEventListener(
      'dragover',
      (event: HTMLElementEventMap['dragover']) => {
        // 阻止默认行为以允许放置元素,为确保 drop 事件始终按预期触发,
        // 应当在处理 dragover 事件的代码部分始终包含 preventDefault() 调用。
        // https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/drop_event
        event.preventDefault();
      },
      false
    );

    // 移入放置区域时增加可拖入的提示色
    target!.addEventListener('dragenter', (event: HTMLElementEventMap['dragenter']) => {
      if ((event.target as HTMLElement).classList.contains('dropzone')) {
        (event.target as HTMLElement).classList.add('dragover');
      }
    });
    // 可拖动元素离开放置区域后清除可移入添加的背景色
    target!.addEventListener('dragleave', (event: HTMLElementEventMap['dragleave']) => {
      if ((event.target as HTMLElement).classList.contains('dropzone')) {
        (event.target as HTMLElement).classList.remove('dragover');
      }
    });

    // 监听移入动作,并将元素变更位置
    target!.addEventListener('drop', (event: HTMLElementEventMap['drop']) => {
      event.preventDefault();
      if ((event.target as HTMLElement).classList.contains('dropzone')) {
        (event.target as HTMLElement).classList.remove('dragover');
        (dragged as HTMLElement)?.parentNode.removeChild(dragged);
        (event.target as HTMLElement).appendChild(dragged);
      }
    });
  }, []);
  return (
    <div>
      <div className='dropzone'>
        <div id='draggable' draggable>
          可以拖动的div元素
        </div>
      </div>
      <div className='dropzone' id='droptarget'></div>
    </div>
  );
};

export default OriginalDrag;

实现效果如下:

Untitled.png

从以上代码看,单纯实现一个处理一个简单单元素拖拽我们要添加三个监听来完成。如果是复杂的拖拽需求,代码复杂程度会增加很多,秉着能用轮子就绝不手写的优秀美德,我们今天来学习一下react-dnd,社区比较火热的解决方案

了解 DnD

react-dnd 是 Drag and Drop 在 react 栈的实现,项目的结构如下:

Untitled 1.png

其中 react-DnD 是提供给 react 环境使用的包,DnD-core 是 DnD 的核心实现,backend 是支持拖拽的宿主环境实现,包含 h5 的的 backend-html 和移动端使用的 backend-touch;

值得一提的是,DnD-core 使用 redux 实现了状态管理,并将元素拖拽抽象为了包含元素的数据拖拽。

尝试复刻官网教程

react-dnd 官网有一个简单的国际象棋棋子拖拽的 Demo,接下来我们来尝试完成这个教程,来理解 react-dnd 的数据驱动理念。

敲定组件

  • Knight,我们唯一的棋子;
  • Square,棋盘上的一个方格;可以通过 props 传递当前方块的颜色,默认为白色,可以通过 black 属性传递布尔值修改颜色为黑色;同时可以接受一个子组件:放置在其上的 Knight 棋子;
  • Board,整个棋盘,包含 64 个方格;props 中包含 knightPostion,我们用二维数组作为坐标,其中[0,0] 的坐标指的是 A8 方块,为了与浏览器的坐标方向相匹配。

此时我们还不需要考虑状态管理,只需要先完成功能即可。

创建组件

官方示例自下而上的开发流程确实是很高效的方式,所以我们首先从 Knight 组件开始:

// Knight.tsx
const Knight = () => {
  return <span></span>;
};
export default Knight;

这里的 ♘ 是 Unicode 编码的符号,别说,比 emoji 的 🐴 抽象多了。我们可以尝试渲染一下这个 🐴,查看是否完成。

根据上面的规划,Square 组件实现如下:

// Square.tsx
import type { CSSProperties, FC, ReactNode } from 'react';

interface SquareProps {
  black: boolean;
  children?: ReactNode;
}

const squareStyle: CSSProperties = {
  width: '100%',
  height: '100%',
};

const Square: FC<SquareProps> = ({ black, children }) => {
  //生成当前方块的背景色和抽象小马的颜色
  const backgroundColor = black ? 'black' : 'white';
  const color = black ? 'white' : 'black';
  return (
    <div
      style={{
        ...squareStyle,
        backgroundColor,
        color,
      }}
    >
      {children}
    </div>
  );
};
export default Square;

最后是最最重要的 Board 组件:

// Board.tsx
import type { FC, ReactNode } from 'react';

import Square from './Square.tsx';
import Knight from './Knight.tsx';

type renderSquare = (i: number, knightPosition: [number, number]) => ReactNode;

interface BoardProps {
  knightPosition: [number, number];
}

// 渲染Square组件,并根据knightPosition判断Knight生成在哪个Square
const renderSquare: renderSquare = (i, [knightX, knightY]) => {
  const x = i % 8;
  const y = Math.floor(i / 8);
  const isKnightHere = x === knightX && y === knightY;
  const black = (x + y) % 2 === 1;
  const piece = isKnightHere ? <Knight /> : null;
  return (
    <div key={i} style={{ width: '12.5%', height: '12.5%' }}>
      <Square black={black}>{piece}</Square>
    </div>
  );
};

const Board: FC<BoardProps> = ({ knightPosition }) => {
  const squares = [];
  for (let i = 0; i < 64; i++) {
    squares.push(renderSquare(i, knightPosition));
  }
  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        flexWrap: 'wrap',
      }}
    >
      {squares}
    </div>
  );
};
export default Board;

现在我们可以尝试在页面上渲染我们的棋盘和唯一的棋子:

//App.tsx

import './App.css';
import Board from './features/dnd/components/Board';
function App() {
  return (
    <div style={{ height: 500, width: 500, border: '1px solid #000' }}>
      <Board knightPosition={[0, 3]} />
    </div>
  );
}

export default App;

通过修改 knightPosition,我们就可以完成对棋子位置的变动,我们的抽象小 🐴 在棋盘上非常棒(虽然现在还很小,后续会让它变大,小小的也很可爱)。

Untitled转存失败,建议直接上传图片文件

为棋子增加状态

目前我们完成的 Board 组件仅仅是一个受控组件:根据传入的棋子位置渲染棋盘与棋子。如果我们想让棋子可以被拖动,首先要解决的就是 knightPostion 可以被存储与更新。

我们首先来解决棋子位置更新的问题,首完成点击棋盘某个方块让棋子移动到相关位置,处理好状态后再去实现拖动代替点击移动。

dnd 官网教程并没有使用任何状态管理工具,而是简单做了一个简单订阅发布的工具方法:Game。

首先我们先做一个随机变换 knightPosition 的功能,首先我们实现一个 observe 随机变换位置的函数:

// Ganme.ts

export const observe = (receive: any) => {
  const randPos = () => Math.floor(Math.random() * 8);
  setInterval(() => receive([randPos(), randPos()]), 500);
};

使用这个函数:

// main.tsx

import ReactDOM from 'react-dom/client';
import Board from './features/dnd/components/Board';
import { observe } from './lib/Game';

const root = document.getElementById('root');

observe((knightPosition: any) => {
  ReactDOM.createRoot(root as HTMLElement).render(
    <div style={{ height: 500, width: 500, border: '1px solid #000' }}>
      <Board knightPosition={knightPosition} />
    </div>
  );
});

然后我们就可以看到我们的抽象小 🐴 肆意狂奔在棋盘上了(这里我用了官网的示例 Gif,因为录一个挺费劲)。

https://s3.amazonaws.com/f.cl.ly/items/1K0s0n0r0C0e2P2N2D1d/Screen%20Recording%202015-05-15%20at%2012.06%20pm.gif

如果嫌 🐴 小,我们可以给 span 标签增加 font size 来解决看不清楚 🐴 的问题:

// Knight.tsx

<span style={{ fontSize: 40 }}>♘</span>

完成自由奔跑的 🐴 以后,我们就要完善这个订阅发布的工具,完成点击移动 🐴 的功能。由于点击移动本质上就是修改发布的状态,我们可以让 Game 提供出一个修改 knight 的方法来让外部修改 knightPosition:

// Game.ts

let knightPosition = [1, 7];
let observer = null;

const emitChange = () => {
  observer(knightPosition);
};

export const observe = (o: any) => {
  if (observer !== null) {
    throw new Error('不支持多个观察者啊小老弟!');
  }
  observer = o;
  emitChange();
};

//修改knightPosition的位置
export const moveKnight: (toX: number, toY: number) => void = (toX, Toy) => {
  knightPosition = [toX, Toy];
  emitChange();
};

接下来就是实现点击方块的时候去调用 moveKnight 方法即可。这里值得注意的是,可能我们在下意识中就会觉得 Square 组件本身去调用 moveKnight 方法是合理的,但如果在 Square 组件中调用 moveKnight,我们就需要在 Square 组件中得到当前方块所在的位置。教程中有一句非常经典的法则:

If a component doesn't need some data for rendering, it doesn't need that data at all. 如果一个组件在渲染时不需要某些数据,那么它根本不需要那些数据。

而 Square 组件在渲染时,根本不需要自己的位置,所以我们可以在这里避免与 moveKnight 方法耦合。相反,我们可以在 renderSquare 时为 Square 增加一个父级的 div,并添加一个 click 事件去调用 moveKnight 方法。

相关代码如下:

// Board.tsx

//.....

const handleSquareClick = (toX: number, toY: number) => {
  moveKnight(toX, toY);
};

const renderSquare: renderSquare = (i, [knightX, knightY]) => {
  //.....
  return (
    <div key={i} style={{ width: '12.5%', height: '12.5%' }} onClick={() => handleSquareClick(x, y)}>
      <Square black={black}>{piece}</Square>
    </div>
  );
};

距离我们的棋子正常行动还差一个骑士守则:只能按照 L 型的规则移动。我们需要 Game 提供一个检查行动是否合理的方法,供移动方法检查这次移动是否符合骑士守则。

// Game.ts

//....
// 骑士可以以 "L" 字型的方式移动,即横向或纵向移动 2 个单位并且垂直或水平移动 1 个单位,
// 或者横向或纵向移动 1 个单位并且垂直或水平移动 2 个单位。
export const canMoveKnight = (toX: number, toY: number): boolean => {
  const [x, y] = knightPosition;
  const dx = toX - x;
  const dy = toY - y;
  return (Math.abs(dx) === 2 && Math.abs(dy) === 1) || (Math.abs(dx) === 1 && Math.abs(dy) === 2);
};

最后,为移动的 click 方法加上这个检查方法:

// Board.tsx

//...
const handleSquareClick = (toX: number, toY: number) => {
  if (canMoveKnight(toX, toY)) {
    moveKnight(toX, toY);
  }
};

让我们检查下成果:

https://s3.amazonaws.com/f.cl.ly/items/1F371u301l1H2X3o0g1h/Screen%20Recording%202015-05-15%20at%2012.08%20pm.gif

添加拖放互动

终于到了我们的重头戏:React Dnd 以及需要使用的 backend 包,react-html5-backend:

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

设置拖放上下文

上面有提到,在不同的宿主环境中,dnd 用的 backed 并不一样,而 backend 是提供给谁用的呢?

在使用 dnd 时,我们需要为要拖拽的组件增加DndProvider,并注入 backend:

// Board.tsx

const Board: FC<BoardProps> = ({ knightPosition }) => {
  //...
  return <DndProvider backend={HTML5Backend}>{/* ... */}</DndProvider>;
};

定义拖动类型

react-dnd 使用 redux 处理了内部的状态,并且建议开发者将拖动元素移动抽象为拖动数据移动,所以我们可以给拖动元素创建一个类型常量:

// ItemType.tsx

export const ItemTypes = {
  KNIGHT: 'knight',
};

让棋子可以拖动

首先让我们来了解一下useDrag这个 hooks。useDrag 这个钩子函数的入参及反参类型如下:

const [collected, dragRef, dragPreviewRef] = useDrag(spec, deps);

useDrag入参:

  • **spec:**可以是一个符合规范对象或返回符合规范对象的函数;
  • **deps:**使用逻辑与 react 的 useMemo 类似,可以传递依赖项;spec 是函数的情况下,默认值为空数组;spec 是对象的情况下,默认值是传入的内容;

其中,spec 约定的对象内容如下:

  • **type(必填):**string | symbol, 用来给拖动对象增加类别;
  • **item(必填):** object | function, 当传递对象时,它代表的是描述被拖动数据的普通 js 对象。这是拖放目标可以获得的关于拖放源的唯一信息,尽量精简成最小数据。尽量避免传递复杂引用,官方推荐使用类似 { id } 的数据格式;当传递的是函数时,则会在拖动操作时被触发,并返回一个代表拖动操作的对象。取消拖放操作时,则返回空值;
  • **previewOptions(可选):** 描述拖拽预览选项的 js 对象,相关的配置项如果有需要的话,可以通过 ts 类型描述文件去查看,这里就不累述了。
  • **options(可选):** 一个普通对象,包含一个可选参数:dropEffect,在拖动中使用的指针拖放效果类型,move/copy 两种。
  • **end(item,monitor)(可选):**end  方法在拖拽结束时被调用,用于处理拖拽操作的收尾工作,对于每一个 begin 方法都会对应一个 end 方法执行;可以通过调用  monitor.didDrop()  来检查拖拽是否被兼容的放置目标处理了。如果被处理了,并且放置目标在其  drop()  方法中返回了一个普通对象作为拖放结果(drop result),您可以通过调用  monitor.getDropResult()  获取该结果。在这个方法中,可以执行相应的操作,比如触发数据处理动作或其他逻辑处理
  • **canDrag(monitor)(可选):**用来指定当前元素是否允许拖动,如果是始终允许拖动忽略该参数即可;如果要根据某些条件来处理判断是否允许拖动,可以在该函数中根据具体逻辑返回 true 或者 false需要注意的是,并不能在该方法中调用monitor.canDrag()
  • **isDragging(monitor)(可选):** 默认情况下,只有发起拖拽操作的拖拽源被视为正在拖拽。我们可以通过自定义该方法来定义拖拽源是否被视为正在拖拽,例如,根据拖拽源的 ID 和当前拖拽项的 ID 进行比较,以确定是否正在拖拽;例如,在看板中将卡片在列表之间移动时,希望它保持拖拽的外观,即使从技术上讲,每次将其移动到另一个列表时,组件都会被卸载并重新加载一个不同的组件;
  • collect(monitor)(可选): 该函数用于收集需要注入组件使用的属性,比如我们返回了 monitor 的 isDragging()函数的值,那么在解构参数中的 collectoed 中就会有这个参数;详细关于收集到什么参数,可以通过 monitor 一节文档详细了解。

useDrag返参:

  • **collected:**collect 函数的返回值,如果没有定义 collect 函数将会返回一个空对象;
  • **dragRef:**拖动源的 ref 实例,需要我们手动绑定到 DOM 元素上;
  • **dragPreviewRef:**拖动预览的 ref 实例,使用时同样需要我们手动绑定到 DOM 元素上;

虽然我们现在还没有使用过 useDrag 这个 hooks,但是不得不说 dnd 的代码相当优秀,即使没有深入使用,大概看一下源码架构也能得知是如何工作的:

//useOptionalFactory.ts
export function useOptionalFactory<T>(arg: FactoryOrInstance<T>, deps?: unknown[]): T {
  //判断入参类型并进行memoization
  const memoDeps = [...(deps || [])];
  if (deps == null && typeof arg !== 'function') {
    memoDeps.push(arg);
  }
  return useMemo<T>(() => {
    return typeof arg === 'function' ? (arg as () => T)() : (arg as T);
  }, memoDeps);
}

//useDrag.ts
export function useDrag<DragObject = unknown, DropResult = unknown, CollectedProps = unknown>(
  specArg: FactoryOrInstance<DragSourceHookSpec<DragObject, DropResult, CollectedProps>>,
  deps?: unknown[]
): [CollectedProps, ConnectDragSource, ConnectDragPreview] {
  const spec = useOptionalFactory(specArg, deps);
  invariant(!(spec as any).begin, `useDrag::spec.begin was deprecated in v14. Replace spec.begin() with spec.item(). (see more here - https://react-dnd.github.io/react-dnd/docs/api/use-drag)`);

  const monitor = useDragSourceMonitor<DragObject, DropResult>();
  const connector = useDragSourceConnector(spec.options, spec.previewOptions);
  useRegisteredDragSource(spec, monitor, connector);

  return [useCollectedProps(spec.collect, monitor, connector), useConnectDragSource(connector), useConnectDragPreview(connector)];
}

再认真了解完 useDrag 的三围(入参出参)后,我们可以下手修改我们的 knight 棋子了,让它离着目标效果更进一步:让它可以被拖动,并且增加一些拖动的显示效果:

// Knight.tsx

import { useDrag } from 'react-dnd';
import { ItemTypes } from '../../../types/ItemType.ts';

const Knight = () => {
  const [{ isDragging }, drag] = useDrag(() => ({
    type: ItemTypes.KNIGHT,
    options: {
      dropEffect: 'copy',
    },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  }));
  return (
    <div
      ref={drag}
      style={{
        opacity: isDragging ? 0.5 : 1,
        fontSize: 40,
        fontWeight: 'bold',
      }}
    ></div>
  );
};
export default Knight;

通过改动 Knight 组件,我们快速掌握了如何使用 useDrag 来让组件变成可拖动的,通过 collect 函数成功获取到了棋子被拖动时的状态,从而优化了拖动的显示效果。

让棋子可以落下

Knight 现在是一个拖动源,但是 Square 组件并不能接受 Knight 放在自己身上,接下来我们就说服 Square 组件来允许我们棋子的进入

但问题是,Square 组件是不知道自己所在位置的,为了保持 Square 组件的纯粹,我们增加一个新的组件 BoradSquare。它接收自己的位置,并渲染原本的 Square 组件,可以将一部分 renderSquare 方法移动到 BoradSquare 中:

// BoardSquare.tsx
import type { FC, ReactNode } from 'react';
import Square from './Square.tsx';

interface BoardSquareProps {
  x: number;
  y: number;
  children?: ReactNode;
}

const BoardSquare: FC<BoardSquareProps> = ({ x, y, children }) => {
  const black = (x + y) % 2 === 1;
  return <Square black={black}>{children}</Square>;
};
export default BoardSquare;

同时还需要修改 Board 来配合使用:

// Borad.tsx
//....
import BoardSquare from './BoardSquare.tsx';

//....

// 渲染Square组件,并根据knightPosition判断Knight生成在哪个Square
const renderSquare: renderSquare = (i, knightPosition) => {
  const x = i % 8;
  const y = Math.floor(i / 8);
  return (
    <div key={i} style={{ width: '12.5%', height: '12.5%' }}>
      <BoardSquare x={x} y={y}>
        {renderPiece(x, y, knightPosition)}
      </BoardSquare>
    </div>
  );
};

const renderPiece = (x: number, y: number, [knightX, knightY]: [number, number]) => {
  if (x === knightX && y === knightY) {
    return <Knight />;
  }
};

这样我们就可以在 BoardSquare 中使用**useDrop**来完成放下的动作了,我们先来了解一下 useDrop 这个函数:

const [collected, dropRef] = useDrop(spec, deps);

**useDrop**入参:

  • **spec:**与 useDrag 类似,传入一个符合规范的对象或返回符合规范对象的函数;
  • **deps:**与 useDrag 的 deps 用法一致;

其中,spec 约定的对象内容如下:

  • **accept(必填):**string | string [] | symbol | symbol[] 这里对应 useDrag 里传递的 type,仅对 accept 包含的 drag 行为做出反应;
  • **options(可选):**一个普通对象。这个选项用于指定一个自定义的  arePropsEqual(props, otherProps)函数,该函数用于比较组件的属性是否相等,一般情况下不会使用;
  • **drop(item, monitor)(可选):**当兼容的项目被放置在目标上时调用。当出现可响应的 drap 动作时,drop 函数就会被执行。这个函数可以返回undefined,也可以返回一个普通对象。如果返回一个对象,就会被当做拖放结果(drop result),并将作为在拖拽组件 useDrag end 方法monitor.getDropResult() 的结果返回。如果定义了 canDrop() 并且返回 false,将不会调用此方法;
  • **hover(item, monitor)(可选):**当项目在组件上悬停时调用。可以使用 monitor.isOver({ shallow: true }) 检查悬停是否仅发生在当前目标上,还是在嵌套的目标上。与 drop() 不同,即使定义了 canDrop() 并且返回 false,也会调用此方法。可以使用 monitor.canDrop() 检查是否是这种情况;
  • canDrop(item, monitor)(可选): 可以用该方法指定当前的拖放目标(drop target)是否允许接受放置。忽略此参数代表始终允许接受放置。试实现该方法可以自定义可放置的规则;与 canDrag 类似,不能在方法内调用monitor.canDrop()方法;
  • **collect(monitor)(可选):**与 useDrag 类似,将需要的数据回传给组件;

**useDrop**返参:

  • **collected:**与 useDrag 类似,collect 函数返回的数据;
  • **dropRef:** drop 元素的 ref,需要手动绑定组件;

现在我们和了解 useDarp 三围一样,了解了 useDrop 的三围,我们就可以来着手修改我们的 BoardSquare 组件了。

首先我们先让棋子能够在任意位置落下:

import { moveKnight } from '../../../lib/Game.ts';
//...

const BoardSquare: FC<BoardSquareProps> = ({ x, y, children }) => {
  const black = (x + y) % 2 === 1;
  const [_, drop] = useDrop(
    () => ({
      accept: ItemTypes.KNIGHT,
      drop: () => moveKnight(x, y),
    }),
    [x, y]
  );
  return (
    <div
      ref={drop}
      style={{
        position: 'relative',
        width: '100%',
        height: '100%',
      }}
    >
      <Square black={black}>{children}</Square>
    </div>
  );
};

我们可以将 moveKnight 方法直接传递给 useDrop 方法使用,同样的我们可以进阶一下,移动棋子的时候提示落点,提高用户体验:

const BoardSquare: FC<BoardSquareProps> = ({ x, y, children }) => {
  const black = (x + y) % 2 === 1;
  const [{ isOver }, drop] = useDrop(
    () => ({
      accept: ItemTypes.KNIGHT,
      drop: () => moveKnight(x, y),
      collect: (monitor) => ({
        isOver: monitor.isOver(),
      }),
    }),
    [x, y]
  );
  return (
    <div
      ref={drop}
      style={{
        position: 'relative',
        width: '100%',
        height: '100%',
      }}
    >
      <Square black={black}>{children}</Square>
      {isOver && (
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            height: '100%',
            width: '100%',
            zIndex: 1,
            opacity: 0.5,
            backgroundColor: 'yellow',
          }}
        />
      )}
    </div>
  );
};

在以上组件的基础上,我们再使用 canMoveKnight 方法去实现 canDrop,这样就可以限制棋子在哪里落下:

//BoardSquare.tsx
//...
import { canMoveKnight, moveKnight } from '../../../lib/Game.ts';
import { Overlay, OverlayType } from './Overlay.tsx';
//...

const BoardSquare: FC<BoardSquareProps> = ({ x, y, children }) => {
  const black = (x + y) % 2 === 1;
  const [{ isOver, canDrop }, drop] = useDrop(
    () => ({
      accept: ItemTypes.KNIGHT,
      canDrop: () => canMoveKnight(x, y),
      drop: () => moveKnight(x, y),
      collect: (monitor) => ({
        isOver: monitor.isOver(),
        canDrop: monitor.canDrop(),
      }),
    }),
    [x, y]
  );
  return (
    <div
      ref={drop}
      style={{
        position: 'relative',
        width: '100%',
        height: '100%',
      }}
    >
      <Square black={black}>{children}</Square>
      {isOver && !canDrop && <Overlay type={OverlayType.IllegalMoveHover} />}
      {!isOver && canDrop && <Overlay type={OverlayType.PossibleMove} />}
      {isOver && canDrop && <Overlay type={OverlayType.LegalMoveHover} />}
    </div>
  );
};

//Overlay.tsx

import type { FC } from 'react';

export enum OverlayType {
  IllegalMoveHover = 'Illegal',
  LegalMoveHover = 'Legal',
  PossibleMove = 'Possible',
}
export interface OverlayProps {
  type: OverlayType;
}

export const Overlay: FC<OverlayProps> = ({ type }) => {
  const color = getOverlayColor(type);
  return (
    <div
      className='overlay'
      role={type}
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        height: '100%',
        width: '100%',
        zIndex: 1,
        opacity: 0.5,
        backgroundColor: color,
      }}
    />
  );
};

function getOverlayColor(type: OverlayType): string {
  switch (type) {
    case OverlayType.IllegalMoveHover:
      return 'red';
    case OverlayType.LegalMoveHover:
      return 'green';
    case OverlayType.PossibleMove:
      return 'yellow';
  }
}

到这里,我们的教程就算完成了,至于拖拽预览图片,这个用法与链接拖拽元素一样就不累述了。

总结

总结一下,我们所做的这个简单棋盘:

  1. 首先我们使用 useDrag 去让棋子元素变得可拖动,并收到了我们需要用到的拖动时状态,方便控制页面状态;
  2. 其次我们使用 useDrop 去让方块组件可接收拖动的元素的释放;并且根据逻辑控制棋子下落逻辑。

其中像是 useDragLayer,useDragDropManager,DragSourceMonitor 以及 DropTargetMonitor 有需要的时候查询官网文档即可。