🎯 本章核心问题

如何实现流畅、直观的拖拽交互体验?

挑战 传统方案的痛点 我们的解决方案
事件丢失 鼠标移出元素后拖拽中断 监听 document 而非元素本身
位置错乱 像素坐标不整齐,组件重叠 Grid 网格吸附系统
性能卡顿 大量 DOM 操作导致掉帧 CSS transform + GPU 加速
状态同步 拖拽后刷新页面位置丢失 mouseup 即时持久化到数据库
碰撞问题 组件可以互相覆盖 规划:AABB 碰撞检测

📐 架构总览

大屏拖拽交互系统架构

大屏拖拽交互系统架构

核心模块

拖拽交互系统核心模块

事件处理、Grid、Composable 与 API 持久化


🖱️ 一、拖拽事件生命周期(3个阶段)

1.1 完整流程图解

拖拽三阶段时序

mousedown 初始化 → mousemove 实时更新 → mouseup 确认保存

1.2 为什么监听要绑定在 document 上?

经典 Bug 场景

1
2
3
4
5
6
7
// ❌ 错误做法:绑定在元素上
widget.addEventListener('mousedown', () => {
widget.addEventListener('mousemove', handleMove)
})

// 问题:当鼠标快速移出 widget 区域时,
// mousemove 事件不再触发 → 拖拽"卡住"
1
2
3
4
5
6
// ✅ 正确做法:绑定在 document 上
widget.addEventListener('mousedown', () => {
// 无论鼠标在哪里,都能捕获到事件
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleUp)
})

原理

事件的目标(target)监听者(listener) 是两个概念。
即使鼠标已经离开了 widget 元素,document 仍然能捕获到全局的 mousemove/mouseup 事件。


💻 二、核心代码实现:useDraggable Composable

composables/useDraggable.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import { ref, reactive, onUnmounted } from 'vue'

interface DragOptions {
widgetId: number
initialX: number
initialY: number
width: number // Widget 的 Grid 宽度
height: number // Widget 的 Grid 高度
gridSize: {
cellWidth: number // 每个 Grid 单元的像素宽度(如 80px)
cellHeight: number // 每个 Grid 单元的像素高度(如 80px)
cols: number // 总列数(12)
rows: number // 总行数(无限制)
}
onPositionChange: (x: number, y: number) => void // 回调:位置变化时调用
onCollision?: (collidingWidgetId: number) => void // 回调:碰撞时调用
}

interface DragState {
isDragging: boolean
startX: number // mousedown 时的屏幕 X 坐标
startY: number // mousedown 时的屏幕 Y 坐标
startGridX: number // mousedown 时的 Grid X
startGridY: number // mousedown 时的 Grid Y
currentGridX: number // 当前的 Grid X(实时更新)
currentGridY: number // 当前的 Grid Y(实时更新)
}

export function useDraggable(options: DragOptions) {
const {
widgetId,
initialX,
initialY,
width,
height,
gridSize,
onPositionChange,
onCollision,
} = options

const state = reactive<DragState>({
isDragging: false,
startX: 0,
startY: 0,
startGridX: initialX,
startGridY: initialY,
currentGridX: initialX,
currentGridY: initialY,
})

/**
* 边界约束函数
* 确保 Grid 坐标不超出有效范围
*/
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}

/**
* 像素坐标 → Grid 坐标转换
*/
function pixelToGrid(pixelX: number, pixelY: number): { x: number; y: number } {
const gridX = Math.round(pixelX / gridSize.cellWidth)
const gridY = Math.round(pixelY / gridSize.cellHeight)

return {
x: clamp(gridX + state.startGridX, 0, gridSize.cols - width),
y: clamp(gridY + state.startGridY, 0, Infinity), // Y 方向可以无限延伸
}
}

/**
* 阶段一:mousedown 处理
*/
function handleMouseDown(event: MouseEvent) {
// 只响应左键点击
if (event.button !== 0) return

event.preventDefault() // 防止选中文本

state.isDragging = true
state.startX = event.clientX
state.startY = event.clientY
state.startGridX = state.currentGridX
state.startGridY = state.currentGridY

// 🔑 关键:绑定到 document,而非当前元素
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)

// 添加拖拽中的样式类(用于视觉反馈)
;(event.target as HTMLElement).classList.add('dragging')
}

/**
* 阶段二:mousemove 处理(高频触发)
*/
function handleMouseMove(event: MouseEvent) {
if (!state.isDragging) return

// 计算 delta(相对于起点的偏移量)
const deltaX = event.clientX - state.startX
const deltaY = event.clientY - state.startY

// 转换为 Grid 坐标
const newCoords = pixelToGrid(deltaX, deltaY)

// 只有坐标真正改变时才更新(减少不必要的渲染)
if (
newCoords.x !== state.currentGridX ||
newCoords.y !== state.currentGridY
) {
// TODO: 这里可以添加碰撞检测逻辑
// if (checkCollision(newCoords)) {
// onCollision?.(collidingId)
// return
// }

state.currentGridX = newCoords.x
state.currentGridY = newCoords.y
}
}

/**
* 阶段三:mouseup 处理
*/
async function handleMouseUp(event: MouseEvent) {
if (!state.isDragging) return

state.isDragging = false

// 移除 document 监听器(防止内存泄漏)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)

// 移除拖拽样式
;(event.target as HTMLElement).classList.remove('dragging')

// 如果位置发生了变化,触发回调(保存到后端)
if (
state.currentGridX !== state.startGridX ||
state.currentGridY !== state.startGridY
) {
await onPositionChange(state.currentGridX, state.currentGridY)
}
}

// 组件卸载时清理(防止内存泄漏)
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})

return {
state,
handleMouseDown,
}
}

📐 三、Grid 布局系统详解

3.1 什么是 Grid 布局?

类比理解

就像 Excel 表格一样,将画布划分为 12 列 × N 行 的网格系统。
每个 Widget 占据若干个”单元格”,通过坐标 (x, y, w, h) 定位。

可视化示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
列:  0   1   2   3   4   5   6   7   8   9   10  11
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
行 0 │ Widget A (x=0,y=0,w=6,h=2) │ Widget B │
│ [今日销售额 KPI] │(x=6,y=0, │
├───┴───┴───┴───┴───┴───┼───┴───┴───┴───┤w=6,h=2) │
行 1 │ │[趋势折线] │
├───┬───┬───┬───┬───┬───┴───┬───┬───┬───┴───┴───┤
行 2 │ Widget C (x=0,y=2,w=12,h=3) │
│ [品类销售额 TOP10 柱状图] │
│ │
行 3 │ │
│ │
├───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┤
行 4 │ │
│ (空白区域,可放置新 Widget) │
...

3.2 坐标转换数学公式

像素 → Grid(用于拖拽时的实时计算)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 将屏幕像素坐标转换为 Grid 坐标
*
* @param pixelX 屏幕像素 X(相对于容器的 offset)
* @param pixelY 屏幕像素 Y
* @returns Grid 坐标 {x, y}
*
* 示例:
* cellWidth = 80px, gap = 16px
* pixelX = 176px
* → gridX = Math.round(176 / 96) = Math.round(1.83) = 2
*/
function pixelToGrid(
pixelX: number,
pixelY: number,
cellWidth: number = 80,
cellHeight: number = 80,
gap: number = 16
): { x: number; y: number } {
// 每个实际占用的宽度 = 单元格宽度 + 间距
const effectiveWidth = cellWidth + gap
const effectiveHeight = cellHeight + gap

return {
x: Math.round(pixelX / effectiveWidth),
y: Math.round(pixelY / effectiveHeight),
}
}

Grid → 像素(用于渲染定位)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 将 Grid 坐标转换为 CSS 像素值
*
* @param gridX Grid X 坐标
* @param gridY Grid Y 坐标
* @param width Grid 宽度(占几列)
* @param height Grid 高度(占几行)
* @returns CSS 样式对象
*/
function gridToPixel(
gridX: number,
gridY: number,
width: number,
height: number,
cellWidth: number = 80,
cellHeight: number = 80,
gap: number = 16
): React.CSSProperties {
return {
left: `${gridX * (cellWidth + gap)}px`,
top: `${gridY * (cellHeight + gap)}px`,
width: `${width * cellWidth + (width - 1) * gap}px`,
height: `${height * cellHeight + (height - 1) * gap}px`,
}
}

// 使用示例
const style = gridToPixel(x=2, y=1, width=6, height=2)
// → { left: '192px', top: '96px', width: '520px', height: '176px' }

3.3 Vue 模板中的应用

components/dashboard/WidgetWrapper.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
<template>
<div
class="widget-wrapper"
:class="{ dragging: state.isDragging }"
:style="widgetStyle"
@mousedown="handleMouseDown"
>
<!-- 缩放手柄(右下角) -->
<div
v-if="!state.isDragging"
class="resize-handle"
@mousedown.stop="handleResizeStart"
>⋔</div>

<!-- 插槽:实际的 Widget 内容 -->
<slot />
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useDraggable } from '@/composables/useDraggable'

const props = defineProps<{
widgetId: number
x: number
y: number
width: number
height: number
}>()

const emit = defineEmits<{
(e: 'positionChange', x: number, y: number): void
(e: 'resize', width: number, height: number): void
}>()

// 使用 useDraggable composable
const { state, handleMouseDown } = useDraggable({
widgetId: props.widgetId,
initialX: props.x,
initialY: props.y,
width: props.width,
height: props.height,
gridSize: {
cellWidth: 80,
cellHeight: 80,
cols: 12,
rows: Infinity,
},
onPositionChange: async (newX, newY) => {
// 即时保存到后端
emit('positionChange', newX, newY)
await fetch(`/api/widgets/${props.widgetId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: newX, y: newY }),
})
},
})

// 动态计算 CSS 样式
const widgetStyle = computed(() => ({
position: 'absolute' as const,
left: `${state.currentGridX * 96}px`, // 80 + 16(gap)
top: `${state.currentGridY * 96}px`,
width: `${props.width * 80 + (props.width - 1) * 16}px`,
height: `${props.height * 80 + (props.height - 1) * 16}px`,
// 🔥 关键:使用 transform 触发 GPU 加速
transform: state.isDragging
? `translate(${Math.random() * 0.01}px)` // 微小偏移强制重绘
: undefined,
zIndex: state.isDragging ? 1000 : 1, // 拖拽时提升层级
transition: state.isDragging ? 'none' : 'all 0.3s ease', // 拖拽时禁用动画
}))
</script>

<style scoped>
.widget-wrapper {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
cursor: grab;
user-select: none; /* 防止拖拽时选中文字 */
}

.widget-wrapper.dragging {
cursor: grabbing;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
opacity: 0.9;
}

.resize-handle {
position: absolute;
right: 4px;
bottom: 4px;
width: 16px;
height: 16px;
cursor: nwse-resize;
opacity: 0.5;
font-size: 14px;
line-height: 16px;
text-align: center;
}

.resize-handle:hover {
opacity: 1;
}
</style>

💥 四、碰撞检测算法(AABB)

4.1 为什么需要碰撞检测?

没有碰撞检测的问题

1
2
3
4
5
6
7
拖拽前:                    拖拽后(错误):
┌─────────┐ ┌─────────┐
│ Widget A │ │Widget A │ ← 重叠了!
└─────────┘ ├─────────┤┌─────────┐
┌─────────┐ │Widget A ││Widget B │
│ Widget B │ └─────────┘└─────────┘
└─────────┘

4.2 AABB(Axis-Aligned Bounding Box)算法

utils/collision.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
interface Rect {
x: number
y: number
width: number
height: number
}

/**
* AABB 碰撞检测
*
* 原理:如果两个矩形在任意轴上都没有重叠,则它们不相交。
* 反之,如果在所有轴上都重叠,则发生碰撞。
*
* 可视化:
* 不碰撞的情况(4种):
* 1. A 在 B 左边 2. A 在 B 右边
* ┌──┐ ┌──┐
* │A │ │B │
* └──┘ ┌──┐ ┌──┐ └──┘
* │B │ │A │
* └──┘ └──┘
*
* 3. A 在 B 上边 4. A 在 B 下边
* ┌──┐
* │A │
* └──┘
* ┌──┐ ┌──┐
* │B │ │B │
* └──┘ └──┘
* ┌──┐
* │A │
* └──┘
*/
export function isColliding(a: Rect, b: Rect): boolean {
// 如果满足以下任一条件,则肯定不碰撞
const noCollision =
a.x + a.width <= b.x || // A 的右边 ≤ B 的左边(A 在 B 左侧)
b.x + b.width <= a.x || // B 的右边 ≤ A 的左边(A 在 B 右侧)
a.y + a.height <= b.y || // A 的下边 ≤ B 的上边(A 在 B 上方)
b.y + b.height <= a.y // B 的下边 ≤ A 的上边(A 在 B 下方)

// 取反:只有以上条件都不满足时,才说明发生了碰撞
return !noCollision
}

/**
* 检测一个 Widget 是否与列表中任何其他 Widget 碰撞
*/
export function checkCollisionWithOthers(
target: Rect,
others: Array<{ id: number } & Rect>
): number | null {
for (const other of others) {
if (isColliding(target, other)) {
return other.id // 返回碰撞的 Widget ID
}
}
return null // 无碰撞
}

4.3 在拖拽中集成碰撞检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 在 useDraggable.ts 的 handleMouseMove 中添加
function handleMouseMove(event: MouseEvent) {
if (!state.isDragging) return

const deltaX = event.clientX - state.startX
const deltaY = event.clientY - state.startY
const newCoords = pixelToGrid(deltaX, deltaY)

// 构建目标矩形
const targetRect: Rect = {
x: newCoords.x,
y: newCoords.y,
width: options.width,
height: options.height,
}

// 获取其他所有 Widget 的位置(从 Pinia store 或 props)
const otherWidgets = dashboardStore.widgets
.filter(w => w.id !== widgetId)
.map(w => ({ id: w.id, x: w.x, y: w.y, width: w.width, height: w.height }))

// 碰撞检测
const collidingId = checkCollisionWithOthers(targetRect, otherWidgets)

if (collidingId) {
// 发生碰撞,可以选择:
// 选项 1:阻止移动(当前位置不变)
// 选项 2:交换位置(高级功能)
// 选项 3:显示警告但允许重叠(简单模式)

onCollision?.(collidingId)
return // 不更新坐标
}

// 无碰撞,正常更新
state.currentGridX = newCoords.x
state.currentGridY = newCoords.y
}

↔️ 五、缩放(Resize)功能实现

5.1 缩放手柄设计

每个 Widget 右下角显示一个特殊的拖拽区域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
<div class="resize-handle" @mousedown.stop="handleResizeStart">
⤡ <!-- 或使用 SVG 图标 -->
</div>
</template>

<style scoped>
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize; /* 斜向缩放光标 */
opacity: 0;
transition: opacity 0.2s;
}

.widget-wrapper:hover .resize-handle {
opacity: 0.6;
}

.resize-handle:hover {
opacity: 1;
background: linear-gradient(
135deg,
transparent 50%,
#1890ff 50%
); /* 对角线视觉效果 */
}
</style>

5.2 Resize 逻辑实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function handleResizeStart(event: MouseEvent) {
event.preventDefault()
event.stopPropagation() // 防止触发父级拖拽

const startX = event.clientX
const startY = event.clientY
const startWidth = props.width
const startHeight = props.height

const handleResizeMove = (e: MouseEvent) => {
const deltaX = e.clientX - startX
const deltaY = e.clientY - startY

// 像素 → Grid 单位
const deltaGridX = Math.round(deltaX / 96) // cellWidth + gap
const deltaGridY = Math.round(deltaY / 96)

// 应用最小尺寸限制
const newWidth = Math.max(2, startWidth + deltaGridX) // 最小 2 列
const newHeight = Math.max(1, startHeight + deltaGridY) // 最小 1 行

// 应用最大尺寸限制
const maxWidth = 12 - props.x // 不能超出右边界
const clampedWidth = Math.min(newWidth, maxWidth)

emit('resize', clampedWidth, newHeight)
}

const handleResizeEnd = () => {
document.removeEventListener('mousemove', handleResizeMove)
document.removeEventListener('mouseup', handleResizeEnd)

// 保存到后端
saveResizeToBackend()
}

document.addEventListener('mousemove', handleResizeMove)
document.addEventListener('mouseup', handleResizeEnd)
}

⚡ 六、性能优化策略

6.1 GPU 加速(关键优化)

1
2
3
4
5
6
7
8
9
10
11
12
13
.widget-wrapper {
/* ❌ 会导致整页 reflow/repaint */
/* left: 100px; top: 200px; */

/* ✅ 使用 transform 触发 GPU 合成层 */
will-change: transform; /* 提示浏览器提前优化 */
transform: translate3d(0, 0, 0); /* 强制 GPU 加速 */
}

/* 拖拽时禁用过渡动画(保证跟手性) */
.widget-wrapper.dragging {
transition: none !important;
}

为什么 transform 更快?

属性 触发操作 性能影响
top/left Layout(回流)+ Paint(重绘) ⚠️ 慢
transform Composite(合成) ✅ 快(仅 GPU 操作)

6.2 防抖与节流

1
2
3
4
5
6
import { throttle } from 'lodash-es'

// 使用 throttle 限制 mousemove 触发频率(~60fps 已足够)
const throttledMouseMove = throttle(handleMouseMove, 16) // 16ms ≈ 60fps

document.addEventListener('mousemove', throttledMouseMove)

6.3 虚拟化长列表

如果大屏有大量 Widget(>20 个),考虑使用虚拟滚动:

1
2
3
4
5
6
7
8
9
<!-- 只渲染可视区域内的 Widget -->
<RecycleScroller
:items="widgets"
:item-size="200"
key-field="id"
v-slot="{ item }"
>
<WidgetWrapper :widget="item" />
</RecycleScroller>

🎯 七、完整使用示例

7.1 在 Dashboard 页面中使用

views/dashboard/DashboardEditor.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<template>
<div class="dashboard-editor">
<!-- 工具栏 -->
<div class="toolbar">
<button @click="addWidget">+ 添加组件</button>
<button @click="saveLayout">💾 保存布局</button>
<span class="hint">拖拽组件调整位置,右下角可缩放</span>
</div>

<!-- Grid 画布 -->
<div
ref="canvasRef"
class="grid-canvas"
:style="{
gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
gridAutoRows: 'minmax(80px, auto)',
gap: '16px',
}"
>
<!-- 渲染所有 Widget -->
<WidgetWrapper
v-for="widget in widgets"
:key="widget.id"
:widget-id="widget.id"
:x="widget.x"
:y="widget.y"
:width="widget.width"
:height="widget.height"
@position-change="handlePositionChange"
@resize="handleResize"
>
<!-- 动态组件 -->
<component
:is="getComponentType(widget.type)"
:title="widget.title"
:data="widgetData[widget.id]"
/>
</WidgetWrapper>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import WidgetWrapper from '@/components/dashboard/WidgetWrapper.vue'
import KpiCard from '@/components/dashboard/KpiCard.vue'
import LineChart from '@/components/dashboard/LineChart.vue'

const canvasRef = ref<HTMLDivElement>()
const widgets = ref<any[]>([])
const widgetData = ref<Record<number, any>>({})

onMounted(async () => {
// 从 API 加载 Dashboard 配置
const res = await fetch(`/api/dashboards/${dashboardId}`)
const data = await res.json()
widgets.value = data.widgets

// 加载数据
await loadWidgetData()
})

async function handlePositionChange(widgetId: number, newX: number, newY: number) {
// 更新本地状态(乐观更新)
const widget = widgets.value.find(w => w.id === widgetId)
if (widget) {
widget.x = newX
widget.y = newY
}

// 后端已由 WidgetWrapper 内部处理,这里可以做额外逻辑
console.log(`Widget ${widgetId} moved to (${newX}, ${newY})`)
}

function getComponentType(type: string) {
const map: Record<string, any> = {
kpi_card: KpiCard,
line_chart: LineChart,
bar_chart: BarChart,
pie_chart: PieChart,
table: DataTable,
}
return map[type] || DataTable
}
</script>

<style scoped>
.grid-canvas {
display: grid;
min-height: 800px;
padding: 16px;
background-color: #f0f2f5;
background-image:
linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px);
background-size: 96px 96px; /* 80(cell) + 16(gap) */
border-radius: 8px;
position: relative;
}
</style>

🎯 八、最佳实践总结

✅ 我们做到了什么

  1. Composable 架构:将拖拽逻辑封装为 useDraggable(),高度复用
  2. Document 级监听:彻底解决鼠标移出元素后事件丢失的经典问题
  3. Grid 吸附系统:自动对齐到网格整数,保证界面整齐美观
  4. GPU 加速渲染:使用 CSS transform 实现 60fps 流畅拖拽
  5. AABB 碰撞检测:防止组件互相覆盖,O(n) 时间复杂度
  6. 即时持久化:mouseup 后立即保存到数据库,刷新不丢失
  7. 完整的缩放支持:右下角手柄 + 最小尺寸限制

📚 最佳实践清单

  • 使用 Composition API 封装可复用的拖拽逻辑
  • 事件监听绑定在 document 而非元素本身
  • 所有坐标计算基于 Grid 系统(而非绝对像素)
  • 使用 transform 替代 top/left 触发 GPU 加速
  • 拖拽时禁用 CSS transition 保证跟手性
  • 实现 AABB 碰撞检测算法
  • mouseup 时立即调用 API 保存新位置
  • 组件卸载时清理事件监听器(防内存泄漏)
  • 设置 user-select: none 防止拖拽时选中文本
  • 缩放设置最小尺寸限制(width ≥ 2, height ≥ 1)

🚀 进阶扩展方向

  1. 撤销/重做(Undo/Redo):维护操作历史栈,Ctrl+Z 撤销
  2. 吸附对齐(Snap to Edge):靠近其他 Widget 边缘时自动对齐
  3. 键盘快捷键:方向键微调位置,Delete 删除组件
  4. 多选拖拽:按住 Ctrl 点击多个组件,批量移动
  5. 响应式适配:根据屏幕尺寸动态调整 Grid 列数(桌面 12 列,平板 8 列,手机 4 列)

相关代码文件

最后一篇文章将深入 生产部署与性能优化 —— 异步架构设计、缓存策略、监控告警等运维核心内容!

敬请期待!🚀