Vue实战:手把手教你封装一个可拖拽并支持穿透操作的弹窗组件

🚀 一、为什么需要这样的弹窗?

最近接到一个新需求,要求:点击功能按钮弹出弹窗,此时用户可与弹窗底部地图交互,并且弹窗可拖拽到任意位置,以便用户看到地图的其它位置并交互。主流组件库的 Modal 弹窗通常会遮挡页面内容,不允许用户操作底层元素,大多不支持拖动。于是,我决定自己封装一个 可拖动 + 可穿透点击的弹窗组件!

🎬 二、最终效果演示

功能亮点:

✅ 支持鼠标拖动

✅ 支持触摸屏拖动

✅ 点击弹窗外区域不影响底层操作(穿透点击)

✅ 支持自定义标题、宽度、插槽内容

🛠️ 三、技术选型说明

  • 组件使用 Vue 2 实现(因为维护的项目比较老,原理都是一样的,vue3、react都可参考实现该功能)
  • Vue 的 props 控制显隐状态
  • @mousedown / @mousemove / @mouseup 实现拖拽逻辑
  • pointer-events: none/auto 控制是否拦截点击事件
  • 使用 <slot> 插槽机制允许自由插入任意内容,提升组件灵活性
  • 使用 scoped 样式避免组件样式冲突,通过 ref 获取 DOM 节点进行位置控制

🧱 四、组件实现详解

1️⃣ 组件模板结构

  • ref="modal" 是 Vue 中用来给 DOM 元素或子组件注册一个引用标识。在这个组件中,它被绑定到了弹窗的容器 <div class="modal"> 上,然后通过动态绑定样式 :style 控制该 DOM 元素的位置和宽度。
js 复制代码
<template>
  <div v-if="visible" class="modal-overlay">
    <div 
      ref="modal"
      class="modal"
      @mousedown="startDrag"
      @touchstart="startDrag"
      @mouseup="stopDrag"
      @touchend="stopDrag"
      @mousemove="onDrag"
      @touchmove="onDrag"
      :style="{ top: positionY + 'px', left: positionX + 'px', width }"
    >
      <div class="title">
        <div>{{ title }}</div>
        <a-icon @click="close" type="close" />
      </div>
      <div class="content">
        <!-- 这里可以根据自身需求定义具名插槽,如header、footer等,此处只做案列展示,不使用具名插槽 -->
        <slot></slot>
      </div>
    </div>
  </div>
</template>

2️⃣ 数据与 props 定义

  • onDrag 计算了鼠标移动的偏移量 dxdy,然后累加到 positionXpositionY 上。因为这两个变量是响应式的,所以当它们变化时,Vue 自动重新渲染 DOM,也就是实现了弹窗的"拖动"效果。
kotlin 复制代码
<script>
export default {
  name: 'CanDragModal',
  props: {
    visible: { type: Boolean, default: false },
    title: { type: String, default: '标题' },
    width: { type: String, default: '500px' }
    ...
    // 这里的属性可根据需求自行扩展,比如事件、弹窗初始位置、底部等等...
  },
  data() {
    return {
      isDragging: false,
      lastCursorX: null,
      lastCursorY: null,
      positionX: 0,
      positionY: 0
    };
  },
  methods: {
    close() {
      this.$emit('update:visible', false);
    },
    startDrag(e) {
      this.isDragging = true;
      this.lastCursorX = e.clientX || e.touches[0].clientX;
      this.lastCursorY = e.clientY || e.touches[0].clientY;
    },
    stopDrag() {
      this.isDragging = false;
    },
    onDrag(e) {
      if (this.isDragging) {
        const dx = (e.clientX || e.touches[0].clientX) - this.lastCursorX;
        const dy = (e.clientY || e.touches[0].clientY) - this.lastCursorY;

        this.positionX += dx;
        this.positionY += dy;

        this.lastCursorX = e.clientX || e.touches[0].clientX;
        this.lastCursorY = e.clientY || e.touches[0].clientY;
      }
    }
  }
};
</script>

3️⃣ 样式部分(含穿透点击控制)

  • pointer-events: none;:穿透点击关键属性,不加这个样式则无法穿透
js 复制代码
<style scoped lang="less">
.modal-overlay {
  position: fixed;
  top: 10%; // 弹窗初始位置也可扩展为组件属性
  left: 50%; // 弹窗初始位置也可扩展为组件属性
  right: 0;
  bottom: 0;
  z-index: 999;
  cursor: pointer;
  pointer-events: none; /* 穿透点击关键属性,不加这个样式则无法穿透 */
}

.modal {
  position: absolute;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
  z-index: 1000;
  pointer-events: auto; /* 弹窗本身响应点击 */
  background-color: #001C34;

  .title {
    display: flex;
    justify-content: space-between;
    align-items: center;
    height: 32px;
    line-height: 32px;
    color: #fff;
    opacity: 0.7;
  }

  .content {
    color: #fff;
  }
}
</style>

✅ 完整代码:

xml 复制代码
<template>
  <div v-if="visible" class="modal-overlay">
    <div 
      ref="modal"
      class="modal"
      @mousedown="startDrag"
      @touchstart="startDrag"
      @mouseup="stopDrag"
      @touchend="stopDrag"
      @mousemove="onDrag"
      @touchmove="onDrag"
      :style="{ top: positionY + 'px', left: positionX + 'px', width, }"
    >
      <div class="title">
        <div>{{ title }}</div>
        <a-icon @click="close" type="close" />
      </div>
      <div class="content">
        <slot></slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CanDragModal',
  props: {
    visible: {
      type: Boolean,
      default: false
    },
    title: {
      type: String,
      default: '标题'
    },
    width: {
      type: String,
      default: '500px'
    },
  },
  data() {
    return {
      isDragging: false,
      lastCursorX: null,
      lastCursorY: null,
      positionX: 0,
      positionY: 0
    };
  },
  methods: {
    close() {
      this.$emit('update:visible', false);
    },
    startDrag(e) {
      this.isDragging = true;
      this.lastCursorX = e.clientX || e.touches[0].clientX;
      this.lastCursorY = e.clientY || e.touches[0].clientY;
    },
    stopDrag() {
      this.isDragging = false;
    },
    onDrag(e) {
      if (this.isDragging) {
        const dx = (e.clientX || e.touches[0].clientX) - this.lastCursorX;
        const dy = (e.clientY || e.touches[0].clientY) - this.lastCursorY;
        
        this.positionX += dx;
        this.positionY += dy;

        this.lastCursorX = e.clientX || e.touches[0].clientX;
        this.lastCursorY = e.clientY || e.touches[0].clientY;
      }
    }
  }
};
</script>

<style scoped lang=less>
.modal-overlay {
  position: fixed;
  top: 10%;
  left: 50%;
  right: 0;
  bottom: 0;
  z-index: 999;
  cursor: pointer;
  pointer-events: none; 
}

.modal {
  position: absolute;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
  z-index: 1000;
  pointer-events: auto;
  background-color: #001C34;
  .title {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: auto;
    height: 32px;
    line-height: 32px;
    text-align: left;
    color: #fff;
    opacity: 0.7;
  }
  .content {
    color: #fff;
  }
}
</style>

如果想进一步优化,限制弹窗不能拖出可视区域,可以添加边界判断逻辑

ini 复制代码
const maxX = window.innerWidth - this.$refs.modal.offsetWidth;
const maxY = window.innerHeight - this.$refs.modal.offsetHeight;

this.positionX = Math.min(Math.max(this.positionX, 0), maxX);
this.positionY = Math.min(Math.max(this.positionY, 0), maxY);

⚙️ 五、如何使用这个组件?

xml 复制代码
<template>
  <div>
    <button @click="showModal = true">打开弹窗</button>
    <CanDragModal
      v-model:visible="showModal"
      title="这是一个可以拖动的弹窗"
      width="600px"
    >
      <p>这里是弹窗内容,你可以自由拖动它。</p>
      <p>同时也可以点击弹窗外区域操作页面其他内容。</p>
      <p>这里可以根据自身需求定义具名插槽。</p>
    </CanDragModal>
  </div>
</template>

<script>
import CanDragModal from './components/CanDragModal.vue';

export default {
  components: { CanDragModal },
  data() {
    return {
      showModal: false
    };
  }
};
</script>

🧩 七、可拓展功能建议

比如:

  • 支持最大化/最小化按钮
  • 动画过渡效果
  • 多语言支持

支持最大化/最小化、多语言支持在实际需求里是比较常用的,大家可以尝试实现,我就不做扩展啦~

🎯 总结:

我们实现了:可拖动的、支持穿透点击、支持插槽内容的弹窗,可在项目中复用该组件,且可根据需求继续扩展组件功能。 组件封装是提升前端工程化能力的关键点,也是前端开发者的必备技能,希望分享的东西能帮到需要的人,也希望大家能友好的提出改进意见,共同进步!后面会继续和大家分享有趣有意义的需求~

相关推荐
独立开阀者_FwtCoder几秒前
Nginx 通过匹配 Cookie 将请求定向到特定服务器
java·vue.js·后端
哒哒哒52852024 分钟前
HTTP缓存
前端·面试
T___27 分钟前
从入门到放弃?带你重新认识 Headless UI
前端·设计模式
wordbaby28 分钟前
React Router 中调用 Actions 的三种方式详解
前端·react.js
黄丽萍34 分钟前
前端Vue3项目代码开发规范
前端
curdcv_po38 分钟前
🏄公司报销,培养我成一名 WebGL 工程师⛵️
前端
Jolyne_1 小时前
前端常用的树处理方法总结
前端·算法·面试
wordbaby1 小时前
后端的力量,前端的体验:React Router Server Action 的魔力
前端·react.js
Alang1 小时前
Mac Mini M4 16G 内存本地大模型性能横评:9 款模型实测对比
前端·llm·aigc
林太白1 小时前
Rust-连接数据库
前端·后端·rust