使用vue3 实现个优雅的拖拽

前置知识

vue2 中常用的复用的方式

  • mixin(混入)
  • HOC
  • Renderless (Scoped Slots)

vue3 中的常用复用方式

  • Composition API
  • 自定义指令
  • Plugins

需求介绍

要求实现个可复用的拖拽逻辑,用户可以方便的使用,可拖拽,可设置边界。

技术选型

条条大路通罗马,我们有很多种方式来实现这个功能,本次选用vue3 组合式API 的写法,主要原因是我这种方式写的少,想练练;次要原因是这种方式较为优雅,可以按需引入,tree shaking 也很方便。

实现分析

首先需要考虑的是坐标系与拖动的坐标点取哪个点?
坐标系这里可以简单的使用网页的坐标,left 作为x轴的数值, right 作为 Y轴的数值。
拖动的坐标点就取元素的左上角的点,也方便定位
因为是左上角的点的话,取值的时候就不能取事件的 MouseEvent: clientXMouseEvent: clientY 直接作为坐标,不然就要求用户点击的时候只能点击左上角,不然一赋值,会导致元素直接偏移,体验就很差。这块我的解决方案是计算出来两者的差值,每次赋值的时候,将取出来的 clientXclientY 加上差值,就得到了左上角的坐标
其次看可拖拽这个点

拖拽的事件必定是从元素上开始的,然后考虑到会拖着移动,所以移动事件与结束事件应该是绑定在外层的限制元素上

最后看可设置边界这个点
简单的想个理想模型,左上角的点坐标应该大于等于限制范围的左上角坐标,右下角的点坐标应该小于等于限制范围的右下角的坐标。考虑到实际情况,可能存在限制范围小于拖拽元素的情况,这里如果出现这种情况,对他的处理就是将坐标设为左上角(尽可能覆盖限制范围)。
这边考虑到用户体验,打算支持2种输入,一种是符合直觉的直接传入坐标的范围(minX,miny,maxX,maxY),另一种DX友好的方式是直接传入范围对应的元素,直接在内部计算出来范围,并且监听对应的范围变化,来作为限制。

具体实现

1、求取鼠标点击点与左上角点的差值

1
2
3
4
5
6
7
8
9
10
11
// target 是指待移动的元素
  const start = (e: PointerEvent) => {
    const rect = target.value!.getBoundingClientRect();
    // 记录下点击的位置与左上角的偏差
    const pos = {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    }
    // 记下本次的偏差值
    pressedDelta.value = pos;
  }

Element.getBoundingClientRect() 方法返回一个 DOMRect 对象,其提供了元素的大小及其相对于视口的位置。

2、实现移动

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
// target 是指待移动的元素 draggingElement 是指外层的事件
// 移动开始
  const start = (e: PointerEvent) => {
    const rect = target.value!.getBoundingClientRect();
    // 记录下点击的位置与左上角的偏差
    const pos = {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    }
    pressedDelta.value = pos;
  }
  // 移动事件
  const move = (e: PointerEvent) => {
    // 如果没点击开始则不触发
    if (!pressedDelta.value) {
      return;
    }
    let { x, y } = position.value;
    // 鼠标移动到的位置减去偏差
    if (axis === 'x' || axis === 'both') {
      x = e.clientX - pressedDelta.value.x;
    }
    if (axis === 'y' || axis === 'both') {
      y = e.clientY - pressedDelta.value.y;
    }
    position.value = limitArea({ x, y });
  }
  // 移动结束
  const end = (e: PointerEvent) => {
    if (!pressedDelta.value) {
      return
    }
    pressedDelta.value = undefined;
  }
onMounted(
()=>{
target.value!.addEventListener("pointerdown", start);
draggingElement!.addEventListener("pointermove", move);
draggingElement!.addEventListener("pointerup", end);
}
)

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
// 要求不超过边界
  const limitArea = ({ x = position.value.x, y = position.value.y }: { x: number, y: number } = position.value) => {
    if (x < areaLimit.value.startX) {
      x = areaLimit.value.startX;
    }
    if (x > areaLimit.value.endX - size.value.width) {
      x = areaLimit.value.endX - size.value.width;
    }
    if (y < areaLimit.value.startY) {
      y = areaLimit.value.startY;
    }
    if (y > areaLimit.value.endY - size.value.height) {
      y = areaLimit.value.endY - size.value.height;
    }
    // 如果元素小于限制区域 将元素移动到左上角
    if(areaLimit.value.endY - areaLimit.value.startY < size.value.height){
      y = areaLimit.value.startY
    }
    if(areaLimit.value.endX - areaLimit.value.startX < size.value.width){
      x = areaLimit.value.startX
    }
    return {
      x, y
    }
  }

4、监听输入的限制元素的变化

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
let mo: MutationObserver;
 let re: ResizeObserver;
  // 更新相对位置

  const initWatch = () => {
    const limitdiv = unref(limitDOM) ? unref(limitDOM) : document.getElementById("app")
    const callback = () => {
      const { left, right, top, bottom } = limitdiv!.getBoundingClientRect()
      areaLimit.value = {
        startX: left,
        startY: top,
        endX: right,
        endY: bottom,
      }
      position.value = limitArea();
    };
    if (limitdiv) {
      mo = new MutationObserver(callback);
      mo.observe(limitdiv, {
        attributes: true,
      });
      re = new ResizeObserver(callback)
      re.observe(limitdiv)
      callback();
    }
  }

  onMounted(
    () => {
      initWatch()
    }
  )

// 凡事有开始就有结束,别忘记处理
  onBeforeUnmount(
    () => {
      if (mo) {
        mo?.disconnect()
      }
      if (re) {
        re?.disconnect()
      }
    }
  )

上面的使用 观察者 的这种方式性能会比较好点,当然可能有漏掉的情况,我这边时间原因没有怎么测试,但思路都是这样的。

最后来看下整体的效果

drag例子动图

以上是所有的代码(供参考)

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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
import { ref, onMounted, computed, unref, onBeforeUnmount } from "vue";

import type { Ref } from "vue";



export const defaultWindow = /*#__PURE__*/ window;





interface useDragOption {

  draggingElement?: any,

  draggingHandle?: any,

  initialValue?: any,

  axis?: 'x' | 'y' | 'both',

  limitDOM?: Ref<HTMLElement | null | undefined>,

}




// Composables

const useDrag = (target: Ref<HTMLElement | null>, options: useDragOption) => {

  const {

    draggingElement = defaultWindow,

    draggingHandle = target,

    initialValue,

    axis = 'both',

    limitDOM,

  } = options;



  const position = ref(

    initialValue ?? { x: 0, y: 0 },

  )



  const size = computed(

    () => {

      return {

        width: target?.value?.offsetWidth || 0,

        height: target?.value?.offsetHeight || 0,

      }

    }

  )





  // 移动的变化

  const pressedDelta = ref();



  onMounted(

    () => {

      // 判断是否有target

      if (!unref(target)) {

        console.warn("drag目标元素不存在,请检查!");

        return;

      }

      // 注册事件 此处可以搞个整体的事件,来提高一些性能,累了毁灭吧

      target.value!.addEventListener("pointerdown", start);

      draggingElement!.addEventListener("pointermove", move);

      draggingElement!.addEventListener("pointerup", end);



    }

  )



  onBeforeUnmount(

    () => {

      // 判断是否有target

      if (unref(target)) {

        // 取消事件

        target.value!.removeEventListener("pointerdown", start);

        draggingElement!.removeEventListener("pointermove", move);

        draggingElement!.removeEventListener("pointerup", end);

      }

    }

  )




  // 移动开始

  const start = (e: PointerEvent) => {

    const rect = target.value!.getBoundingClientRect();

    // 记录下点击的位置与左上角的偏差

    const pos = {

      x: e.clientX - rect.left,

      y: e.clientY - rect.top,

    }

    pressedDelta.value = pos;

  }



  // 移动事件

  const move = (e: PointerEvent) => {

    // 如果没点击开始则不触发

    if (!pressedDelta.value) {

      return;

    }

    let { x, y } = position.value;

    // 鼠标移动到的位置减去偏差

    if (axis === 'x' || axis === 'both') {

      x = e.clientX - pressedDelta.value.x;

    }

    if (axis === 'y' || axis === 'both') {

      y = e.clientY - pressedDelta.value.y;

    }

    position.value = limitArea({ x, y });

  }



  // 移动结束

  const end = (e: PointerEvent) => {

    if (!pressedDelta.value) {

      return

    }

    pressedDelta.value = undefined;

  }



  // 要求不超过边界

  const limitArea = ({ x = position.value.x, y = position.value.y }: { x: number, y: number } = position.value) => {

    if (x < areaLimit.value.startX) {

      x = areaLimit.value.startX;

    }

    if (x > areaLimit.value.endX - size.value.width) {

      x = areaLimit.value.endX - size.value.width;

    }

    if (y < areaLimit.value.startY) {

      y = areaLimit.value.startY;

    }

    if (y > areaLimit.value.endY - size.value.height) {

      y = areaLimit.value.endY - size.value.height;

    }




    // 如果元素小于限制区域 将元素移动到左上角

    if(areaLimit.value.endY - areaLimit.value.startY < size.value.height){

      y = areaLimit.value.startY

    }

    if(areaLimit.value.endX - areaLimit.value.startX < size.value.width){

      x = areaLimit.value.startX

    }



    return {

      x, y

    }

  }





  // 计算div的返回限制范围



  const areaLimit = ref(

    {

      startX: 0,

      startY: 0,

      endX: 500,

      endY: 500,

    }

  )

  let mo: MutationObserver;

  let re: ResizeObserver;

  // 更新相对位置

  const initWatch = () => {

    const limitdiv = unref(limitDOM) ? unref(limitDOM) : document.getElementById("app")

    const callback = () => {

      const { left, right, top, bottom } = limitdiv!.getBoundingClientRect()

      areaLimit.value = {

        startX: left,

        startY: top,

        endX: right,

        endY: bottom,

      }

      position.value = limitArea();

    };

    if (limitdiv) {

      mo = new MutationObserver(callback);

      mo.observe(limitdiv, {

        attributes: true,

      });

      re = new ResizeObserver(callback)

      re.observe(limitdiv)

      callback();

    }





  }

  onMounted(

    () => {

      initWatch()

    }

  )

  onBeforeUnmount(

    () => {

      if (mo) {

        mo?.disconnect()

      }

      if (re) {

        re?.disconnect()

      }

    }

  )





  return {

    draggingHandle,

    x: computed(() => position.value.x),

    y: computed(() => position.value.y),

    position,

    isDragging: computed(() => !!pressedDelta.value),

    style: computed(

      () => `left:${position.value.x}px;top:${position.value.y}px;`,

    )

  }

}

export { useDrag }
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
<script setup lang="ts">

import { shallowRef } from "vue";

import { useDrag } from "./use/drag"



const el = shallowRef()

const limit = shallowRef()

const { x, y, isDragging, style } = useDrag(el, { limitDOM: limit })



</script>



<template>

  <div>

    <div ref="limit" style="height:500px;width:100%;border: 1px solid yellow;">

      我是限制框

    </div>

    <div></div>

    <div ref="el" :style="style" style="position: fixed;border: 1px solid red;">

      <div style="width:100px;height:100px;">

        x:{{ x }}

        y:{{ y }}

        isDragging:{{ isDragging }}

      </div>



    </div>

  </div>

</template>



<style scoped></style>

使用vue3 实现个优雅的拖拽
https://blog.devgaoy.cn/2023/05/09/drag/
作者
knight.gao
发布于
2023年5月10日
许可协议