目录
一、应用场景
二、开发流程
1.自定义指令
2.功能原理
3.难点
三、详细开发
四、总结
一、应用场景
我之前有开发过一个图片查看的组件,这个组件可在单页面打开,也可以在弹窗里打开,但是弹窗因为是比较固定,所以有一些局限性,只能拖拽,不能改变弹窗大小,于是有了开发【可以拖拽改变大小的弹窗】组件
原先的图片查看组件的博客地址:仿照elemenet-image的预览开发图片切换和放大缩小等功能_vue3 <el-image> 下方一行缩略图 可左右-CSDN博客
上方的组件实现效果如此:
- 目标:这次我需要实现的是满足上方的图片查看的功能(去掉底部的轮播图,弹窗不太需要),还需要满足弹窗的拖拽边框可以改变弹窗大小,并且弹窗的顶部可以被拖拽着移动位置。
- 实现方案:因为我是使用的是el-dialog,所以本身弹窗就可以拖拽,只是不能被手动改变大小,查找了一些解决方案后,于是借助一些思想,实现了一个自定义指令,期间踩了一些坑。
- 实现功能:
- 拖动弹窗:通过拖动弹窗的头部 (
.el-dialog__header
),可以在页面上自由移动弹窗的位置。鼠标按下头部并拖动时,会实时更新弹窗的位置。但是不能移出左侧和上侧视图范围,这部分也在下面的调整大小里有限制。双击全屏/还原:双击弹窗的头部,可以在全屏和恢复到之前的大小和位置之间切换。全屏状态下,弹窗覆盖整个视口,头部不可拖动。再次双击会恢复到初始的大小和位置。
调整大小:
- 右下角调整:通过右下角的一个小区域 (
se-resize
),可以拖动调整弹窗的宽度和高度,同时保持最小宽度和高度限制。- 左右侧调整:通过左右两侧的小区域 (
w-resize
),可以水平调整弹窗的宽度。同样,宽度不能小于设定的最小值。- 下侧调整:通过下边的小区域 (
n-resize
),可以垂直调整弹窗的高度,高度不能小于设定的最小值
4.实现效果:
二、开发流程
这里对创建自定义指令做一些简单介绍
1.自定义指令
首先需要了解以下知识:
Vue 3 的自定义指令提供了一些生命周期钩子,用于在指令应用到元素的不同阶段执行特定的操作:
beforeMount
:指令绑定到元素并插入父节点之前调用。mounted
:指令绑定到元素后调用。beforeUpdate
:指令所在组件的 VNode 更新之前调用。updated
:指令所在组件的 VNode 更新之后调用。beforeUnmount
:指令所在组件销毁之前调用。unmounted
:指令绑定的元素移出 DOM 之后调用。简单的示例:
// 在 main.js 中注册全局自定义指令
import { createApp } from 'vue';
import App from './App.vue';const app = createApp(App);
app.directive('focus', {
mounted(el) {
el.focus();
}
});app.mount('#app');
使用:
<template> <input v-focus /> </template>
我在开发过程中反复测试,得出我们在updated进行绑定就行,这样才能保证弹窗的创建和绑定。
如果需要实现一个可以拖拽改变弹窗大小的指令,那么首先建立一个文件夹,如下:
这里先不讨论dialog.js的具体内容,先创建如下的内容:
// src\directive\index.js
import drag from './dialog'
export default function (app) {app.directive('dialogDrag', drag)
}// src\directive\dialog.jsexport const dialogDrag = (el, binding, vnode, oldVnode) => {//这里是补充逻辑的地方
}export default {updated(el, binding, vnode, prevVnode) {dialogDrag(el, binding, vnode, prevVnode)}
}
2.功能原理
为了实现我想要的功能,可以通过 JavaScript 操作 DOM 元素的样式和事件监听器,来实现拖拽拉伸移动等等,开发前,先进行三项功能的原理整理:
1. 弹窗拖拽功能——通过拖动弹窗的标题栏来移动整个弹窗的位置
- 通过
el.querySelector('.el-dialog__header')
获取弹窗的标题栏元素(.el-dialog__header
)。- 设置标题栏的
cursor
为move
,提示用户可以拖动该区域。- 通过
mousedown
事件监听用户按下鼠标的动作,计算并记录鼠标点击位置与弹窗左上角的偏移量。- 当鼠标移动时,通过
mousemove
事件更新弹窗的位置,使其跟随鼠标移动。在mouseup
事件中移除mousemove
和mouseup
事件监听,以终止拖拽操作。2. 弹窗拉伸功能——通过拖动弹窗的边缘或角落来调整弹窗的尺寸。
- 在弹窗的右下角(
se-resize
)、右边(w-resize
)、左边(w-resize
)、下边(n-resize
)分别添加拉伸控制块,这些控制块是通过document.createElement('div')
动态创建并插入到弹窗中。- 每个控制块绑定一个
mousedown
事件,用于监听用户的拉伸操作。根据用户鼠标移动的方向,计算弹窗的宽度或高度变化,并更新弹窗的width
和height
样式属性。- 拉伸结束时,通过
mouseup
事件移除mousemove
和mouseup
事件监听,停止尺寸调整操作。3. 双击全屏与还原——通过双击弹窗的标题栏实现弹窗全屏和还原
- 双击标题栏时(
dblclick
事件),根据当前弹窗是否全屏状态(由isFullScreen
标志控制)执行全屏或还原操作。- 全屏时,将弹窗的位置和尺寸调整为占满整个视窗(
100VW
,100VH
),并移除标题栏的拖拽功能。- 还原时,恢复弹窗到全屏前的尺寸和位置,并重新启用标题栏的拖拽功能。
3.难点
- 同步尺寸和位置:在拖拽或拉伸时,需要实时同步弹窗的位置和尺寸,这涉及到对鼠标移动的精确跟踪,并处理弹窗在不同浏览器窗口尺寸下的表现。
- 边界处理:在拖拽和拉伸时,防止弹窗超出窗口的可视区域,尤其是避免标题栏被拖出窗口顶部。
- 多方向拉伸的冲突处理:在实现多方向拉伸时,确保各方向的拉伸控制块不会互相冲突。例如,右下角的拉伸控制块涉及同时调整宽度和高度,需要正确处理与单方向拉伸控制块之间的优先级问题。
三、详细开发
注意!我先写踩坑的点,如下:
第一步,我们要找到我们需要在哪里使用,我的应用场景就是在弹窗的地方使用,所以我就想定义一个弹窗的指令,理想的情况是这样的:
<el-dialogv-model="dialogVisible"v-dialogDrag ///这里width="50%"top="0vh":z-index="2080":modal="false":close-on-click-modal="false"modal-class="dialog_class"><div class="image-view-container"><ImageView :url="dialogImageUrl" style="width: 100%" @changeImage="changeImage" /></div></el-dialog>
然后在指令里去写获取当前弹窗的DOM,比如这样:
export const dialogDrag = (el, binding, vnode, oldVnode) => {const dialogElement = el.querySelector('.el-dialog')// console.log(dialogElement) // 这里是 el-dialog 元素的 DOMif (!dialogElement) {return}
}
就会发现怎么也获取不到当前的dom。
我一开始以为我是钩子时机不对,updated
钩子可能会在元素还未完全渲染时触发,这可能导致无法获取到子元素。所以为了确保 DOM 结构已经完全渲染,尝试使用 mounted
钩子,结果也一样,然后我尝试用我常用的方法:nextTick,也无法实现……于是第一步就卡在这里了。
问题就在于:el-dialog
组件可能还未完全渲染完成,无法正确获取到 DOM 元素。
当然可以通过添加一些调试信息,检查 el-dialog
是否确实存在,我在写的过程中,确实这样写无法实现。
出现这样的问题:
所以经过多次调试,我选择了这样的方式:
<div v-dialogDrag class="image-view"><el-dialogv-model="dialogVisible"width="50%"top="0vh":z-index="2080":modal="false":close-on-click-modal="false"modal-class="dialog_class"><div class="image-view-container"><ImageView :url="dialogImageUrl" style="width: 100%" @changeImage="changeImage" /></div></el-dialog></div>
那么具体的指令的代码如下:
export const dialogDrag = (el, binding, vnode, oldVnode) => {const dialogElement = el.querySelector('.el-dialog')// console.log(dialogElement) // 这里是 el-dialog 元素的 DOMif (!dialogElement) {return}//弹框可拉伸最小宽高let minWidth = 400let minHeight = 400//初始非全屏let isFullScreen = false//当前宽高let nowWidth = 0let nowHight = 0//当前顶部高度let nowMarginTop = 0//获取弹框头部(这部分可双击全屏)const dialogHeaderEl = el.querySelector('.el-dialog__header')//弹窗const dragDom = el.querySelector('.el-dialog')//弹窗bodyconst dialogBodyEl = el.querySelector('.el-dialog__body')// 设置body的最小高宽dialogBodyEl.style.minWidth = minWidth - 5 + 'px'dialogBodyEl.style.minHeight = minHeight - 100 + 'px'dialogBodyEl.style.height = '100%'//给弹窗加上overflow auto;不然缩小时框内的标签可能超出dialog;dragDom.style.overflow = 'auto'//清除选择头部文字效果dialogHeaderEl.onselectstart = new Function('return false')//头部加上可拖动cursordialogHeaderEl.style.cursor = 'move'// 获取原有属性 ie dom元素.currentStyle 火狐谷歌 window.getComputedStyle(dom元素, null);const sty = dragDom.currentStyle || window.getComputedStyle(dragDom, null)let moveDown = (e) => {// 鼠标按下,计算当前元素距离可视区的距离const disX = e.clientX - dialogHeaderEl.offsetLeftconst disY = e.clientY - dialogHeaderEl.offsetTop// 计算弹窗样式中的 --el-dialog-margin-top 值const dialogStyles = window.getComputedStyle(dragDom)const marginTopVh = parseFloat(dialogStyles.getPropertyValue('--el-dialog-margin-top'))// 计算初始弹窗顶部相对于可视区域顶部的偏移量const dialogMarginTopPx = window.innerHeight * (marginTopVh / 100)const initialTop = dialogMarginTopPx// 获取初始弹窗距离窗口左侧的距离const dialogMarginLeft = getComputedStyle(dragDom).marginLeftconst initialLeft = parseFloat(dialogMarginLeft)// 获取到的值带px 正则匹配替换let styL, styT// 注意在ie中 第一次获取到的值为组件自带50% 移动之后赋值为pxif (sty.left.includes('%')) {styL = +document.body.clientWidth * (+sty.left.replace(/%/g, '') / 100)styT = +document.body.clientHeight * (+sty.top.replace(/%/g, '') / 100)} else {styL = +sty.left.replace(/px/g, '')styT = +sty.top.replace(/px/g, '')}document.onmousemove = function (e) {// 通过事件委托,计算移动的距离const l = e.clientX - disXconst t = e.clientY - disY// 计算弹窗的左边界,不能超过窗口的左侧const minLeft = -initialLeft// 控制弹窗的左边界const left = Math.max(minLeft, l + styL)// 移动当前元素dragDom.style.left = `${left}px`dragDom.style.top = `${Math.max(-initialTop, t + styT)}px` //确保了拖拽过程中弹窗头部不会超出窗口的顶部//将此时的位置传出去//binding.value({x:e.pageX,y:e.pageY})}document.onmouseup = function (e) {document.onmousemove = nulldocument.onmouseup = null}}dialogHeaderEl.onmousedown = moveDown//双击(头部)效果不想要可以注释dialogHeaderEl.ondblclick = (e) => {if (isFullScreen == false) {nowHight = dragDom.clientHeightnowWidth = dragDom.clientWidthnowMarginTop = dragDom.style.marginTopdragDom.style.left = 0dragDom.style.top = 0dragDom.style.height = '100VH'dragDom.style.width = '100VW'dragDom.style.marginTop = 0dragDom.style.marginBottom = 0isFullScreen = truedialogHeaderEl.style.cursor = 'initial'dialogHeaderEl.onmousedown = null} else {dragDom.style.height = 'auto'dragDom.style.width = nowWidth + 'px'dragDom.style.marginTop = nowMarginTopisFullScreen = falsedialogHeaderEl.style.cursor = 'move'dialogHeaderEl.onmousedown = moveDown}}//拉伸右下方let resizeEl = document.createElement('div')dragDom.appendChild(resizeEl)//在弹窗右下角加上一个10-10px的控制块resizeEl.style.cursor = 'se-resize'resizeEl.style.position = 'absolute'resizeEl.style.height = '10px'resizeEl.style.width = '10px'resizeEl.style.right = '0px'resizeEl.style.bottom = '0px'resizeEl.style.zIndex = '99'//鼠标拉伸弹窗resizeEl.onmousedown = (e) => {// 记录初始x位置let startX = e.clientX// 鼠标按下,计算当前元素距离可视区的距离let disX = e.clientX - resizeEl.offsetLeftlet disY = e.clientY - resizeEl.offsetTopdocument.onmousemove = function (e) {e.preventDefault() // 移动时禁用默认事件// 通过事件委托,计算移动的距离//这里 由于elementUI的dialog控制居中的,所以水平拉伸效果是双倍//比较最小宽高和现在的宽高的大小,取大值dragDom.style.width = `${Math.max(minWidth, e.clientX - disX + (e.clientX - startX))}px`dragDom.style.height = `${Math.max(minHeight, e.clientY - disY)}px`}//拉伸结束document.onmouseup = function (e) {document.onmousemove = nulldocument.onmouseup = null}}//拉伸右边let resizeElR = document.createElement('div')dragDom.appendChild(resizeElR)//在弹窗右下角加上一个10-10px的控制块resizeElR.style.cursor = 'w-resize'resizeElR.style.position = 'absolute'resizeElR.style.height = '100%'resizeElR.style.width = '10px'resizeElR.style.right = '0px'resizeElR.style.top = '0px'//鼠标拉伸弹窗resizeElR.onmousedown = (e) => {let elW = dragDom.clientWidthlet initialOffsetLeft = dragDom.offsetLeft// 记录初始x位置let startX = e.clientXdocument.onmousemove = function (e) {e.preventDefault() // 移动时禁用默认事件//右侧鼠标拖拽位置if (startX > initialOffsetLeft + elW - 20 && startX < initialOffsetLeft + elW) {//往左拖拽if (startX > e.clientX) {dragDom.style.width = `${Math.max(minWidth, elW - (startX - e.clientX) * 2)}px`}//往右拖拽if (startX < e.clientX) {dragDom.style.width = `${elW + (e.clientX - startX) * 2}px`}}}//拉伸结束document.onmouseup = function (e) {document.onmousemove = nulldocument.onmouseup = null}}//拉伸左边let resizeElL = document.createElement('div')dragDom.appendChild(resizeElL)//在弹窗右下角加上一个10-10px的控制块resizeElL.style.cursor = 'w-resize'resizeElL.style.position = 'absolute'resizeElL.style.height = '100%'resizeElL.style.width = '10px'resizeElL.style.left = '0px'resizeElL.style.top = '0px'//鼠标拉伸弹窗resizeElL.onmousedown = (e) => {let elW = dragDom.clientWidthlet initialOffsetLeft = dragDom.offsetLeft// 记录初始x位置let startX = e.clientXdocument.onmousemove = function (e) {e.preventDefault() // 移动时禁用默认事件//左侧鼠标拖拽位置if (startX > initialOffsetLeft && startX < initialOffsetLeft + 20) {//往左拖拽if (startX > e.clientX) {dragDom.style.width = `${elW + (startX - e.clientX) * 2}px`}//往右拖拽if (startX < e.clientX) {dragDom.style.width = `${Math.max(minWidth, elW - (e.clientX - startX) * 2)}px`}}}//拉伸结束document.onmouseup = function (e) {document.onmousemove = nulldocument.onmouseup = null}}// 拉伸下边let resizeElB = document.createElement('div')dragDom.appendChild(resizeElB)//在弹窗右下角加上一个10-10px的控制块resizeElB.style.cursor = 'n-resize'resizeElB.style.position = 'absolute'resizeElB.style.height = '10px'resizeElB.style.width = '100%'resizeElB.style.left = '0px'resizeElB.style.bottom = '0px'// 鼠标拉伸弹窗resizeElB.onmousedown = (e) => {// 记录初始鼠标位置和弹窗尺寸let startY = e.clientYlet elH = dragDom.clientHeightdocument.onmousemove = function (e) {e.preventDefault() // 移动时禁用默认事件dragDom.style.height = `${Math.max(minHeight, elH + (e.clientY - startY) * 2)}px`}// 拉伸结束document.onmouseup = function (e) {document.onmousemove = nulldocument.onmouseup = null}}
}export default {updated(el, binding, vnode, prevVnode) {dialogDrag(el, binding, vnode, prevVnode)}
}
mousemove
、mouseup
和mousedown
是 JavaScript 中用于处理鼠标交互的事件,分别对应鼠标的移动、按下和松开操作,所以上述代码的实现也是注意借助这几个事件来实现的。当然加一些防抖,效果会更好。
四、总结
说下难点,第一个就是生命周期的选择和指令使用的位置,一定套一个div。
其他难点就是,需要动态计算弹窗的位置与尺寸,因为弹窗的位置和尺寸是动态计算的,涉及到鼠标的实时位置和弹窗初始位置之间的关系。为了确保用户体验,处理窗口边界的限制也是一个难点,确保弹窗不会拖出可视区域(这里我的可视区域是左边和上面不能拖出,但是右边和下边可以),还有一个比较难的就是弹窗内的图片查看组件的样式适配,因为要对弹窗边框拖拽改变大小时,图片也要自适应的改变,所以这个样式方面就做了很多功夫,代码也贴上去了,仅供参考~
至于可以优化的点,应该就是拖拽边框的时候更丝滑和防抖吧,如果有其他建议,麻烦评论区指出~