轻松搞定拖拽移动!

1,154 阅读3分钟

功能演示

体验网址:Treasure-Navigation拖动演示.gif

github地址


基础结构

首先,我们来看看基本的 HTML 和 CSS 结构:

template: 部分

<template>
  <div
    class="draggable-container"
    :style="containerStyle"
  >
    <!-- 插槽内容 -->
    <slot></slot>
  </div>
</template>

CSS 样式

/* 拖拽容器的样式 */
.draggable-container {
  position: absolute; /* 绝对定位方便改动 left 和 top */
  cursor: move; /* 鼠标样式,显示拖拽效果 */
}

我们给容器添加了 position: absolute,这样就可以自由控制它的位置。
:style="containerStyle"容器的位置信息可以通过计算属性来控制lefttop,我们只要改变计算属性里的值会很方便

动态控制容器位置

容器的位置会通过 Vue 的 计算属性 动态更新,容器的位置信息可以方便地通过 containerStyle 来控制:

computed: {
    containerStyle() {
      return {
        top: `${this.position.top}px`,
        left: `${this.position.left}px`,
        width: `${this.width}px`,
        height: `${this.height}px`,
        zIndex: this.zIndex,
      };
    },
},

containerStyle 会根据 positionwidthheightzIndex 等值来动态计算并返回样式对象,从而实现拖动时实时更新容器的位置。

初始化位置

容器的位置通过 父组件传值 进行控制。这样,父组件可以灵活地设置容器的初始位置。

props: {
  left: { type: Number, default: 0 }, // 初始 X 位置
  top: { type: Number, default: 0 }, // 初始 Y 位置
  width: { type: Number, default: 300 }, // 初始宽度
  height: { type: Number, default: 300 }, // 初始高度
  zIndex: { type: Number, default: 1 }, // 层级
},

因为容器的位置会发生变化,所以 props 只能用作初始化值。我们需要将其复制到组件的 data 中,才修改left top

data() {
  return {
    position: { left: this.left, top: this.top }, // 容器的初始位置
  };
},

拖拽逻辑

📍 核心思路

拖动.jpg
  • 容器移动距离 = 鼠标移动距离
  • 鼠标移动距离 = 鼠标当前位置 - 鼠标初始位置

我们通过鼠标的位置得到容器的 新位置,并更新其 lefttop 属性。

  • 容器当前位置 = 鼠标当前位置 - 鼠标初始位置 + 容器初始位置
<div
    class="draggable-container"
    @mousedown="startDrag"
    :style="containerStyle"
>
data() {
    return {
      position: { left: this.left, top: this.top }, // 容器的位置,初始值为pros中的left和top
      originMouseX: 0, // 鼠标初始 X 坐标
      originMouseY: 0, // 鼠标初始 Y 坐标
      originContainX: 0, // 容器初始 X 坐标
      originContainY: 0, // 容器初始 Y 坐标
    };
},

// 拖拽开始
startDrag(event) {
  this.originMouseX = event.clientX; // 鼠标初始 X 坐标
  this.originMouseY = event.clientY; // 鼠标初始 Y 坐标
  this.originContainX = this.position.left; // 容器初始 X 坐标
  this.originContainY = this.position.top; // 容器初始 Y 坐标
  // 拖拽移动的时候需要改变容器位置,所以要监听move事件
  document.addEventListener("mousemove", this.handleDrag);
  // 鼠标松开,不再有拖拽效果
  document.addEventListener("mouseup", this.stopDrag);
},

// 拖拽过程: 容器当前位置 = 鼠标当前位置 - 鼠标初始位置 + 容器初始位置;
handleDrag(event) {
  this.position.left = event.clientX - this.originMouseX + this.originContainX;
  this.position.top = event.clientY - this.originMouseY + this.originContainY;
},

// 停止拖拽
stopDrag() {
  document.removeEventListener("mousemove", this.handleDrag);
  document.removeEventListener("mouseup", this.stopDrag);
},

解决定位问题

有时我们的拖拽元素可能处于某个父元素内,这时我们可能会受到父元素的影响。
我们可以通过 appendToBody 这个属性来决定是否将拖拽元素移到 body 中,以解决定位问题。

props: {
    appendToBody: { type: Boolean, default: false }, // 是否插入 body
},
// this.$el是当前组件最外层的html元素,即当前组件的根元素
mounted() {
    if (this.appendToBody) {
      document.body.appendChild(this.$el); // 将拖拽元素插入到 body
    }
},
beforeDestroy() {
    const $el = this.$el;
    if ($el.parentNode === document.body) {
      $el.parentNode.removeChild($el); // 移除元素
    }
},

父组件引用方式

<DraggableContainer :appendToBody="true" >
  <div>能拖动我了</div>
</DraggableContainer>

Vue 2 完整代码示例

<template>
  <div
    class="draggable-container"
    @mousedown="startDrag"
    :style="containerStyle"
  >
    <!-- 插槽内容 -->
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: {
    left: { type: Number, default: 0 }, // 初始 X 位置
    top: { type: Number, default: 0 }, // 初始 Y 位置
    width: { type: Number, default: 300 }, // 初始宽度
    height: { type: Number, default: 300 }, // 初始高度
    zIndex: { type: Number, default: 1 }, // 层级
    appendToBody: { type: Boolean, default: false }, // 是否插入 body
  },
  computed: {
    containerStyle() {
      return {
        top: `${this.position.top}px`,
        left: `${this.position.left}px`,
        width: `${this.width}px`,
        height: `${this.height}px`,
        zIndex: this.zIndex,
      };
    },
  },
  mounted() {
    if (this.appendToBody) {
      document.body.appendChild(this.$el);
    }
  },
  beforeDestroy() {
    const $el = this.$el;
    if ($el.parentNode === document.body) {
      $el.parentNode.removeChild($el);
    }
  },
  data() {
    return {
      position: { left: this.left, top: this.top }, // 容器的位置,初始值为pros中的left和top
      originMouseX: 0, // 鼠标初始 X 坐标
      originMouseY: 0, // 鼠标初始 Y 坐标
      originContainX: 0, // 容器初始 X 坐标
      originContainY: 0, // 容器初始 Y 坐标
    };
  },
  methods: {
    // 拖拽开始
    startDrag(event) {
      this.originMouseX = event.clientX; // 鼠标初始 X 坐标
      this.originMouseY = event.clientY; // 鼠标初始 Y 坐标
      this.originContainX = this.position.left; // 容器初始 X 坐标
      this.originContainY = this.position.top; // 容器初始 Y 坐标

      // 拖拽移动的时候需要改变容器位置,所以要监听move事件
      document.addEventListener("mousemove", this.handleDrag);
      // 鼠标松开,不再有拖拽效果
      document.addEventListener("mouseup", this.stopDrag);
    },

    // 拖拽过程: 容器当前位置 = 鼠标当前位置 - 鼠标初始位置 + 容器初始位置;
    handleDrag(event) {
      this.position.left =
        event.clientX - this.originMouseX + this.originContainX;
      this.position.top =
        event.clientY - this.originMouseY + this.originContainY;
    },

    // 停止拖拽
    stopDrag() {
      document.removeEventListener("mousemove", this.handleDrag);
      document.removeEventListener("mouseup", this.stopDrag);
    },
  },
};
</script>

<style>
/* 拖拽容器的样式 */
.draggable-container {
  position: absolute; /* 绝对定位方便改动 left 和 top */
  cursor: move; /* 鼠标样式 */
  user-select: none; /* 禁止选中文本 */
}
</style>


Vue 3 版本

在 Vue 3 中,我们可以使用 Teleport 组件将子组件直接传送到指定元素(如 body)中,从而避免父元素的定位问题。

<template>
  <Teleport to="body" :disabled="!appendToBody">
  <div class="draggable-container" @mousedown="startDrag" :style="containerStyle">
    <!-- 插槽内容 -->
    <slot></slot>
  </div>
  </Teleport>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';

// 定义 props 类型
const props = defineProps({
  left: { type: Number, default: 0 }, // 初始 X 位置
  top: { type: Number, default: 0 }, // 初始 Y 位置
  width: { type: Number, default: 300 }, // 初始宽度
  height: { type: Number, default: 300 }, // 初始高度
  zIndex: { type: Number, default: 1 }, // 层级
  appendToBody: { type: Boolean, default: false } // 是否插入 body
});

// 定义响应式变量
const position = ref({ left: 0, top: 0 }); // 容器位置
const originMouseX = ref(0); // 鼠标初始 X 坐标
const originMouseY = ref(0); // 鼠标初始 Y 坐标
const originContainX = ref(0); // 容器初始 X 坐标
const originContainY = ref(0); // 容器初始 Y 坐标

// 计算容器样式
const containerStyle = computed(() => ({
  top: `${position.value.top}px`,
  left: `${position.value.left}px`,
  width: `${props.width}px`,
  height: `${props.height}px`,
  zIndex: props.zIndex
}));

// 拖拽开始
const startDrag = (event: MouseEvent) => {
  originMouseX.value = event.clientX;
  originMouseY.value = event.clientY;
  originContainX.value = position.value.left;
  originContainY.value = position.value.top;

  // 拖拽过程中监听鼠标移动
  document.addEventListener('mousemove', handleDrag);
  // 鼠标松开时停止拖拽
  document.addEventListener('mouseup', stopDrag);
};

// 拖拽过程
const handleDrag = (event: MouseEvent) => {
  position.value.left = event.clientX - originMouseX.value + originContainX.value;
  position.value.top = event.clientY - originMouseY.value + originContainY.value;
};

// 停止拖拽
const stopDrag = () => {
  document.removeEventListener('mousemove', handleDrag);
  document.removeEventListener('mouseup', stopDrag);
};

</script>

<style scoped>
/* 拖拽容器的样式 */
.draggable-container {
  position: absolute; /* 绝对定位方便改动 left 和 top */
  cursor: move; /* 鼠标样式 */
  user-select: none; /* 禁止选中文本 */
}
</style>