html5 drag实现拖拽

1,599 阅读5分钟

HTML5 拖放 (DnD) API 可以实现拖动功能,下面具体看如何实现。

原理

对元素设置设置 draggable=true可以使元素支持拖动,并且通过dragstart、dragover、drop来实现简单的拖动功能。

<div class="grid">
  <div class="item" draggable="true">item 1</div>
  <div class="item" draggable="true">item 2</div>
  <div class="item" draggable="true">item 3</div>
  <div class="item" draggable="true">item 4</div>
  <div class="item" draggable="true">item 5</div>
  <div class="item" draggable="true">item 6</div>
</div>
.grid {
  display: grid;
  gap: 20px 80px;
  grid-template-columns: auto auto auto auto;
}
.item {
  border: 1px solid #2196f3;
  background-color: #d3eafd;
  border-radius: 5px;
  padding: 10px;
  cursor: move;
  user-select: none;
}
var items = document.querySelectorAll(".item");
var dragEl;
function handleDragStart() {
  dragEl = this;
}

function handleDragOver(e) {
  if (e.preventDefault) {
    e.preventDefault();
  }
  return false;
}

function handleDrop() {
  document.querySelector(".grid").insertBefore(dragEl, this);
}

items.forEach(function (item, index) {
  item.addEventListener("dragstart", handleDragStart);
  item.addEventListener("dragover", handleDragOver);
  item.addEventListener("drop", handleDrop);
});

效果: 这里使用insertBefore将拖动的元素放到目标元素前面。

优点

实现简单,性能好。

缺点

  1. 不能自定义跟随鼠标透明元素的样式
  2. 不能自定义设置拖拽过程中的鼠标指针
  3. 不支持触摸拖拽

优化

在实际项目中这么简单实现拖动肯定不行,需要对其细节效果进行优化。

拖动元素添加响应效果

拖动元素添加透明度来和其他元素进行区分。

var items = document.querySelectorAll(".item");
var dragEl;
function handleDragStart() {
  dragEl = this;
  this.style.opacity = "0.5";
}

function handleDragEnd() {
  this.style.opacity = "1";
}

function handleDragOver() {
  if (e.preventDefault) {
    e.preventDefault();
  }
  return false;
}

function handleDrop() {
  document.querySelector(".grid").insertBefore(dragEl, this);
}

items.forEach(function (item, index) {
  item.addEventListener("dragstart", handleDragStart);
  item.addEventListener("dragend", handleDragEnd);
  item.addEventListener("dragover", handleDragOver);
  item.addEventListener("drop", handleDrop);
});

效果:

拖动到其他元素添加响应效果

拖动到其他元素添加边框给以区分。

.grid {
  display: grid;
  gap: 20px 80px;
  grid-template-columns: auto auto auto auto;
}
.item {
  border: 1px solid #2196f3;
  background-color: #d3eafd;
  border-radius: 5px;
  padding: 10px;
  cursor: move;
  user-select: none;
  margin: 1px;
}
.enter {
  border: 2px solid #2196f3;
  margin: 0;
}
var items = document.querySelectorAll(".item");
var dragEl;
function handleDragStart() {
  dragEl = this;
  this.style.opacity = "0.5";
}

function handleDragEnd() {
  this.style.opacity = "1";
}

function handleDragOver(e) {
  if (e.preventDefault) {
    e.preventDefault();
  }
  return false;
}

function handleDrop() {
  document.querySelector(".grid").insertBefore(dragEl, this);
  this.classList.remove("enter");
}

function handleDragEnter() {
  if (this !== dragEl) {
    this.classList.add("enter");
  }
}

function handleDragLeave() {
  this.classList.remove("enter");
}

items.forEach(function (item, index) {
  item.addEventListener("dragstart", handleDragStart);
  item.addEventListener("dragend", handleDragEnd);
  item.addEventListener("dragover", handleDragOver);
  item.addEventListener("drop", handleDrop);
  item.addEventListener("dragenter", handleDragEnter);
  item.addEventListener("dragleave", handleDragLeave);
});

效果: 通过dragend和dragleave事件对元素添加样式。

自定义跟随鼠标透明元素样式

拖动时跟随鼠标指针的那个元素样式过分简陋,虽然HTML5 拖放 API并没有提供方法自定义跟随鼠标的元素样式,但可以用另一种方式去实现。可以利用setDragImage方法。

发生拖动时,从拖动目标 (dragstart事件触发的元素) 生成半透明图像,并在拖动过程中跟随鼠标指针。这个图片是自动创建的,你不需要自己去创建它。然而,如果想要设置为自定义图像,那么 DataTransfer.setDragImage() 方法就能派上用场。
--MDN

可以利用setDragImage设置透明图片,将拖动原生的跟随鼠标指针的那个元素去掉,然后自定一个元素来跟随鼠标移动。

在dragstart事件中setDragImage方法设置透明图片

var img = new Image();
img.src =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath /%3E%3C/svg%3E";
e.dataTransfer.setDragImage(img, 0, 0);

然后用cloneNode将元素复制一个新节点对象。

cloneDragEl = dragEl.cloneNode(true);

然后给新节点添加定位和设置位置最后添加到boy中。

cloneDragEl.classList.add("drag-over");
let targetOffset = dragEl.getClientRects();
dragElOffset = {
  left: e.clientX - targetOffset[0].left,
  top: e.clientY - targetOffset[0].top,
  width: targetOffset[0].width,
};
cloneDragEl.style.width = `${dragElOffset.width}px`;
cloneDragEl.style.transform = `translate3d(${targetOffset[0].left}px,${targetOffset[0].top}px,0)`;
document.body.appendChild(cloneDragEl);

在grid元素的dragover事件中根据鼠标的位置来设置复制元素的位置。

function handleGridDragOver(e) {
  if (e.preventDefault) {
    e.preventDefault();
  }
  if (cloneDragEl) {
    cloneDragEl.style.transform = `translate3d(${
      e.clientX - dragElOffset.left
    }px,${e.clientY - dragElOffset.top}px,0)`;
  }
  return false;
}

在dragend事件中复制元素移除。

function handleDragEnd() {
  this.style.opacity = "1";
  document.body.removeChild(cloneDragEl);
}

效果:

完整代码:

.grid {
  display: grid;
  gap: 20px 80px;
  grid-template-columns: auto auto auto auto;
}
.item {
  border: 1px solid #2196f3;
  background-color: #d3eafd;
  border-radius: 5px;
  padding: 10px;
  cursor: move;
  user-select: none;
  margin: 1px;
}
.enter {
  border: 2px solid #2196f3;
  margin: 0;
}
.drag-over {
  position: fixed;
  top: 0;
  left: 0;
  pointer-events: none;
  box-sizing: border-box;
}
var items = document.querySelectorAll(".item");
var dragEl = null;
var cloneDragEl = null;
function handleDragStart(e) {
  dragEl = this;
  this.style.opacity = "0.5";

  var img = new Image();
  img.src =
    "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath /%3E%3C/svg%3E";
  e.dataTransfer.setDragImage(img, 0, 0);

  cloneDragEl = dragEl.cloneNode(true);
  cloneDragEl.classList.add("drag-over");
  let targetOffset = dragEl.getClientRects();
  dragElOffset = {
    left: e.clientX - targetOffset[0].left,
    top: e.clientY - targetOffset[0].top,
    width: targetOffset[0].width,
  };
  cloneDragEl.style.width = `${dragElOffset.width}px`;
  cloneDragEl.style.transform = `translate3d(${targetOffset[0].left}px,${targetOffset[0].top}px,0)`;
  document.body.appendChild(cloneDragEl);
}

function handleDragEnd() {
  this.style.opacity = "1";
  document.body.removeChild(cloneDragEl);
}

function handleDragOver(e) {
  if (e.preventDefault) {
    e.preventDefault();
  }
  return false;
}

function handleDrop() {
  document.querySelector(".grid").insertBefore(dragEl, this);
  this.classList.remove("enter");
}

function handleDragEnter() {
  this.classList.add("enter");
}

function handleDragLeave() {
  this.classList.remove("enter");
}

function handleGridDragOver(e) {
  if (e.preventDefault) {
    e.preventDefault();
  }
  if (cloneDragEl) {
    cloneDragEl.style.transform = `translate3d(${
      e.clientX - dragElOffset.left
    }px,${e.clientY - dragElOffset.top}px,0)`;
  }
  return false;
}
items.forEach(function (item, index) {
  item.addEventListener("dragstart", handleDragStart);
  item.addEventListener("dragend", handleDragEnd);
  item.addEventListener("dragover", handleDragOver);
  item.addEventListener("drop", handleDrop);
  item.addEventListener("dragenter", handleDragEnter);
  item.addEventListener("dragleave", handleDragLeave);
});
document
  .querySelector(".grid")
  .addEventListener("dragover", handleGridDragOver);

这里鼠标跟随的元素需要设置样式 pointer-events: none;让元素不会触发鼠标事件。

设置拖拽过程中的鼠标指针

可以通过effectAllowed来设置拖拽过程中的鼠标指针。

e.dataTransfer.effectAllowed = 'move';

但只有预设的几个指针样式,自定义还没办法,知道的同学可以留言告知。

insertBefore方式有点生硬,加上动画效果

首先添加过渡属性。

.item {
  border: 1px solid #2196f3;
  background-color: #d3eafd;
  border-radius: 5px;
  padding: 10px;
  cursor: move;
  user-select: none;
  margin: 1px;
  transition: transform 0.3s;
}

在dragstart事件中把元素隐藏,并且记录元素的位置。

function handleDragStart(e) {
  var offset = dragEl.getClientRects();
  this.style.opacity = "0";
  targetOffset = {
    left: offset[0].left,
    top: offset[0].top,
  };
}

在dragenter事件当鼠标进入元素时触发,改变元素的transform移动到拖拽元素的位置上,也就是targetOffset记录的位置。

function handleDragEnter() {
  if (dragEl !== this) {
    this.classList.remove("drag");
    let offset = this.getClientRects();
    let translate = getTranslate(this);
    this.style.transform = `translate3d(${
      targetOffset.left - offset[0].left + translate.x.value
    }px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
    targetOffset = {
      left: offset[0].left,
      top: offset[0].top,
    };
  }
}

这里封装了一个getTranslate函数,getTranslate函数是用来获取元素的translate值,getClientRects获取的是元素在页面视图中的位置,translate是相对初始位置的偏移,获取元素的初始位置(getClientRects获取位置-translate的偏移)就要获取元素的translate偏移值。

function getTranslate(dom) {
  const attrs = dom.attributeStyleMap.get("transform");
  if (!attrs) return { x: { value: 0 }, y: { value: 0 } };

  const translation = Array.from(attrs.values()).find(
    (attr) => attr instanceof CSSTranslate
  );
  return translation;
}

这里通过attributeStyleMap来取元素的样式Map。

HTMLElement.attributeStyleMap 只读

一个StylePropertyMap,代表元素的样式属性的声明 --MDN

兼容性: Firefox不支持该属性。

dragend事件中将拖动元素定位到最后位置上。

function handleDragEnd() {
  let offset = this.getClientRects();
  let translate = getTranslate(this);
  this.style.transform = `translate3d(${
    targetOffset.left - offset[0].left + translate.x.value
  }px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
  this.style.opacity = "1";
  document.body.removeChild(cloneDragEl);
  dragEl = null;
  cloneDragEl = null;
}

完整代码:

.grid {
  display: grid;
  gap: 20px 80px;
  grid-template-columns: auto auto auto auto;
}

.item {
  border: 1px solid #2196f3;
  background-color: #d3eafd;
  border-radius: 5px;
  padding: 10px;
  cursor: move;
  user-select: none;
  margin: 1px;
  transition: transform 0.3s;
}

.enter {
  border: 2px solid #2196f3;
  margin: 0;
}

.drag-over {
  position: fixed;
  top: 0;
  left: 0;
  pointer-events: none;
  box-sizing: border-box;
  transition: none;
}
.drag {
  transition: none;
}
      var items = document.querySelectorAll(".item");
      var dragEl = null;
      var cloneDragEl = null;
      var targetOffset = null;

      function getTranslate(dom) {
        const attrs = dom.attributeStyleMap.get("transform");
        if (!attrs) return { x: { value: 0 }, y: { value: 0 } };

        const translation = Array.from(attrs.values()).find(
          (attr) => attr instanceof CSSTranslate
        );
        return translation;
      }
      function handleDragStart(e) {
        dragEl = this;
        this.classList.add("drag");
        cloneDragEl = dragEl.cloneNode(true);
        this.style.opacity = "0";

        var img = new Image();
        img.src =
          "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath /%3E%3C/svg%3E";
        e.dataTransfer.setDragImage(img, 0, 0);
        e.dataTransfer.effectAllowed = "move";

        cloneDragEl.classList.add("drag-over");
        var offset = dragEl.getClientRects();
        targetOffset = {
          left: offset[0].left,
          top: offset[0].top,
        };
        dragElOffset = {
          left: e.clientX - offset[0].left,
          top: e.clientY - offset[0].top,
          width: offset[0].width,
        };
        cloneDragEl.style.width = `${dragElOffset.width}px`;
        cloneDragEl.style.transform = `translate3d(${targetOffset.left}px,${targetOffset.top}px,0)`;
        document.body.appendChild(cloneDragEl);
      }

      function handleDragEnd() {
        let offset = this.getClientRects();
        let translate = getTranslate(this);
        this.style.transform = `translate3d(${
          targetOffset.left - offset[0].left + translate.x.value
        }px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
        this.style.opacity = "1";
        document.body.removeChild(cloneDragEl);
        dragEl = null;
        cloneDragEl = null;
      }

      function handleDragOver(e) {
        if (e.preventDefault) {
          e.preventDefault();
        }
        return false;
      }

      function handleGridDragOver(e) {
        if (e.preventDefault) {
          e.preventDefault();
        }
        if (cloneDragEl) {
          cloneDragEl.style.transform = `translate3d(${
            e.clientX - dragElOffset.left
          }px,${e.clientY - dragElOffset.top}px,0)`;
        }
        return false;
      }

      function handleDrop(e) {
        e.stopPropagation();
        document.body.removeChild(cloneDragEl);
        dragEl = null;
        cloneDragEl = null;
      }

      function handleDragEnter() {
        if (dragEl !== this) {
          this.classList.remove("drag");
          // this.classList.add("enter");
          let offset = this.getClientRects();
          let translate = getTranslate(this);
          this.style.transform = `translate3d(${
            targetOffset.left - offset[0].left + translate.x.value
          }px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
          targetOffset = {
            left: offset[0].left,
            top: offset[0].top,
          };
        }
      }

      items.forEach(function (item, index) {
        item.addEventListener("dragstart", handleDragStart);
        item.addEventListener("dragend", handleDragEnd);
        item.addEventListener("dragover", handleDragOver);
        item.addEventListener("drop", handleDrop);
        item.addEventListener("dragenter", handleDragEnter);
      });
      document
        .querySelector(".grid")
        .addEventListener("dragover", handleGridDragOver);

思考

除了上面的优化,还有很多优化的点,比如:

  • 如何获取最终的排序列表。
  • 性能上如何优化提高性能。
  • 封装成一个工具插件的话代码结构如何组织,功能配置项如何抽离等。

总结

本文主要是帮助理解拖拽的实现原理,后面会通过解读拖拽工具库源码来总结更多的优化角度。