🚀 一、为什么需要这样的弹窗?
最近接到一个新需求,要求:点击功能按钮弹出弹窗,此时用户可与弹窗底部地图交互,并且弹窗可拖拽到任意位置,以便用户看到地图的其它位置并交互。主流组件库的 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 计算了鼠标移动的偏移量
dx
和dy
,然后累加到positionX
和positionY
上。因为这两个变量是响应式的,所以当它们变化时,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>
🧩 七、可拓展功能建议
比如:
- 支持最大化/最小化按钮
- 动画过渡效果
- 多语言支持
支持最大化/最小化、多语言支持在实际需求里是比较常用的,大家可以尝试实现,我就不做扩展啦~
🎯 总结:
我们实现了:可拖动的、支持穿透点击、支持插槽内容的弹窗,可在项目中复用该组件,且可根据需求继续扩展组件功能。 组件封装是提升前端工程化能力的关键点,也是前端开发者的必备技能,希望分享的东西能帮到需要的人,也希望大家能友好的提出改进意见,共同进步!后面会继续和大家分享有趣有意义的需求~