我为弹窗设置了以下功能:
- 双击弹窗任意区域,即可关闭弹窗;
- 点击键盘的esc按键也可以关闭弹窗;
- 拖拽弹窗顶部区域可以拖动弹窗移动位置;
- 打开多个弹窗,可以保证每次最新打开的弹窗可以位于最上层,如果我点击置于后面的弹窗也可以将其优先级置于最上面。
如何实现以上功能?
1.双击关闭
html
<div @dblclick="handleDoubleClick">
弹窗1
</div>
为弹窗绑定 dbclick方法,表明双击弹窗之后会触发的方法:
javascript
const handleDoubleClick = (e) => {
closeDialog();
};
2.ESC关闭
javascript
onMounted(() => {
// 监听键盘 ESC
const handleKeyDown = (e) => {
if (e.key === 'Escape' || e.key === 'Esc') {
closeDialog();
}
};
window.addEventListener('keydown', handleKeyDown);
// 在卸载前移除监听
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
});
});
3.设置弹窗优先级
html
<div :style="{ zIndex: computedZIndex }">
弹窗1
</div>
我们为弹窗绑定动态 style 来实时更新它的层级。
这里我引入了状态管理库 pinia,因此下面我会使用全局的变量。
下面我定义了一个全局变量 maxZIndex,用于更新此时页面中优先级的最大值,并为最后一次操作的弹窗更新优先级的值。dialogList 为管理当前弹出的弹窗。
javascript
import { defineStore } from 'pinia'
export const useHotStore = defineStore('hot', {
state: () => {
return {
maxZIndex: 1000,
dialogList: [],
}
},
actions: {
getNewMaxZIndex() {
this.maxZIndex += 1
return this.maxZIndex
},
registerDialog(id, closeFn) { // 注册弹窗:挂载时调用,记录弹窗信息
// 找到当前弹窗的 zIndex(此时已通过 getNewMaxZIndex 赋值)
const zIndex = this.maxZIndex
this.dialogList.push({ id, zIndex, closeFn })
},
unregisterDialog(id) { // 注销弹窗:卸载时调用,移除弹窗信息
this.dialogList = this.dialogList.filter(dialog => dialog.id !== id)
},
closeTopDialog() { // 关闭最顶层弹窗:按 zIndex 倒序排序,取第一个关闭
if (this.dialogList.length === 0) return
// 按层级从高到低排序,取最顶层弹窗
const sorted = [...this.dialogList].sort((a, b) => b.zIndex - a.zIndex)
const topDialog = sorted[0]
// 执行弹窗的关闭方法
topDialog.closeFn()
}
},
})
closeTopDialog() 方法是要关闭最顶层的弹窗,也就是当我们按键盘上的 esc 时可以逐级关闭各个弹窗。
javascript
import { useHotStore } from '@/store/index.js'
const hotStore = useHotStore()
const computedZIndex = ref(hotStore.maxZIndex)
// 关键:每个弹窗都需要定义一个唯一的id作为标识
const dialogId = `hot-point-detail-${Date.now()}`
onMounted(() => { // closeDialog方法是我控制弹窗关闭的方法,这里不描述
computedZIndex.value = hotStore.getNewMaxZIndex()
hotStore.registerDialog(dialogId, closeDialog)
})
onBeforeUnmount(() => {
hotStore.unregisterDialog(dialogId)
})
4.拖动弹窗
html
<div class="dialog-drag-container" :style="{ zIndex: computedZIndex }" @dblclick="handleDoubleClick">
<img class="close-dialog-btn no-drag" src="close-btn.png" @click="closeDialog"/>
<div style="cursor: move;" v-drag>弹窗1</div>
</div>
我们为弹窗绑定了类名 dialog-drag-container,这是一个全局类名,之后我们无论定义几个弹窗,都需要绑定这个类名。
在内部我们有弹窗的主体区域,也有关闭按钮:
- 如果你想可以拖动这个弹窗的任意位置都可以改变位置,就在最外层的div上绑定 v-drag 指令。
- 如果你想在弹窗顶部区域去控制弹窗关闭,就在顶部定义一个10px高度的div,并为它绑定上 v-drag 指令。如果不想影响到关闭按钮,可以为这个标签绑定 no-drag 类名。
- 上面提到的 v-drag 指令是自己编写的 ,我们需要定义一个 drag.js 用于控制全局弹窗的拖动事件:
javascript
import { useHotStore } from '@/page/board/store/hot'
// 注册拖动指令:v-drag,绑定到弹窗的「可拖动区域」(如标题栏)
export const dragDirective = {
mounted(el, binding) {
const hotStore = useHotStore()
const dialogEl = el.closest('.dialog-drag-container') // 弹窗最外层容器(需所有弹窗统一类名)
if (!dialogEl) return console.warn('未找到弹窗容器,请给弹窗添加.dialog-container类')
let isDragging = false // 是否正在拖动
let startX = 0 // 鼠标按下时的X坐标
let startY = 0 // 鼠标按下时的Y坐标
let dialogLeft = 0 // 弹窗初始left值
let dialogTop = 0 // 弹窗初始top值
// 1. 鼠标按下:记录初始位置和状态
el.addEventListener('mousedown', (e) => {
// 仅左键拖动(排除右键/中键)
if (e.button !== 0) return
isDragging = true
// 记录鼠标按下时的坐标(相对于文档)
startX = e.clientX
startY = e.clientY
// 记录弹窗初始位置(若弹窗用fixed定位,直接取offsetLeft/offsetTop)
const dialogStyle = window.getComputedStyle(dialogEl)
dialogLeft = parseInt(dialogStyle.left) || 0
dialogTop = parseInt(dialogStyle.top) || 0
// 拖动开始:将当前弹窗层级设为最高
dialogEl.style.zIndex = hotStore.getNewMaxZIndex()
// 给文档绑定mousemove和mouseup(避免鼠标移出拖动区域后失效)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
})
// 2. 鼠标移动:计算弹窗新位置并更新
const handleMouseMove = (e) => {
if (!isDragging) return
// 计算鼠标移动的距离
const moveX = e.clientX - startX
const moveY = e.clientY - startY
// 计算弹窗新位置(避免移出视口,可选)
const newLeft = dialogLeft + moveX
const newTop = dialogTop + moveY
// 更新弹窗位置(弹窗需设置position: fixed/fixed)
dialogEl.style.left = `${newLeft}px`
dialogEl.style.top = `${newTop}px`
}
// 3. 鼠标松开:结束拖动,移除事件监听
const handleMouseUp = () => {
isDragging = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// 组件卸载时清除事件(避免内存泄漏)
el._unmountDrag = () => {
el.removeEventListener('mousedown', el._mousedownHandler)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
},
unmounted(el) {
// 组件卸载时清除事件
if (el._unmountDrag) el._unmountDrag()
}
}
// 在Vue中全局注册指令(main.js中引入)
export const registerDragDirective = (app) => {
app.directive('drag', dragDirective)
}
在拖动方法中也引用到了改变弹窗优先级的方法,这就做到了两者联动。
5.内部事件冒泡处理
我在弹窗中放了一个 video 播放器,这个播放器可以双击全屏,或者我连续点击两次"播放/暂停"按钮,就会导致事件冒泡,从而触发关闭弹窗的方法,应该如何处理?
html
<video ref="dialogVideoEl" class="dialog-video" playsinline preload="auto" controls @dblclick.stop @click.stop></video>
这里有三个关键点:
- 为标签绑定 @click.stop 和 @dbclick.stop 来阻止冒泡
- 必须是闭合的video标签,即 <video></video>,不能是单闭合
- 在最外层容器绑定的 @dblclick="handleDoubleClick" 事件中,增加如下处理:
javascript
const handleDoubleClick = (e) => {
if (e) {
const path = e.composedPath ? e.composedPath() : (e.path || []);
const foundVideoOrPlyr = path.some(node => {
try {
return node && (node.tagName === 'VIDEO' || (node.classList && node.classList.contains && node.classList.contains('plyr')));
} catch {
return false;
}
});
if (foundVideoOrPlyr) {
return;
}
}
closeDialog();
};