返回笔记首页

拖拽系统设计-深度剖析

主题配置

简历项目经验描述

版本1 - 适合初中级

plain
开发通用拖拽组件,支持列表排序、看板拖拽等多种场景
- 基于 Pointer Events API 实现跨设备拖拽,兼容鼠标、触摸、触控笔
- 支持拖拽排序、跨列表拖拽、拖拽复制等 8+ 种交互模式
- 实现拖拽预览、吸附对齐、撤销重做等增强功能,用户体验提升 40%

版本2 - 适合高级

plain
设计并实现企业级拖拽系统,支持复杂业务场景
- 自研拖拽引擎,通过碰撞检测、约束验证、状态管理等机制,支持 10+ 种复杂拖拽场景
- 实现类 Trello 看板系统,支持卡片拖拽、列拖拽、泳道拖拽,日活用户 5000+
- 开发可视化布局编辑器,支持组件拖拽、嵌套容器、参考线对齐,配置效率提升 5 倍
- 优化拖拽性能,通过节流、虚拟化、增量更新等手段,大数据量拖拽帧率稳定 60fps

版本3 - 适合架构方向

plain
主导拖拽交互系统架构设计,建立统一的拖拽解决方案
- 抽象拖拽核心模型,支持声明式 API,新增拖拽场景开发时间从 3 天降至 4 小时
- 设计插件化架构,功能按需加载,基础库体积仅 8KB,完整功能 25KB
- 建立拖拽性能监控体系,自动识别性能瓶颈,优化建议准确率 90%
- 制定拖拽交互规范和可访问性标准,通过 WCAG 2.1 AA 级认证

面试标准回答话术

Q1: 你们的拖拽系统是怎么设计的?

标准回答

"我们的拖拽系统是从零实现的,没有用第三方库,主要考虑是灵活性和性能。

整个系统分三层架构:

底层是拖拽引擎,负责处理原始的拖拽事件。用的是 Pointer Events API,而不是传统的 mouse 和 touch 事件,因为 Pointer Events 统一了鼠标、触摸、触控笔的处理,兼容性更好。

核心流程是:

  1. pointerdown 时开始监听,记录初始位置和拖拽的元素
  2. pointermove 时计算偏移量,更新拖拽元素的位置
  3. pointerup 时结束拖拽,触发 drop 事件

这个过程要用 setPointerCapture 锁定指针,即使鼠标移出元素也能继续接收事件。

中间层是拖拽管理器,负责管理拖拽状态和可放置区域。每个可拖拽元素注册时,会记录它的 ID、类型、约束条件。每个可放置区域也要注册,说明接受哪种类型的拖拽。

拖拽过程中,管理器会实时做碰撞检测,判断拖拽元素是否在某个可放置区域上方。如果是,就高亮那个区域,给用户视觉反馈。

上层是业务组件,比如看板、排序列表、树形结构。这些组件通过声明式的 API 使用拖拽功能,不需要关心底层实现。

举个例子,一个简单的拖拽排序列表:

vue
<DraggableList v-model=\"items\" item-key=\"id\">
  <template #item=\"{ item }\">
    <div>{{ item.name }}</div>
  </template>
</DraggableList>

就这么几行代码,拖拽排序就能工作了。组件内部会自动处理拖拽的所有逻辑,包括动画、占位符、数据更新。

性能方面,我们做了几个优化。拖拽过程中的位置更新用 transform,不触发 reflow。碰撞检测用了空间索引,不是遍历所有区域。大列表配合虚拟滚动,避免 DOM 节点过多。

整套系统已经支持了看板、表单设计器、布局编辑器等 10 多个业务场景,表现都很稳定。"

Q2: 类 Trello 看板的拖拽是怎么实现的?

标准回答

"Trello 看板的拖拽比普通列表复杂很多,因为有三个层级:卡片、列、看板。卡片可以在列内排序,也可以跨列拖拽,列本身也可以拖拽排序。

我的实现方案是嵌套的拖拽容器。

卡片拖拽: 每个卡片是可拖拽的,每个列是可放置区域。拖拽卡片时,会检测卡片中心点在哪个列上方,就高亮那个列。松开鼠标时,卡片就移动到那个列。

难点在于插入位置的计算。用户可能想把卡片插入到列的任意位置,不一定是末尾。我的做法是在拖拽过程中,遍历目标列的所有卡片,计算拖拽元素的中心点最接近哪张卡片,然后在那张卡片上方或下方显示一个插入指示器。

列拖拽: 列也是可拖拽的,看板容器是可放置区域。拖拽列时,其他列会自动调整位置,给拖拽的列腾出空间。这个效果是通过动画实现的,用 CSS transition 让列平滑移动。

数据更新: 拖拽结束后,要更新数据结构。我们的数据是这样的:

javascript
{
  columns: [
    {
      id: 'col1',
      name: '待办',
      cards: [
        { id: 'card1', title: '任务1' },
        { id: 'card2', title: '任务2' }
      ]
    }
  ]
}

跨列拖拽卡片时,要从源列的 cards 数组里删除,添加到目标列的 cards 数组,同时更新索引。这些操作要保证原子性,用 Vue 的响应式系统自动触发视图更新。

乐观更新: 用户操作时,先更新本地数据和视图,然后异步调用接口。如果接口失败,就回滚数据,并提示用户。这样体验很流畅,用户感觉不到网络延迟。

撤销重做: 每次拖拽都会记录一个操作历史,包含操作类型、源位置、目标位置。撤销时,根据历史反向操作。我们维护了一个操作栈,最多保留 50 步历史。

还有个优化是批量拖拽。按住 Ctrl 多选卡片,可以一起拖拽。这个实现是把选中的卡片作为一个整体,拖拽时预览显示卡片数量,放下时批量移动。"

Q3: 可视化布局编辑器的拖拽有什么特殊之处?

标准回答

"布局编辑器比看板更复杂,因为有嵌套容器、参考线对齐、吸附等高级功能。

嵌套容器的处理: 组件可以拖拽到容器里,容器又可以嵌套容器,形成树形结构。拖拽时要判断是否允许放置,比如不能把父容器拖到子容器里,不能超过最大嵌套层级。

我用深度优先遍历检查拖拽元素和目标容器的祖先链,如果目标在祖先链里,就禁止放置。还要检查容器的 accept 配置,有些容器只接受特定类型的组件。

参考线对齐: 拖拽组件时,如果接近其他组件的边界,会显示参考线,帮助用户对齐。我实现的对齐类型有:

  • 左对齐:拖拽组件的左边缘对齐其他组件的左边缘
  • 右对齐:右边缘对齐
  • 居中对齐:中心点对齐
  • 顶部对齐、底部对齐同理

检测对齐的逻辑是,遍历画布上的所有组件,计算拖拽组件的边界和其他组件的边界的距离,如果小于阈值(比如 5px),就认为接近,显示参考线。

吸附效果: 接近参考线时,拖拽组件会自动吸附到对齐位置,不需要用户手动精确对齐。实现上,如果检测到接近参考线,就直接修改拖拽元素的 transform,让它对齐。

组件选中和多选: 单击组件选中,显示边框和调整手柄。按住 Shift 多选,可以批量移动和删除。拖拽多个组件时,保持它们的相对位置不变。

调整大小: 选中组件后,四周显示 8 个调整手柄,拖拽手柄可以调整宽高。还支持等比缩放,按住 Shift 拖拽角上的手柄,宽高按比例变化。

调整大小时也要检测吸附,比如调整到和其他组件一样宽时,显示参考线并吸附。

撤销重做: 布局编辑器的撤销重做比看板复杂,因为不只有移动操作,还有添加、删除、调整大小、修改属性。我用命令模式实现,每个操作是一个命令对象,包含 execute 和 undo 方法。

命令栈记录所有操作,撤销就是调用 undo,重做就是重新 execute。还支持命令合并,比如连续调整大小的多个操作可以合并成一个,减少历史记录。

性能优化: 画布上组件很多时,每次拖拽都遍历所有组件检测碰撞,性能会有问题。我用了空间索引,把画布分成网格,每个网格记录包含哪些组件。碰撞检测时,只检查拖拽元素所在网格及周围网格的组件,大大减少了计算量。"

Q4: 树形节点拖拽的难点是什么?

标准回答

"树形拖拽的难点主要是判断放置位置和更新树结构。

放置位置判断: 树形结构有三种放置位置:

  1. 作为某个节点的子节点(插入到 children)
  2. 作为某个节点的兄弟节点(插入到同一层级)
  3. 移动到空白区域(移动到根层级)

我的判断逻辑是:

  • 如果拖拽到节点的中间区域,就作为子节点
  • 如果拖拽到节点的上方或下方边缘,就作为兄弟节点
  • 如果拖拽到容器空白区域,就作为根节点

视觉反馈很重要。我用了三种指示器:

  • 子节点插入:在目标节点内部显示高亮背景
  • 兄弟节点插入:在目标节点上方或下方显示一条插入线
  • 根节点插入:在根层级显示插入线

约束检查: 不是所有拖拽都合法,要做约束检查:

  1. 不能拖到自己的子孙节点下(会形成环)
  2. 不能超过最大层级限制
  3. 某些节点可能不允许拖拽或不允许接收子节点

我在拖拽开始时,遍历拖拽节点的所有子孙节点,记录到一个集合里。拖拽过程中,如果目标节点在这个集合里,就禁止放置。

树结构更新: 拖拽结束后,要更新树的数据结构。这个比看板复杂,因为树可能嵌套很深,而且源节点和目标节点可能在不同的分支。

我用了两步操作:

  1. 从源位置删除节点(找到父节点,从它的 children 里移除)
  2. 在目标位置插入节点(找到目标父节点,插入到它的 children)

关键是要正确计算父节点和插入索引。我维护了一个 nodeMap,key 是节点 ID,value 是节点引用,可以快速找到任意节点。

动画效果: 树形拖拽的动画比较难做,因为节点的层级和位置都在变化。我的做法是:

  1. 拖拽开始时,记录节点的原始位置
  2. 拖拽结束时,计算节点的目标位置
  3. 用 FLIP 技术(First, Last, Invert, Play)实现平滑动画

具体来说,先让节点瞬移到目标位置,然后计算它从原始位置到目标位置的偏移量,用 transform 反向偏移,最后播放动画恢复到目标位置。

展开折叠的处理: 拖拽到折叠的节点上时,如果悬停超过 1 秒,自动展开节点,方便用户拖拽到更深的层级。这个用 setTimeout 实现,记录悬停的开始时间,超时就触发展开。

还有个细节是,展开节点后,要重新计算可放置区域,更新碰撞检测。"

Q5: 拖拽的性能如何优化?

标准回答

"拖拽的性能优化主要从几个方面入手:

1. 减少重绘和回流

拖拽过程中,最频繁的操作是更新拖拽元素的位置。如果用 left/top 定位,每次更新都会触发 reflow,性能很差。

我用 transform 代替 left/top:

javascript
element.style.transform = `translate(${x}px, ${y}px)`

transform 只触发 composite,不触发 layout 和 paint,性能好很多。

2. 事件节流

pointermove 事件触发频率很高,每秒可能几十次。如果每次都更新 DOM,CPU 会吃不消。

我用 requestAnimationFrame 做节流:

javascript
let rafId = null
function handleMove(e) {
  if (rafId) return

  rafId = requestAnimationFrame(() => {
    updatePosition(e.clientX, e.clientY)
    rafId = null
  })
}

保证每帧最多更新一次,大大降低了 CPU 占用。

3. 碰撞检测优化

如果拖拽容器里有 1000 个元素,每次移动都遍历 1000 个元素判断碰撞,性能很差。

我用了空间索引,把容器分成网格,每个格子记录包含哪些元素。碰撞检测时,只检查拖拽元素所在格子及周围格子的元素。

从 O(n) 降到了 O(1),性能提升显著。

4. 虚拟化长列表

如果拖拽列表有几千项,全部渲染会很卡。我结合虚拟滚动,只渲染可见的项,大大减少了 DOM 节点数量。

拖拽时,如果拖到可视区域外,触发滚动,同时更新虚拟滚动的范围。

5. 拖拽预览优化

拖拽预览就是跟随鼠标移动的半透明元素。如果预览很复杂,包含图片、样式,渲染会很慢。

我做了简化预览,只显示关键信息,比如标题和图标。对于图片,用缩略图代替原图。

还可以用 canvas 渲染预览,比 DOM 性能更好。

6. 防抖和批量更新

如果拖拽触发数据变化,比如排序、分组,不要每次移动都更新,而是拖拽结束后再更新。

如果必须实时更新,用 debounce 防抖,减少更新频率。

通过这些优化,即使拖拽 1000 项的列表,帧率也能稳定在 60fps。"

Q6: 拖拽数据如何持久化?

标准回答

"拖拽数据持久化有两种策略:实时保存和延迟保存。

实时保存: 每次拖拽结束,立即调用接口保存新的顺序或位置。优点是数据不会丢失,缺点是网络请求频繁,可能影响性能。

我用了乐观更新 + 队列的方案:

  1. 拖拽结束,立即更新本地数据和 UI
  2. 把保存请求加入队列,异步发送
  3. 如果请求失败,从队列里重试,最多 3 次
  4. 3 次都失败,提示用户并回滚数据

队列可以合并请求,比如用户连续拖拽多次,只发送最后一次的结果。

延迟保存: 用户操作过程中不保存,离开页面或点击保存按钮时才保存。优点是减少网络请求,缺点是可能丢失数据。

为了防止丢失,我做了本地缓存:

  1. 拖拽结束,保存数据到 localStorage
  2. 页面刷新,从 localStorage 恢复数据
  3. 用户主动保存,清空 localStorage

还监听 beforeunload 事件,如果有未保存的改动,弹窗提示用户。

版本冲突处理: 多人协作时,可能出现冲突。比如 A 和 B 同时编辑看板,A 移动了卡片,B 也移动了卡片,谁的改动生效?

我用了版本号机制:

  1. 每次加载数据,记录版本号
  2. 保存时,把版本号发给后端
  3. 后端检查版本号,如果不一致,拒绝保存
  4. 前端收到拒绝,重新加载数据,让用户重新操作

还有个方案是操作转换(Operational Transformation),类似 Google Docs 的协同编辑,但实现复杂,我们没有用。

数据格式: 持久化的数据要能还原拖拽状态。比如看板的数据:

json
{
  \"columns\": [
    {
      \"id\": \"col1\",
      \"order\": 0,
      \"cards\": [
        { \"id\": \"card1\", \"order\": 0 },
        { \"id\": \"card2\", \"order\": 1 }
      ]
    }
  ]
}

每个元素都有 order 字段,表示顺序。排序时只需要更新 order,不需要调整数组结构。

布局编辑器的数据:

json
{
  \"components\": [
    {
      \"id\": \"comp1\",
      \"type\": \"button\",
      \"x\": 100,
      \"y\": 200,
      \"width\": 80,
      \"height\": 40,
      \"parentId\": null
    }
  ]
}

每个组件的位置、大小、父组件都记录下来,可以完全还原布局。"


核心难点与解决方案

难点1: 跨容器拖拽的状态管理

问题描述: 拖拽元素从一个容器移动到另一个容器,涉及两个容器的状态更新,如何保证数据一致性?

解决方案

"跨容器拖拽的状态管理是个经典的状态同步问题。我用了单向数据流 + 事件总线的方案。

设计思路
  1. 全局拖拽状态 - 用一个全局的 store 管理拖拽状态,包括:
javascript
{
  isDragging: false,      // 是否正在拖拽
  dragData: null,         // 拖拽的数据
  sourceContainer: null,  // 源容器
  targetContainer: null,  // 目标容器
  sourceIndex: null,      // 源索引
  targetIndex: null,      // 目标索引
}
  1. 容器注册机制 - 每个可拖拽容器初始化时,在全局 store 注册:
javascript
const containerId = registerContainer({
  id: 'list-1',
  type: 'list',
  onDrop: handleDrop,
  canAccept: (data) => data.type === 'card'
})
  1. 拖拽生命周期:

开始拖拽(dragStart):

  • 记录源容器和源索引
  • 把拖拽数据存到全局状态
  • 触发源容器的 onDragStart 事件

拖拽中(dragOver):

  • 实时检测拖拽元素在哪个容器上方
  • 调用目标容器的 canAccept 判断是否可以放置
  • 如果可以,更新目标容器和目标索引,触发 onDragEnter
  • 如果离开容器,触发 onDragLeave

结束拖拽(dragEnd):

  • 如果有有效的目标容器,触发 drop 事件
  • 调用源容器的 onRemove,从源位置删除数据
  • 调用目标容器的 onAdd,在目标位置添加数据
  • 清空全局拖拽状态
具体实现
javascript
// 拖拽管理器
const dragManager = {
  state: reactive({
    isDragging: false,
    dragData: null,
    sourceContainer: null,
    targetContainer: null,
  }),

  containers: new Map(),

  registerContainer(config) {
    const id = generateId()
    this.containers.set(id, config)
    return id
  },

  startDrag(containerId, index, data) {
    const container = this.containers.get(containerId)

    this.state.isDragging = true
    this.state.dragData = data
    this.state.sourceContainer = containerId
    this.state.sourceIndex = index

    container.onDragStart?.(data, index)
  },

  updateTarget(containerId, index) {
    if (this.state.targetContainer !== containerId) {
      // 离开旧容器
      if (this.state.targetContainer) {
        const oldContainer = this.containers.get(this.state.targetContainer)
        oldContainer.onDragLeave?.()
      }

      // 进入新容器
      const newContainer = this.containers.get(containerId)
      if (newContainer.canAccept(this.state.dragData)) {
        this.state.targetContainer = containerId
        newContainer.onDragEnter?.(this.state.dragData)
      }
    }

    this.state.targetIndex = index
  },

  endDrag() {
    const { sourceContainer, targetContainer, sourceIndex, targetIndex, dragData } = this.state

    if (targetContainer && sourceContainer !== targetContainer) {
      // 跨容器拖拽
      const source = this.containers.get(sourceContainer)
      const target = this.containers.get(targetContainer)

      source.onRemove(sourceIndex)
      target.onAdd(targetIndex, dragData)
    } else if (targetContainer && sourceIndex !== targetIndex) {
      // 同容器排序
      const container = this.containers.get(sourceContainer)
      container.onReorder(sourceIndex, targetIndex)
    }

    // 清空状态
    this.state.isDragging = false
    this.state.dragData = null
    this.state.sourceContainer = null
    this.state.targetContainer = null
  }
}
优点
  • 状态集中管理,容易调试
  • 容器之间解耦,不直接通信
  • 容易扩展,增加新容器不影响现有代码
注意事项
  • 要处理拖拽取消的情况(按 Esc 或拖到无效区域)
  • 要防止内存泄漏,容器销毁时要注销
  • 要处理并发拖拽(不太可能,但要容错)"

难点2: 拖拽预览的性能优化

问题描述: 拖拽预览跟随鼠标移动,如果预览元素很复杂(包含图片、样式、动画),渲染会很慢,导致拖拽卡顿。

解决方案

"拖拽预览的性能优化我做了三层优化:

优化1: 简化预览内容

不是把原始元素完整克隆,而是渲染一个简化版。比如拖拽卡片时,预览只显示标题和图标,不显示描述、标签、按钮这些次要信息。

javascript
function createDragPreview(element) {
  const preview = document.createElement('div')
  preview.className = 'drag-preview'

  // 只提取关键信息
  const title = element.querySelector('.card-title')
  const icon = element.querySelector('.card-icon')

  if (icon) preview.appendChild(icon.cloneNode(true))
  if (title) preview.appendChild(title.cloneNode(true))

  return preview
}
优化2: 使用 Canvas 渲染

对于复杂的预览,用 Canvas 渲染比 DOM 快很多。原理是把元素转成图片,然后在 Canvas 上绘制。

javascript
async function createCanvasPreview(element) {
  // 使用 html2canvas 把元素转成图片
  const canvas = await html2canvas(element, {
    width: element.offsetWidth,
    height: element.offsetHeight,
    scale: 0.5, // 缩小一半,提高性能
  })

  // Canvas 作为预览
  canvas.className = 'drag-preview'
  return canvas
}
优化3: 虚拟预览

对于批量拖拽(比如拖拽 100 个文件),不是渲染 100 个预览,而是渲染一个代表性的预览,显示数量即可。

javascript
function createBatchPreview(items) {
  const preview = document.createElement('div')
  preview.className = 'drag-preview-batch'

  // 显示第一个元素的预览
  const firstItem = createDragPreview(items[0])
  preview.appendChild(firstItem)

  // 显示数量徽章
  if (items.length > 1) {
    const badge = document.createElement('div')
    badge.className = 'preview-badge'
    badge.textContent = items.length
    preview.appendChild(badge)
  }

  return preview
}
优化4: 延迟渲染

预览不是立即渲染完整内容,而是先渲染占位符,然后用 requestIdleCallback 在空闲时渲染完整内容。

javascript
function createLazyPreview(element) {
  // 先渲染占位符
  const preview = document.createElement('div')
  preview.className = 'drag-preview-placeholder'

  // 延迟渲染完整内容
  requestIdleCallback(() => {
    const fullPreview = createDragPreview(element)
    preview.replaceWith(fullPreview)
  })

  return preview
}
优化5: CSS 优化

预览元素用 will-change 和 transform,开启硬件加速:

css
.drag-preview {
  will-change: transform;
  transform: translate3d(0, 0, 0);
  opacity: 0.8;
  pointer-events: none;
}
效果对比
  • 优化前:拖拽复杂元素,帧率 30fps
  • 优化后:拖拽相同元素,帧率 60fps
  • 内存占用减少 60%"

难点3: 参考线对齐和吸附的算法

问题描述: 布局编辑器中,拖拽组件时如何检测是否接近其他组件的边界,并显示参考线和吸附?

解决方案

"参考线对齐的核心是几何计算和边界检测。

算法流程
  1. 获取所有可对齐的边界

遍历画布上的所有组件(除了正在拖拽的),提取它们的边界:

javascript
const boundaries = []

components.forEach(comp => {
  if (comp.id === draggingId) return

  boundaries.push(
    { type: 'left', value: comp.x, component: comp },
    { type: 'right', value: comp.x + comp.width, component: comp },
    { type: 'center-x', value: comp.x + comp.width / 2, component: comp },
    { type: 'top', value: comp.y, component: comp },
    { type: 'bottom', value: comp.y + comp.height, component: comp },
    { type: 'center-y', value: comp.y + comp.height / 2, component: comp }
  )
})
  1. 计算拖拽组件的边界
javascript
const draggingBounds = {
  left: draggingX,
  right: draggingX + draggingWidth,
  centerX: draggingX + draggingWidth / 2,
  top: draggingY,
  bottom: draggingY + draggingHeight,
  centerY: draggingY + draggingHeight / 2
}
  1. 检测接近的边界

对每种类型的边界,找出最接近的:

javascript
const SNAP_THRESHOLD = 5 // 5px 以内算接近

function findClosestBoundary(value, boundaries, type) {
  let closest = null
  let minDistance = Infinity

  boundaries.forEach(boundary => {
    if (boundary.type !== type) return

    const distance = Math.abs(boundary.value - value)

    if (distance < SNAP_THRESHOLD && distance < minDistance) {
      minDistance = distance
      closest = boundary
    }
  })

  return closest
}

const alignments = {
  left: findClosestBoundary(draggingBounds.left, boundaries, 'left'),
  right: findClosestBoundary(draggingBounds.right, boundaries, 'right'),
  centerX: findClosestBoundary(draggingBounds.centerX, boundaries, 'center-x'),
  top: findClosestBoundary(draggingBounds.top, boundaries, 'top'),
  bottom: findClosestBoundary(draggingBounds.bottom, boundaries, 'bottom'),
  centerY: findClosestBoundary(draggingBounds.centerY, boundaries, 'center-y'),
}
  1. 显示参考线

对每个检测到的对齐,显示一条参考线:

javascript
const guidelines = []

if (alignments.left) {
  guidelines.push({
    type: 'vertical',
    position: alignments.left.value,
    start: Math.min(draggingBounds.top, alignments.left.component.y),
    end: Math.max(draggingBounds.bottom, alignments.left.component.y + alignments.left.component.height)
  })
}

// 其他方向同理...

参考线用绝对定位的 div 渲染:

vue
<div
  v-for="line in guidelines"
  :key="line.id"
  class="guideline"
  :class="line.type"
  :style="{
    [line.type === 'vertical' ? 'left' : 'top']: line.position + 'px',
    [line.type === 'vertical' ? 'top' : 'left']: line.start + 'px',
    [line.type === 'vertical' ? 'height' : 'width']: (line.end - line.start) + 'px'
  }"
></div>
  1. 吸附位置

如果检测到对齐,调整拖拽组件的位置:

javascript
let snapX = draggingX
let snapY = draggingY

if (alignments.left) {
  snapX = alignments.left.value
} else if (alignments.right) {
  snapX = alignments.right.value - draggingWidth
} else if (alignments.centerX) {
  snapX = alignments.centerX.value - draggingWidth / 2
}

if (alignments.top) {
  snapY = alignments.top.value
} else if (alignments.bottom) {
  snapY = alignments.bottom.value - draggingHeight
} else if (alignments.centerY) {
  snapY = alignments.centerY.value - draggingHeight / 2
}

// 应用吸附位置
draggingElement.style.transform = `translate(${snapX}px, ${snapY}px)`
优化

画布组件很多时,遍历所有组件检测对齐,性能会有问题。我做了两个优化:

  1. 空间分区 - 把画布分成网格,只检测拖拽组件附近网格的组件
  2. 节流 - 对齐检测用 requestAnimationFrame 节流,每帧最多检测一次
效果
  • 100 个组件的画布,对齐检测耗时 < 2ms
  • 参考线实时更新,无延迟感"

完整技术实现

1. 拖拽核心 Composable

javascript
// composables/useDraggable.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useDraggable(options = {}) {
  const {
    onDragStart,
    onDragMove,
    onDragEnd,
    disabled = false,
    handle = null, // 拖拽手柄选择器
  } = options

  const elementRef = ref(null)
  const isDragging = ref(false)
  const dragData = ref(null)

  let startX = 0
  let startY = 0
  let currentX = 0
  let currentY = 0

  function handlePointerDown(e) {
    if (disabled) return

    // 如果指定了手柄,检查点击的是否是手柄
    if (handle && !e.target.closest(handle)) {
      return
    }

    e.preventDefault()

    // 记录起始位置
    startX = e.clientX
    startY = e.clientY
    currentX = 0
    currentY = 0

    isDragging.value = true

    // 设置指针捕获
    elementRef.value.setPointerCapture(e.pointerId)

    // 添加事件监听
    document.addEventListener('pointermove', handlePointerMove)
    document.addEventListener('pointerup', handlePointerUp)

    // 触发回调
    dragData.value = onDragStart?.(e)
  }

  function handlePointerMove(e) {
    if (!isDragging.value) return

    currentX = e.clientX - startX
    currentY = e.clientY - startY

    onDragMove?.({
      x: currentX,
      y: currentY,
      clientX: e.clientX,
      clientY: e.clientY,
      event: e,
    })
  }

  function handlePointerUp(e) {
    if (!isDragging.value) return

    isDragging.value = false

    // 移除事件监听
    document.removeEventListener('pointermove', handlePointerMove)
    document.removeEventListener('pointerup', handlePointerUp)

    // 触发回调
    onDragEnd?.({
      x: currentX,
      y: currentY,
      event: e,
      data: dragData.value,
    })

    dragData.value = null
  }

  onMounted(() => {
    if (elementRef.value) {
      elementRef.value.addEventListener('pointerdown', handlePointerDown)
    }
  })

  onUnmounted(() => {
    if (elementRef.value) {
      elementRef.value.removeEventListener('pointerdown', handlePointerDown)
    }
    document.removeEventListener('pointermove', handlePointerMove)
    document.removeEventListener('pointerup', handlePointerUp)
  })

  return {
    elementRef,
    isDragging,
    dragData,
  }
}

2. 拖拽排序列表组件

vue
<!-- components/DraggableList/DraggableList.vue -->
<template>
  <div ref="containerRef" class="draggable-list">
    <div
      v-for="(item, index) in localItems"
      :key="getItemKey(item)"
      :ref="el => setItemRef(el, index)"
      class="draggable-item"
      :class="{
        'is-dragging': draggingIndex === index,
        'is-placeholder': placeholderIndex === index
      }"
      :style="getItemStyle(index)"
      @pointerdown="handlePointerDown($event, index)"
    >
      <slot :item="item" :index="index"></slot>
    </div>

    <!-- 拖拽预览 -->
    <Teleport to="body">
      <div
        v-if="isDragging"
        class="drag-preview"
        :style="{
          transform: `translate(${previewX}px, ${previewY}px)`,
          width: previewWidth + 'px'
        }"
      >
        <slot name="preview" :item="draggingItem">
          <slot :item="draggingItem" :index="draggingIndex"></slot>
        </slot>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'

const props = defineProps({
  modelValue: {
    type: Array,
    required: true,
  },
  itemKey: {
    type: [String, Function],
    default: 'id',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
})

const emit = defineEmits(['update:modelValue', 'change'])

const containerRef = ref(null)
const localItems = ref([...props.modelValue])

watch(() => props.modelValue, (newVal) => {
  localItems.value = [...newVal]
}, { deep: true })

// 拖拽状态
const isDragging = ref(false)
const draggingIndex = ref(null)
const draggingItem = ref(null)
const placeholderIndex = ref(null)

// 预览位置
const previewX = ref(0)
const previewY = ref(0)
const previewWidth = ref(0)

// 元素引用
const itemRefs = new Map()

function setItemRef(el, index) {
  if (el) {
    itemRefs.set(index, el)
  }
}

// 获取项的 key
function getItemKey(item) {
  if (typeof props.itemKey === 'function') {
    return props.itemKey(item)
  }
  return item[props.itemKey]
}

// 获取项的样式
function getItemStyle(index) {
  if (draggingIndex.value === index) {
    return {
      opacity: 0.3,
      pointerEvents: 'none',
    }
  }
  return {}
}

// 开始拖拽
let startX = 0
let startY = 0
let startPageY = 0

function handlePointerDown(e, index) {
  if (props.disabled) return

  const element = itemRefs.get(index)
  if (!element) return

  // 记录起始位置
  const rect = element.getBoundingClientRect()
  startX = e.clientX - rect.left
  startY = e.clientY - rect.top
  startPageY = e.clientY

  draggingIndex.value = index
  draggingItem.value = localItems.value[index]
  placeholderIndex.value = index

  previewWidth.value = rect.width
  previewX.value = rect.left
  previewY.value = rect.top

  isDragging.value = true

  // 设置指针捕获
  element.setPointerCapture(e.pointerId)

  document.addEventListener('pointermove', handlePointerMove)
  document.addEventListener('pointerup', handlePointerUp)
}

// 拖拽中
function handlePointerMove(e) {
  if (!isDragging.value) return

  // 更新预览位置
  previewX.value = e.clientX - startX
  previewY.value = e.clientY - startY

  // 计算应该插入的位置
  const newIndex = findInsertIndex(e.clientY)
  if (newIndex !== placeholderIndex.value) {
    placeholderIndex.value = newIndex
  }
}

// 查找插入位置
function findInsertIndex(clientY) {
  const containerRect = containerRef.value.getBoundingClientRect()
  const relativeY = clientY - containerRect.top

  let insertIndex = 0

  for (let i = 0; i < localItems.value.length; i++) {
    if (i === draggingIndex.value) continue

    const element = itemRefs.get(i)
    if (!element) continue

    const rect = element.getBoundingClientRect()
    const elementY = rect.top - containerRect.top
    const elementMiddle = elementY + rect.height / 2

    if (relativeY < elementMiddle) {
      break
    }

    insertIndex = i + 1
  }

  return insertIndex
}

// 结束拖拽
function handlePointerUp(e) {
  if (!isDragging.value) return

  document.removeEventListener('pointermove', handlePointerMove)
  document.removeEventListener('pointerup', handlePointerUp)

  // 更新数组顺序
  if (placeholderIndex.value !== draggingIndex.value) {
    const newItems = [...localItems.value]
    const [removed] = newItems.splice(draggingIndex.value, 1)
    newItems.splice(placeholderIndex.value, 0, removed)

    localItems.value = newItems
    emit('update:modelValue', newItems)
    emit('change', {
      from: draggingIndex.value,
      to: placeholderIndex.value,
      item: removed,
    })
  }

  // 重置状态
  isDragging.value = false
  draggingIndex.value = null
  draggingItem.value = null
  placeholderIndex.value = null
}
</script>

<style scoped>
.draggable-list {
  position: relative;
}

.draggable-item {
  cursor: move;
  transition: opacity 0.2s;
  user-select: none;
}

.draggable-item.is-dragging {
  opacity: 0.3;
  pointer-events: none;
}

.drag-preview {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  opacity: 0.8;
  pointer-events: none;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>

3. 看板拖拽组件

vue
<!-- components/Kanban/KanbanBoard.vue -->
<template>
  <div class="kanban-board">
    <div class="kanban-columns">
      <div
        v-for="column in columns"
        :key="column.id"
        class="kanban-column"
      >
        <div class="column-header">
          <h3>{{ column.name }}</h3>
          <span class="card-count">{{ column.cards.length }}</span>
        </div>

        <div
          :ref="el => setColumnRef(el, column.id)"
          class="column-body"
          :class="{ 'is-over': overColumnId === column.id }"
          @dragover="handleDragOver($event, column.id)"
          @dragleave="handleDragLeave"
          @drop="handleDrop($event, column.id)"
        >
          <div
            v-for="(card, index) in column.cards"
            :key="card.id"
            class="kanban-card"
            draggable="true"
            @dragstart="handleDragStart($event, column.id, index, card)"
            @dragend="handleDragEnd"
          >
            <slot name="card" :card="card">
              <div class="card-title">{{ card.title }}</div>
            </slot>
          </div>

          <!-- 插入指示器 -->
          <div
            v-if="overColumnId === column.id && insertIndex !== null"
            class="insert-indicator"
            :style="{ top: insertIndicatorTop + 'px' }"
          ></div>

          <!-- 空状态 -->
          <div v-if="column.cards.length === 0" class="column-empty">
            拖拽卡片到这里
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const props = defineProps({
  columns: {
    type: Array,
    required: true,
  },
})

const emit = defineEmits(['card-move'])

// 列元素引用
const columnRefs = new Map()

function setColumnRef(el, columnId) {
  if (el) {
    columnRefs.set(columnId, el)
  }
}

// 拖拽状态
const dragState = reactive({
  sourceColumnId: null,
  sourceIndex: null,
  dragData: null,
})

const overColumnId = ref(null)
const insertIndex = ref(null)
const insertIndicatorTop = ref(0)

// 开始拖拽
function handleDragStart(e, columnId, index, card) {
  dragState.sourceColumnId = columnId
  dragState.sourceIndex = index
  dragState.dragData = card

  // 设置拖拽数据
  e.dataTransfer.effectAllowed = 'move'
  e.dataTransfer.setData('text/plain', card.id)

  // 自定义拖拽图像(可选)
  const dragImage = e.target.cloneNode(true)
  dragImage.style.opacity = '0.8'
  document.body.appendChild(dragImage)
  e.dataTransfer.setDragImage(dragImage, 0, 0)
  setTimeout(() => document.body.removeChild(dragImage), 0)
}

// 拖拽经过
function handleDragOver(e, columnId) {
  e.preventDefault()
  e.dataTransfer.dropEffect = 'move'

  overColumnId.value = columnId

  // 计算插入位置
  const columnEl = columnRefs.get(columnId)
  if (!columnEl) return

  const cards = Array.from(columnEl.querySelectorAll('.kanban-card'))
  const afterElement = getDragAfterElement(columnEl, e.clientY, cards)

  if (afterElement == null) {
    insertIndex.value = cards.length
    insertIndicatorTop.value = columnEl.scrollHeight
  } else {
    const rect = afterElement.getBoundingClientRect()
    const containerRect = columnEl.getBoundingClientRect()
    insertIndex.value = cards.indexOf(afterElement)
    insertIndicatorTop.value = rect.top - containerRect.top
  }
}

// 找到拖拽后面的元素
function getDragAfterElement(container, y, cards) {
  return cards.reduce((closest, child) => {
    const box = child.getBoundingClientRect()
    const offset = y - box.top - box.height / 2

    if (offset < 0 && offset > closest.offset) {
      return { offset: offset, element: child }
    } else {
      return closest
    }
  }, { offset: Number.NEGATIVE_INFINITY }).element
}

// 离开拖拽区域
function handleDragLeave(e) {
  // 检查是否真的离开了列(而不是进入子元素)
  if (e.currentTarget.contains(e.relatedTarget)) {
    return
  }

  if (overColumnId.value) {
    overColumnId.value = null
    insertIndex.value = null
  }
}

// 放置
function handleDrop(e, targetColumnId) {
  e.preventDefault()

  const { sourceColumnId, sourceIndex, dragData } = dragState

  if (!sourceColumnId || !dragData) return

  // 触发移动事件
  emit('card-move', {
    card: dragData,
    fromColumn: sourceColumnId,
    fromIndex: sourceIndex,
    toColumn: targetColumnId,
    toIndex: insertIndex.value,
  })

  // 重置状态
  overColumnId.value = null
  insertIndex.value = null
}

// 结束拖拽
function handleDragEnd() {
  dragState.sourceColumnId = null
  dragState.sourceIndex = null
  dragState.dragData = null
  overColumnId.value = null
  insertIndex.value = null
}
</script>

<style scoped>
.kanban-board {
  height: 100%;
  overflow-x: auto;
}

.kanban-columns {
  display: flex;
  gap: 16px;
  padding: 16px;
  height: 100%;
}

.kanban-column {
  flex: 0 0 300px;
  display: flex;
  flex-direction: column;
  background: #f5f5f5;
  border-radius: 4px;
}

.column-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid #e0e0e0;
}

.column-header h3 {
  margin: 0;
  font-size: 16px;
  font-weight: 500;
}

.card-count {
  color: #999;
  font-size: 14px;
}

.column-body {
  flex: 1;
  padding: 12px;
  overflow-y: auto;
  position: relative;
  min-height: 100px;
}

.column-body.is-over {
  background: #e8f4fd;
}

.kanban-card {
  background: white;
  border-radius: 4px;
  padding: 12px;
  margin-bottom: 8px;
  cursor: move;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  transition: box-shadow 0.2s;
}

.kanban-card:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

.card-title {
  font-size: 14px;
  line-height: 1.5;
}

.insert-indicator {
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  background: #1890ff;
  pointer-events: none;
  transition: top 0.2s;
}

.column-empty {
  text-align: center;
  padding: 40px 20px;
  color: #999;
  font-size: 14px;
}
</style>

4. 使用示例

vue
<!-- views/KanbanExample.vue -->
<template>
  <div class="kanban-example">
    <h2>看板示例</h2>

    <KanbanBoard
      :columns="columns"
      @card-move="handleCardMove"
    >
      <template #card="{ card }">
        <div class="custom-card">
          <div class="card-title">{{ card.title }}</div>
          <div class="card-desc">{{ card.description }}</div>
          <div class="card-footer">
            <span class="card-assignee">{{ card.assignee }}</span>
            <span class="card-date">{{ card.dueDate }}</span>
          </div>
        </div>
      </template>
    </KanbanBoard>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import KanbanBoard from '@/components/Kanban/KanbanBoard.vue'

const columns = ref([
  {
    id: 'todo',
    name: '待办',
    cards: [
      {
        id: '1',
        title: '设计首页原型',
        description: '完成首页的交互设计',
        assignee: '张三',
        dueDate: '2024-01-15',
      },
      {
        id: '2',
        title: '开发用户登录',
        description: '实现用户登录功能',
        assignee: '李四',
        dueDate: '2024-01-16',
      },
    ],
  },
  {
    id: 'doing',
    name: '进行中',
    cards: [
      {
        id: '3',
        title: '优化列表性能',
        description: '使用虚拟滚动优化',
        assignee: '王五',
        dueDate: '2024-01-17',
      },
    ],
  },
  {
    id: 'done',
    name: '已完成',
    cards: [
      {
        id: '4',
        title: '搭建项目框架',
        description: '完成项目初始化',
        assignee: '赵六',
        dueDate: '2024-01-10',
      },
    ],
  },
])

function handleCardMove(event) {
  const { card, fromColumn, fromIndex, toColumn, toIndex } = event

  // 从源列删除
  const sourceColumn = columns.value.find(col => col.id === fromColumn)
  if (sourceColumn) {
    sourceColumn.cards.splice(fromIndex, 1)
  }

  // 添加到目标列
  const targetColumn = columns.value.find(col => col.id === toColumn)
  if (targetColumn) {
    targetColumn.cards.splice(toIndex, 0, card)
  }

  message.success(`卡片 "${card.title}" 移动到 ${targetColumn.name}`)

  // 这里可以调用接口保存顺序
  // await updateCardPosition({ cardId: card.id, columnId: toColumn, order: toIndex })
}
</script>

<style scoped>
.kanban-example {
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.custom-card {
  font-size: 14px;
}

.card-title {
  font-weight: 500;
  margin-bottom: 8px;
}

.card-desc {
  color: #666;
  margin-bottom: 12px;
}

.card-footer {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  color: #999;
}
</style>

面试常见追问

Q: 拖拽在移动端需要注意什么?

"移动端主要注意三点:

  1. 事件兼容性 - 移动端用 touch 事件,PC 用 mouse 事件。我用 Pointer Events API 统一处理,兼容性更好。
  2. 滚动冲突 - 移动端拖拽时,容器可能会跟着滚动。我在 touchmove 时调用 e.preventDefault() 阻止默认滚动,同时手动实现边缘自动滚动。
  3. 长按触发 - 移动端通常是长按才开始拖拽,避免和点击冲突。我用 setTimeout 实现,按住 300ms 后才开始拖拽。"

Q: 如何实现拖拽的撤销重做?

"我用命令模式实现撤销重做:

  1. 定义命令接口 - 每个操作是一个命令对象,有 execute 和 undo 方法
  2. 命令栈 - 维护两个栈,undoStack 记录已执行的命令,redoStack 记录已撤销的命令
  3. 执行命令 - 拖拽结束时,创建命令对象,调用 execute,push 到 undoStack
  4. 撤销 - 从 undoStack pop 出命令,调用 undo,push 到 redoStack
  5. 重做 - 从 redoStack pop 出命令,重新 execute,push 回 undoStack

代码示例:

javascript
class MoveCardCommand {
  constructor(data) {
    this.fromColumn = data.fromColumn
    this.toColumn = data.toColumn
    this.fromIndex = data.fromIndex
    this.toIndex = data.toIndex
    this.card = data.card
  }

  execute() {
    // 执行移动
    moveCard(this.card, this.toColumn, this.toIndex)
  }

  undo() {
    // 撤销移动
    moveCard(this.card, this.fromColumn, this.fromIndex)
  }
}
```"

### Q: 拖拽时如何实现自动滚动?

"当拖拽到容器边缘时,自动滚动容器,方便拖拽到不可见的位置。

实现思路:

1. **检测边缘** - 拖拽时判断鼠标是否接近容器边缘(比如距离边缘 50px 以内)

2. **计算滚动速度** - 距离边缘越近,滚动越快。用线性函数计算:
```javascript
const speed = (threshold - distance) / threshold * maxSpeed
  1. 持续滚动 - 用 requestAnimationFrame 循环,每帧滚动一点:
javascript
function autoScroll() {
  if (shouldScroll) {
    container.scrollTop += scrollSpeed
    requestAnimationFrame(autoScroll)
  }
}
  1. 停止滚动 - 鼠标离开边缘区域,或拖拽结束,停止滚动

还要注意边界,不能滚动超出容器范围。"

Q: 如何测试拖拽功能?

"拖拽功能的测试比较特殊,因为涉及鼠标事件模拟。

单元测试:

javascript
import { mount } from '@vue/test-utils'
import DraggableList from './DraggableList.vue'

test('drag and drop reorders items', async () => {
  const wrapper = mount(DraggableList, {
    props: {
      modelValue: [{ id: 1 }, { id: 2 }, { id: 3 }]
    }
  })

  // 模拟拖拽
  const items = wrapper.findAll('.draggable-item')
  await items[0].trigger('pointerdown', { clientX: 0, clientY: 0 })
  await items[0].trigger('pointermove', { clientX: 0, clientY: 100 })
  await items[0].trigger('pointerup')

  // 断言顺序变化
  expect(wrapper.emitted('update:modelValue')[0][0]).toEqual([
    { id: 2 }, { id: 1 }, { id: 3 }
  ])
})

E2E 测试用 Playwright:

javascript
test('kanban drag and drop', async ({ page }) => {
  await page.goto('/kanban')

  const card = page.locator('.kanban-card').first()
  const column = page.locator('.kanban-column').nth(1)

  await card.dragTo(column)

  await expect(column.locator('.kanban-card')).toHaveCount(2)
})
```"

---

## 项目经验总结

### 踩过的坑

1. **iOS Safari 的触摸延迟** - 移动端拖拽有 300ms 延迟,加了 `touch-action: none` 解决

2. **拖拽预览在 Firefox 不显示** - Firefox 的 setDragImage 有 bug,改用绝对定位的 div 模拟预览

3. **跨 iframe 拖拽不工作** - 拖拽数据无法跨 iframe 传递,改用 postMessage 通信

4. **内存泄漏** - 拖拽结束后没有清理事件监听和元素引用,导致内存持续增长

### 性能数据

- 1000 个元素的列表拖拽:帧率 60fps
- 碰撞检测耗时:< 2ms(使用空间索引)
- 拖拽预览渲染:< 16ms(一帧时间内)
- 看板拖拽响应时间:< 50ms

### 可以吹的点

- 自研拖拽引擎,不依赖第三方库,灵活可控
- 支持 10+ 种拖拽场景,覆盖绝大部分业务需求
- 性能优异,大数据量拖拽仍流畅
- API 简洁易用,接入成本低
- 兼容主流浏览器和移动端
```text