拖拽组件

一、元素位置变化的基础原理

元素移动的核心在于改变其在坐标系中的位置,大致思路是鼠标移动后的位置-鼠标移动前的位置 + 元素移动前的位置 = 元素移动后的位置,CSS 提供了多种改变位置的方式,以下是transform、position、margin这几种位置变化的方式、可能出现的问题以及使用比较

使用transform:translate

1const handleEle = document.getElementById('handler');
2    let isDragging = false;
3    let offsetX, offsetY;
4    let mouseBegin = {
5      x: undefined,
6      y: undefined
7    }
8    let eleBegin = {
9      x: undefined,
10      y: undefined
11    }
12    // 鼠标移动后的位置-鼠标移动前的位置 + 元素移动前的位置 = 元素移动后的位置
13    // 错误写法
14    handleEle.addEventListener('mousedown', (e) => {
15      isDragging = true;
16      const rect = handleEle.getBoundingClientRect();
17      mouseBegin.x = e.x;
18      mouseBegin.y = e.y;
19      eleBegin.x = rect.left;
20      eleBegin.y = rect.top;
21      console.log(eleBegin.x, eleBegin.y, mouseBegin);
22
23    });
24
25    document.addEventListener('mousemove', (e) => {
26      if (!isDragging) return;
27      offsetX = e.x - mouseBegin.x;
28      offsetY = e.y - mouseBegin.y;
29  
30      handleEle.style.transform = `translate(${eleBegin.x + offsetX}px, ${eleBegin.y + offsetY}px)`;
31    });
32
33    document.addEventListener('mouseup', () => {
34      isDragging = false;
35    });

可能出现的问题:

鼠标放上去后元素会向下抖动,如果增加一个元素效果更明显

1<div id="handler1">handler ele1</div>
2<div id="handler2">handler ele2</div>

问题原因:

CSS对象模型视图是连接CSS样式与JavaScript交互的核心接口,它定义了如何通过JavaScript访问和操作元素的视觉呈现属性,该模型包含多层次的坐标系体系。在元素拖拽移动中,坐标系是元素可以移动的基础,定义了translate变换的元素会创建一个新的局部坐标系,这个坐标系和原始坐标系在坐标系原点和定位基准等有所不同

  • getBoundingClientRect().left/top 获取的是元素在原始坐标系中的位置,不能获取在translate局部坐标系的位置

  • translate变换是相对于元素自身局部坐标系的位移,不是相对于文档左上角

正确实现方案eleBegin = { x: matrix.m41, y: matrix.m42 }

1handleEle.addEventListener('mousedown', (e) => {
2      isDragging = true;
3      const rect = handleEle.getBoundingClientRect();
4      mouseBegin.x = e.x;
5      mouseBegin.y = e.y;
6      const style = window.getComputedStyle(handleEle);
7      const matrix = new DOMMatrix(style.transform);
8     // 重点是这里
9      eleBegin = { x: matrix.m41, y: matrix.m42 };
10
11    });
12
13    document.addEventListener('mousemove', (e) => {
14      if (!isDragging) return;
15      offsetX = e.x - mouseBegin.x;
16      offsetY = e.y - mouseBegin.y;
17
18      handleEle.style.transform = `translate(${eleBegin.x + offsetX}px, ${eleBegin.y + offsetY}px)`;
19    });
20 document.addEventListener('mouseup', () => {
21      isDragging = false;
22    });

使用translate的特点:

  • 不会触发重排(reflow),只引发重绘(repaint)
  • 不影响文档流和其他元素布局
  • GPU加速,性能最佳
  • 坐标计算相对简单

使用position:absolute

1handleEle.addEventListener('mousedown', (e) => {
2      isDragging = true;
3      const pos = handleEle.getBoundingClientRect();
4      mouseBegin = { x: e.x, y: e.y };
5      eleBegin = {x: pos.left, y: pos.top };
6      console.log('eleBegin', eleBegin);
7    });
8
9    document.addEventListener('mousemove', (e) => {
10      if (!isDragging) return;
11      offsetX = e.x - mouseBegin.x;
12      offsetY = e.y - mouseBegin.y;
13      handleEle.style.left = `${eleBegin.x + offsetX}px`;
14      handleEle.style.top = `${eleBegin.y + offsetY}px`;
15    });

同理,positon为absolute时,他的坐标系是基于最近的定位元素来计算的,而不是基于浏览器视口的位置,解决办法可以是通过offsetLeft和offsetTop来获取定位元素的位置

1// CSS 需要预先设置
2// .draggable { position: absolute; }
3
4  handleEle.addEventListener('mousedown', (e) => {
5      isDragging = true;
6      mouseBegin = { x: e.x, y: e.y };
7      // 关键步骤
8      eleBegin = {x: handleEle.offsetLeft, y: handleEle.offsetTop };
9      console.log('eleBegin', eleBegin);
10    });
11
12    document.addEventListener('mousemove', (e) => {
13      if (!isDragging) return;
14      offsetX = e.x - mouseBegin.x;
15      offsetY = e.y - mouseBegin.y;
16      handleEle.style.left = `${eleBegin.x + offsetX}px`;
17      handleEle.style.top = `${eleBegin.y + offsetY}px`;
18    });

使用absolute特点:

  • 从文档流中脱离
  • 需要父元素有定位上下文(非static)
  • 会触发重排,性能中等
  • 坐标计算需要考虑offsetParent

使用margin

1<div id="handler1">handle1</div>
2<div id="handler2">handle2</div>
3<div>other ele</div>
1let isDragging = false;
2let startX, startY, startMarginLeft, startMarginTop;
3
4element.addEventListener('mousedown', (e) => {
5  isDragging = true;
6  startX = e.clientX;
7  startY = e.clientY;
8  startMarginLeft = parseInt(getComputedStyle(element).marginLeft) || 0;
9  startMarginTop = parseInt(getComputedStyle(element).marginTop) || 0;
10  e.preventDefault();
11});
12
13document.addEventListener('mousemove', (e) => {
14  if (!isDragging) return;
15  element.style.marginLeft = `${startMarginLeft + e.clientX - startX}px`;
16  element.style.marginTop = `${startMarginTop + e.clientY - startY}px`;
17});
18
19document.addEventListener('mouseup', () => {
20  isDragging = false;
21});

特点:

  • 保持在文档流中
  • 会影响周围元素布局
  • 性能最差(触发完整重排)
  • 实际项目中很少用于拖拽实现
  1. 布局流变化
  • 父元素尺寸变化
1.parent {
2  width: 80%; /* 视口变化时影响子元素 */
3}
  • 兄弟元素增删/尺寸变化
  1. 布局模式变化
  • display属性修改
1.element {
2  display: inline-block; /* 从block切换会改变文档流 */
3}
  • float浮动

脱离常规流但仍影响周围元素

1.element {
2  float: left; /* 导致后续元素环绕 */
3}
  1. 高级布局影响
  • flex/grid容器调整,容器属性变化导致子项重新分配空间
1.container {
2  justify-content: space-between; /* 修改后子项位置重计算 */
3}
  • order属性改变
1.element {
2  order: 2; /* 改变在Flex容器中的显示顺序 */
3}

2.4 三种方法对比

特性 transform: translate position: absolute margin
文档流影响 脱离 保持
性能 最佳(GPU加速) 中等 最差
坐标计算复杂度 中等 中等 简单
适用场景 现代UI、动画 传统拖拽 不推荐
层叠上下文 创建 可能创建 不创建
响应式布局兼容性 一般

结论

理解CSS坐标系是实现元素拖拽的基础,在改变元素位置时,要考虑是否在同一个坐标系。而 transform: translate 因其优异的性能和简洁的实现方式,已成为现代Web开发中实现拖拽功能的首选方案。对于需要精确控制层叠上下文或兼容旧浏览器的场景,可考虑使用position: absolute 方案,而margin方案由于其性能缺陷,在实际开发中应避免用于拖拽实现。