## 1 引言

拖拽是前端非常常见的交互操作，但显然拖拽是强 DOM 交互的，而 React 绕过了 DOM 这一层，那么基于 React 的拖拽方案就必定值得聊一聊。

结合 [How To Use The HTML Drag-And-Drop API In React](https://www.smashingmagazine.com/2020/02/html-drag-drop-api-react/) 这篇文章，让我们谈谈 React 拖拽这些事。

## 2 概述

原文说的比较简单，笔者先快速介绍其中重点部分。

首先拖拽主要的 API 有 4 个：`dragEnter` `dragLeave` `dragOver` `drop`，分别对应拖入、拖出、正在当前元素范围内拖拽、完成拖入动作。

基于这些 API，我们可以利用 React 实现一个拖入区域：

```jsx
import React from "react";

const DragAndDrop = props => {
  const handleDragEnter = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDragLeave = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDragOver = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDrop = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  return (
    <div
      className={"drag-drop-zone"}
      onDrop={e => handleDrop(e)}
      onDragOver={e => handleDragOver(e)}
      onDragEnter={e => handleDragEnter(e)}
      onDragLeave={e => handleDragLeave(e)}
    >
      <p>Drag files here to upload</p>
    </div>
  );
};
export default DragAndDrop;
```

`preventDefault` 指的是阻止默认响应，这个响应可能是跳转页面之类的，`stopPropagation` 是阻止冒泡，这样同样监听了事件的父元素就不会收到响应，我们可以精准作用于嵌套的子元素。

接下来是拖拽状态管理，提到了 `useReducer`，顺便复习一下用法：

```jsx
...
const reducer = (state, action) => {
  switch (action.type) {
    case 'SET_DROP_DEPTH':
      return { ...state, dropDepth: action.dropDepth }
    case 'SET_IN_DROP_ZONE':
      return { ...state, inDropZone: action.inDropZone };
    case 'ADD_FILE_TO_LIST':
      return { ...state, fileList: state.fileList.concat(action.files) };
    default:
      return state;
  }
};
const [data, dispatch] = React.useReducer(
  reducer, { dropDepth: 0, inDropZone: false, fileList: [] }
)
...
```

最后一个关键点在于拖入后的处理，利用 `dispatch` 增加拖入文件、设置拖入状态即可：

```js
const handleDrop = e => {
  ...
  let files = [...e.dataTransfer.files];

  if (files && files.length > 0) {
    const existingFiles = data.fileList.map(f => f.name)
    files = files.filter(f => !existingFiles.includes(f.name))

    dispatch({ type: 'ADD_FILE_TO_LIST', files });
    e.dataTransfer.clearData();
    dispatch({ type: 'SET_DROP_DEPTH', dropDepth: 0 });
    dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: false });
  }
};
```

`e.dataTransfer.clearData` 函数用于清除拖拽过程中产生的临时变量，这些临时变量可以通过 `e.dataTransfer.xxx =` 的方式赋值，一般用于拖拽过程中值的传递。

总结一下，利用 HTML5 的 API 将拖拽转化为状态，最终通过状态映射到 UI。

原文内容还是比较简单的，笔者在精读部分再拓展一些更体系化的内容。

## 3 精读

现阶段拖拽主要分为两种，一种是 HTML5 原生规范的拖拽，这种方式在拖拽过程中不会影响 DOM 结构。另一种是完全所见即所得的拖拽方式，拖拽过程中 DOM 位置会随之变动，好处是可以立即反馈拖拽结果，当然缺点是华而不实，一旦用在生产环境，这种拖拽过程可能导致页面结构频繁跳动，反而看不清拖拽效果。

由于本文也采用了第一种拖拽方案，因为笔者再重新整理一遍自己的封装思路。

从使用角度反推，假设我们拥有一个拖拽库，那必定要拥有两个 API：

```jsx
import { DragContainer, DropContainer } from 'dnd'

const DragItem = (
  <DragContainer>
    {({ dragProps }) => (
      <div {...dragProps} />
    )}
  </DragContainer>
)

const DropItem = (
  <DropContainer>
    {({ dropProps }) => (
      <div {...dropProps} />
    )}
  </DropContainer>
)
```

`DragContainer` 包裹可以被拖拽的元素，`DropContainer` 包裹可以被拖入的元素，而至于 `dragProps` 与 `dropProps` 需要透传到子元素的 dom 节点，是为了利用 DOM API 控制拖拽效果，这也是拖拽唯一对 DOM 的要求，双方元素都需要有实体 DOM 承载。

而上面例子中给出 `dragProps` 与 `dropProps` 的方式属于 RenderProps，我们可以将 `children` 当作函数执行以达到效果：

```jsx
const DragContainer = ({ children, componentId }) => {
  const { dragProps } = useDnd(componentId)

  return children({
    dragProps
  })
}

const DropContainer = ({ children, componentId }) => {
  const { dropProps } = useDnd(componentId)

  return children({
    dropProps
  })
}
```

那么这里创建了一个自定义 Hook `useDnd` 接收 `dragProps` 与 `dropProps`，这个自定义 Hook 可以这么写：

```jsx
const useDnd = ({ componentId }) => {
  const dragProps = {}
  const dropProps = {}

  return { dragProps, dropProps }
}
```

接下来，我们就要分别实现 `drag` 与 `drop` 了。

对 `drag` 来说，只要实现 `onDragStart` 与 `onDragEnd` 即可：

```jsx
const dragProps = {
  onDragStart: ev => {
    ev.stopPropagation()
    ev.dataTransfer.setData('componentId', componentId)
  },
  onDragEnd: ev => {
    // 做一些拖拽结束的清理工作
  }
}
```

`stopPropagation` 的作用在原文简介中已经介绍过了，`setData` 则是通知拖拽方，当前拖拽的组件 id 是什么，**这是由于拖拽由 `drag` 发起而由 `drop` 响应，因此必须有个数据传输过程，而 `dataTransfer` 就最适合做这件事。**

对于 `drop` 来说，只要实现 `onDragOver` 与 `onDrop` 即可：

```jsx
const dropProps = {
  onDragOver: ev => {
    // 做一些样式处理，提示用户此时松手会将元素放置在何处
  },
  onDrop: ev => {
    ev.stopPropagation()
    const componentId = ev.dataTransfer.getData('componentId')
    // 通过 componentId 修改数据，通过 React Rerender 刷新 UI
  }
}
```

重点在 `onDrop`，它是实现拖拽效果的 “真正执行处”，最终通过修改 UI 的方式更新数据。

存在一种场景，一个容器既可以被拖动，也可以被拖入，这种情况一般这个组件是个容器，但这个容器可以被拖入到其他容器中，可以自由嵌套。

实现这种场景的方式就是将 `DragContainer` 与 `DropContainer` 作用到一个组件上：

```jsx
const Box = (
  <DragContainer>
    {({ dragProps }) => (
      <DropContainer>
        {({ dropProps }) => {
          <div {...dragProps} {...dropProps} />
        }}
      </DropContainer>
    )}
  </DragContainer>
)
```

之所以能嵌套，在于 HTML5 的 API 允许一个元素同时拥有 `onDragStart`、`onDrop` 这两种属性，而上面的语法不过是同时将这两种属性传给组件 DOM。

所以，动手实现一个拖拽库就是这么简单，只要活用 HTML5 的拖拽 API，结合 React 一些特殊语法便够了。

## 4 总结

最后留下一个思考题，许多具有拖拽功能的系统都具备 “拖拽 placeholder” 的功能，即拖拽元素的过程中，在其 “落点” 位置展示一条横线或竖线，引导出松手后元素位置落点，如图所示：

<img width=400 src="https://img.alicdn.com/tfs/TB11H04wbY1gK0jSZTEXXXDQVXa-1434-384.png">

那么这条辅助线是通过什么方式实现的呢？欢迎在评论区留言！如果你有辅助线实现方案解析的文章，欢迎分享，也可以期待笔者未来专门写一篇 “拖拽 placeholder” 实现剖析的精读。

> 讨论地址是：[精读《手写 JSON Parser》 · Issue #233 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/233)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
