简历项目经验描述
版本1 - 适合初中级
开发通用拖拽组件,支持列表排序、看板拖拽等多种场景
- 基于 Pointer Events API 实现跨设备拖拽,兼容鼠标、触摸、触控笔
- 支持拖拽排序、跨列表拖拽、拖拽复制等 8+ 种交互模式
- 实现拖拽预览、吸附对齐、撤销重做等增强功能,用户体验提升 40%
版本2 - 适合高级
设计并实现企业级拖拽系统,支持复杂业务场景
- 自研拖拽引擎,通过碰撞检测、约束验证、状态管理等机制,支持 10+ 种复杂拖拽场景
- 实现类 Trello 看板系统,支持卡片拖拽、列拖拽、泳道拖拽,日活用户 5000+
- 开发可视化布局编辑器,支持组件拖拽、嵌套容器、参考线对齐,配置效率提升 5 倍
- 优化拖拽性能,通过节流、虚拟化、增量更新等手段,大数据量拖拽帧率稳定 60fps
版本3 - 适合架构方向
主导拖拽交互系统架构设计,建立统一的拖拽解决方案
- 抽象拖拽核心模型,支持声明式 API,新增拖拽场景开发时间从 3 天降至 4 小时
- 设计插件化架构,功能按需加载,基础库体积仅 8KB,完整功能 25KB
- 建立拖拽性能监控体系,自动识别性能瓶颈,优化建议准确率 90%
- 制定拖拽交互规范和可访问性标准,通过 WCAG 2.1 AA 级认证
面试标准回答话术
Q1: 你们的拖拽系统是怎么设计的?
标准回答
"我们的拖拽系统是从零实现的,没有用第三方库,主要考虑是灵活性和性能。
整个系统分三层架构:
底层是拖拽引擎,负责处理原始的拖拽事件。用的是 Pointer Events API,而不是传统的 mouse 和 touch 事件,因为 Pointer Events 统一了鼠标、触摸、触控笔的处理,兼容性更好。
核心流程是:
- pointerdown 时开始监听,记录初始位置和拖拽的元素
- pointermove 时计算偏移量,更新拖拽元素的位置
- pointerup 时结束拖拽,触发 drop 事件
这个过程要用 setPointerCapture 锁定指针,即使鼠标移出元素也能继续接收事件。
中间层是拖拽管理器,负责管理拖拽状态和可放置区域。每个可拖拽元素注册时,会记录它的 ID、类型、约束条件。每个可放置区域也要注册,说明接受哪种类型的拖拽。
拖拽过程中,管理器会实时做碰撞检测,判断拖拽元素是否在某个可放置区域上方。如果是,就高亮那个区域,给用户视觉反馈。
上层是业务组件,比如看板、排序列表、树形结构。这些组件通过声明式的 API 使用拖拽功能,不需要关心底层实现。
举个例子,一个简单的拖拽排序列表:
<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 让列平滑移动。
数据更新: 拖拽结束后,要更新数据结构。我们的数据是这样的:
{
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: 树形节点拖拽的难点是什么?
标准回答
"树形拖拽的难点主要是判断放置位置和更新树结构。
放置位置判断: 树形结构有三种放置位置:
- 作为某个节点的子节点(插入到 children)
- 作为某个节点的兄弟节点(插入到同一层级)
- 移动到空白区域(移动到根层级)
我的判断逻辑是:
- 如果拖拽到节点的中间区域,就作为子节点
- 如果拖拽到节点的上方或下方边缘,就作为兄弟节点
- 如果拖拽到容器空白区域,就作为根节点
视觉反馈很重要。我用了三种指示器:
- 子节点插入:在目标节点内部显示高亮背景
- 兄弟节点插入:在目标节点上方或下方显示一条插入线
- 根节点插入:在根层级显示插入线
约束检查: 不是所有拖拽都合法,要做约束检查:
- 不能拖到自己的子孙节点下(会形成环)
- 不能超过最大层级限制
- 某些节点可能不允许拖拽或不允许接收子节点
我在拖拽开始时,遍历拖拽节点的所有子孙节点,记录到一个集合里。拖拽过程中,如果目标节点在这个集合里,就禁止放置。
树结构更新: 拖拽结束后,要更新树的数据结构。这个比看板复杂,因为树可能嵌套很深,而且源节点和目标节点可能在不同的分支。
我用了两步操作:
- 从源位置删除节点(找到父节点,从它的 children 里移除)
- 在目标位置插入节点(找到目标父节点,插入到它的 children)
关键是要正确计算父节点和插入索引。我维护了一个 nodeMap,key 是节点 ID,value 是节点引用,可以快速找到任意节点。
动画效果: 树形拖拽的动画比较难做,因为节点的层级和位置都在变化。我的做法是:
- 拖拽开始时,记录节点的原始位置
- 拖拽结束时,计算节点的目标位置
- 用 FLIP 技术(First, Last, Invert, Play)实现平滑动画
具体来说,先让节点瞬移到目标位置,然后计算它从原始位置到目标位置的偏移量,用 transform 反向偏移,最后播放动画恢复到目标位置。
展开折叠的处理: 拖拽到折叠的节点上时,如果悬停超过 1 秒,自动展开节点,方便用户拖拽到更深的层级。这个用 setTimeout 实现,记录悬停的开始时间,超时就触发展开。
还有个细节是,展开节点后,要重新计算可放置区域,更新碰撞检测。"
Q5: 拖拽的性能如何优化?
标准回答
"拖拽的性能优化主要从几个方面入手:
1. 减少重绘和回流
拖拽过程中,最频繁的操作是更新拖拽元素的位置。如果用 left/top 定位,每次更新都会触发 reflow,性能很差。
我用 transform 代替 left/top:
element.style.transform = `translate(${x}px, ${y}px)`
transform 只触发 composite,不触发 layout 和 paint,性能好很多。
2. 事件节流
pointermove 事件触发频率很高,每秒可能几十次。如果每次都更新 DOM,CPU 会吃不消。
我用 requestAnimationFrame 做节流:
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: 拖拽数据如何持久化?
标准回答
"拖拽数据持久化有两种策略:实时保存和延迟保存。
实时保存: 每次拖拽结束,立即调用接口保存新的顺序或位置。优点是数据不会丢失,缺点是网络请求频繁,可能影响性能。
我用了乐观更新 + 队列的方案:
- 拖拽结束,立即更新本地数据和 UI
- 把保存请求加入队列,异步发送
- 如果请求失败,从队列里重试,最多 3 次
- 3 次都失败,提示用户并回滚数据
队列可以合并请求,比如用户连续拖拽多次,只发送最后一次的结果。
延迟保存: 用户操作过程中不保存,离开页面或点击保存按钮时才保存。优点是减少网络请求,缺点是可能丢失数据。
为了防止丢失,我做了本地缓存:
- 拖拽结束,保存数据到 localStorage
- 页面刷新,从 localStorage 恢复数据
- 用户主动保存,清空 localStorage
还监听 beforeunload 事件,如果有未保存的改动,弹窗提示用户。
版本冲突处理: 多人协作时,可能出现冲突。比如 A 和 B 同时编辑看板,A 移动了卡片,B 也移动了卡片,谁的改动生效?
我用了版本号机制:
- 每次加载数据,记录版本号
- 保存时,把版本号发给后端
- 后端检查版本号,如果不一致,拒绝保存
- 前端收到拒绝,重新加载数据,让用户重新操作
还有个方案是操作转换(Operational Transformation),类似 Google Docs 的协同编辑,但实现复杂,我们没有用。
数据格式: 持久化的数据要能还原拖拽状态。比如看板的数据:
{
\"columns\": [
{
\"id\": \"col1\",
\"order\": 0,
\"cards\": [
{ \"id\": \"card1\", \"order\": 0 },
{ \"id\": \"card2\", \"order\": 1 }
]
}
]
}
每个元素都有 order 字段,表示顺序。排序时只需要更新 order,不需要调整数组结构。
布局编辑器的数据:
{
\"components\": [
{
\"id\": \"comp1\",
\"type\": \"button\",
\"x\": 100,
\"y\": 200,
\"width\": 80,
\"height\": 40,
\"parentId\": null
}
]
}
每个组件的位置、大小、父组件都记录下来,可以完全还原布局。"
核心难点与解决方案
难点1: 跨容器拖拽的状态管理
问题描述: 拖拽元素从一个容器移动到另一个容器,涉及两个容器的状态更新,如何保证数据一致性?
解决方案
"跨容器拖拽的状态管理是个经典的状态同步问题。我用了单向数据流 + 事件总线的方案。
设计思路
- 全局拖拽状态 - 用一个全局的 store 管理拖拽状态,包括:
{
isDragging: false, // 是否正在拖拽
dragData: null, // 拖拽的数据
sourceContainer: null, // 源容器
targetContainer: null, // 目标容器
sourceIndex: null, // 源索引
targetIndex: null, // 目标索引
}
- 容器注册机制 - 每个可拖拽容器初始化时,在全局 store 注册:
const containerId = registerContainer({
id: 'list-1',
type: 'list',
onDrop: handleDrop,
canAccept: (data) => data.type === 'card'
})
- 拖拽生命周期:
开始拖拽(dragStart):
- 记录源容器和源索引
- 把拖拽数据存到全局状态
- 触发源容器的 onDragStart 事件
拖拽中(dragOver):
- 实时检测拖拽元素在哪个容器上方
- 调用目标容器的 canAccept 判断是否可以放置
- 如果可以,更新目标容器和目标索引,触发 onDragEnter
- 如果离开容器,触发 onDragLeave
结束拖拽(dragEnd):
- 如果有有效的目标容器,触发 drop 事件
- 调用源容器的 onRemove,从源位置删除数据
- 调用目标容器的 onAdd,在目标位置添加数据
- 清空全局拖拽状态
具体实现
// 拖拽管理器
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: 简化预览内容
不是把原始元素完整克隆,而是渲染一个简化版。比如拖拽卡片时,预览只显示标题和图标,不显示描述、标签、按钮这些次要信息。
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 上绘制。
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 个预览,而是渲染一个代表性的预览,显示数量即可。
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 在空闲时渲染完整内容。
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,开启硬件加速:
.drag-preview {
will-change: transform;
transform: translate3d(0, 0, 0);
opacity: 0.8;
pointer-events: none;
}
效果对比
- 优化前:拖拽复杂元素,帧率 30fps
- 优化后:拖拽相同元素,帧率 60fps
- 内存占用减少 60%"
难点3: 参考线对齐和吸附的算法
问题描述: 布局编辑器中,拖拽组件时如何检测是否接近其他组件的边界,并显示参考线和吸附?
解决方案
"参考线对齐的核心是几何计算和边界检测。
算法流程
- 获取所有可对齐的边界
遍历画布上的所有组件(除了正在拖拽的),提取它们的边界:
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 }
)
})
- 计算拖拽组件的边界
const draggingBounds = {
left: draggingX,
right: draggingX + draggingWidth,
centerX: draggingX + draggingWidth / 2,
top: draggingY,
bottom: draggingY + draggingHeight,
centerY: draggingY + draggingHeight / 2
}
- 检测接近的边界
对每种类型的边界,找出最接近的:
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'),
}
- 显示参考线
对每个检测到的对齐,显示一条参考线:
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 渲染:
<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>
- 吸附位置
如果检测到对齐,调整拖拽组件的位置:
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)`
优化
画布组件很多时,遍历所有组件检测对齐,性能会有问题。我做了两个优化:
- 空间分区 - 把画布分成网格,只检测拖拽组件附近网格的组件
- 节流 - 对齐检测用 requestAnimationFrame 节流,每帧最多检测一次
效果
- 100 个组件的画布,对齐检测耗时 < 2ms
- 参考线实时更新,无延迟感"
完整技术实现
1. 拖拽核心 Composable
// 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. 拖拽排序列表组件
<!-- 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. 看板拖拽组件
<!-- 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. 使用示例
<!-- 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: 拖拽在移动端需要注意什么?
"移动端主要注意三点:
- 事件兼容性 - 移动端用 touch 事件,PC 用 mouse 事件。我用 Pointer Events API 统一处理,兼容性更好。
- 滚动冲突 - 移动端拖拽时,容器可能会跟着滚动。我在 touchmove 时调用
e.preventDefault()阻止默认滚动,同时手动实现边缘自动滚动。 - 长按触发 - 移动端通常是长按才开始拖拽,避免和点击冲突。我用 setTimeout 实现,按住 300ms 后才开始拖拽。"
Q: 如何实现拖拽的撤销重做?
"我用命令模式实现撤销重做:
- 定义命令接口 - 每个操作是一个命令对象,有 execute 和 undo 方法
- 命令栈 - 维护两个栈,undoStack 记录已执行的命令,redoStack 记录已撤销的命令
- 执行命令 - 拖拽结束时,创建命令对象,调用 execute,push 到 undoStack
- 撤销 - 从 undoStack pop 出命令,调用 undo,push 到 redoStack
- 重做 - 从 redoStack pop 出命令,重新 execute,push 回 undoStack
代码示例:
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
- 持续滚动 - 用 requestAnimationFrame 循环,每帧滚动一点:
function autoScroll() {
if (shouldScroll) {
container.scrollTop += scrollSpeed
requestAnimationFrame(autoScroll)
}
}
- 停止滚动 - 鼠标离开边缘区域,或拖拽结束,停止滚动
还要注意边界,不能滚动超出容器范围。"
Q: 如何测试拖拽功能?
"拖拽功能的测试比较特殊,因为涉及鼠标事件模拟。
单元测试:
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:
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