ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ElementUI Dialog拖拽拉伸:自定义指令实现全解析

ElementUI Dialog拖拽拉伸:自定义指令实现全解析 1. 需求分析与方案选型1.1 这个需求到底从哪来先聊个场景。很多管理系统里面的弹窗并不只是“展示几行文字点确定”那么简单尤其是登录弹窗、数据对比弹窗、长表单编辑弹窗用户往往需要在多个弹窗之间来回切换或者一边打开弹窗一边参照页面底部的表格数据。ElementUI 的 Dialog 默认是居中固定定位没有标题栏拖拽也没有边缘拉伸一旦弹窗内容稍微多一点屏幕小一点用户就只能干瞪眼要么滚动内部内容要么把浏览器窗口放大缩小。实际项目里产品经理一句“这个弹窗能不能像桌面软件一样随便拖、随便拉”开发就得想办法。我最早遇到这个需求是在一个后台管理系统的登录弹窗里用户抱怨弹窗挡住了左下角的“忘记密码”提示希望能把弹窗挪开。后来陆续在订单编辑、图片裁剪、大屏配置面板里都遇到了类似诉求。所以拖拽和拉伸并不是炫技而是实打实的交互体验优化尤其对分辨率和窗口尺寸差异大的场景非常有用。1.2 方案对比别一上来就写代码实现弹窗拖拽拉伸业界大概有三条路。第一条是找现成的第三方库比如vue-draggable-resizable、vue2-draggable-resizable它们提供了drag和resizable属性封装了拖拽和八个方向拉伸还支持网格对齐、纵横比锁定、边界限制。优点是省事、功能全缺点是和 ElementUI Dialog 的结构配合起来有点麻烦因为 Dialog 默认渲染到了body下如果直接把draggable-resizable组件套在 Dialog 外面位置计算容易错乱需要根据append-to-body等属性做额外适配而且打包体积也会增加不少。第二条是用 CSS 的resize属性配合overflow: auto做拉伸。这个方法几乎零成本resize: both加上overflow: hidden就能让元素右下角出现一个拖拽手柄但问题也很明显只有右下角能拉伸没有左侧和上侧而且样式非常原生跟 ElementUI 弹窗的圆角、阴影风格不统一最关键的是它只能拉伸内容区域不能同时移动弹窗。作为兜底方案可以作为完整方案不行。第三条是自己写指令。用 Vue 的自定义指令v-dialog-drag和v-dialog-resize通过监听mousedown、mousemove、mouseup事件实现。这种方式代码量可控没有任何第三方依赖能精确控制行为还能跟 ElementUI 的Dialog内部结构直接配合。今天重点讲的就是这条路。从这个需求的实际使用频率来看我更推荐第三种。原因很直接业务代码往往已经离不开 ElementUI没必要为了一个交互行为再多引入一个库而且手写指令可以根据项目情况定制边界、记忆位置、禁用状态等逻辑后续维护起来也清晰。2. 核心原理拆解2.1 拖拽的三要素弹窗拖拽的原理并不复杂本质上就是“按下、移动、松开”这三步的状态机。按下时记录鼠标起始坐标和弹窗初始位置移动时计算鼠标位移量并把它追加到弹窗位置松开时卸载事件监听。用代码描述就是const onMouseMove (e) { const offsetX e.clientX - startX; const offsetY e.clientY - startY; dialog.style.left originLeft offsetX px; dialog.style.top originTop offsetY px; };这里有两个关键点。第一监听mousemove和mouseup必须挂在document上而不是弹窗元素本身。因为鼠标移动速度一快很容易跑到弹窗外面如果监听在弹窗上就会出现“拖到一半跟丢了”的体验。第二起始坐标必须在mousedown事件触发时记录用e.clientX/e.clientY而不是offsetX/offsetY。offset是相对目标元素的偏移鼠标在标题栏不同位置按下时算出来的结果不一样会导致弹窗“跳一下”。很多人还会问为什么不用 HTML5 的draggable属性因为draggable是为“拖放文件、拖拽排序”设计的它的默认行为是生成一个半透明的拖影并触发dragstart、dragover、drop这一套机制而不是实时改变元素位置。要实现弹窗平滑跟随鼠标必须用基于mousedownmousemove的方案。2.2 拉伸的八个方向与光标状态拉伸比拖拽多一个维度。拖拽只需要控制左上角坐标left和top而拉伸需要根据鼠标在弹窗边缘的位置决定改变left、top、width、height中的哪几个值。八个方向分别是n上、s下、e右、w左、ne右上、nw左上、se右下、sw左下。每个方向对应不同的计算逻辑。比如拖拽右下角se时只需要改变宽度和高度左上角固定拖拽左上角nw时宽度、高度、left、top 都要变而且要注意鼠标向右下移动时width变大left保持初始值鼠标向左上移动时width变小但left会跟着鼠标向左移动这样才能保证右下角不动。判断当前鼠标落在哪个方向常用的做法是在弹窗四条边和四个角上放置透明的“热区”元素。每条边宽度 6px 左右每个角放一个 12px × 12px 的方块热区元素设置对应的cursor样式n和s是ns-resizee和w是ew-resize四个角分别是nwse-resize对应nw和se和nesw-resize对应ne和sw。这样用户一旦把鼠标移到边缘光标会自动变化提示这里可以拉伸。还有一个细节热区必须用绝对定位覆盖在弹窗边框上而且z-index要高于弹窗内容但低于正在拖拽的遮罩。否则鼠标移到边缘时会被内容区的元素挡住热区失效。2.3 为什么直接改 Dialog 的样式不够有人可能试过直接给.el-dialog加position: absolute然后改left、top确实能移动但会遇到两个坑。第一个坑是append-to-body的影响。ElementUI 的 Dialog 默认渲染在触发它的组件内部但如果弹窗嵌套层级深position: relative的父容器会影响left、top的参照系。建议在拖拽前先确认弹窗的position计算值是absolute还是fixed并根据实际使用的append-to-body属性来确定坐标基准。大部分项目里我会把 Dialog 设置append-to-body让它的定位参照变成body这样left、top直接用clientX和clientY计算避免嵌套容器带来的偏移。第二个坑是弹窗居中定位和 margin 的叠加。ElementUI 默认margin: 0 auto配合top: 15vh实现居中偏上的效果。如果你直接修改left会发现margin: auto还在起作用弹窗位置会很奇怪。正确做法是先手动设置left和top为具体像素值同时把margin重置为0再关闭top的默认值。简单来说拖拽开始前把弹窗的left和top初始化成当前计算后的位置之后每次移动就基于这个绝对值累加。3. 手写指令完整实现3.1 指令的目录结构与基本骨架我建议用两个独立指令一个负责拖拽一个负责拉伸便于单独复用。原因很简单有些弹窗只需要拖拽不需要拉伸比如登录框而数据对比弹窗可能两者都需要。拆开后使用者可以按需加载。项目代码结构大概这样src/ directives/ index.js dialog-drag.js dialog-resize.js在dialog-drag.js里核心逻辑就是监听标题栏的mousedown然后开始拖拽。这里有一个重要决定指令挂在哪个元素上。有两种选择挂在el-dialog组件上通过配置项传入自定义标题栏选择器挂在 Dialog 内部的标题栏元素上通过指令的binding.value传回调。我更推荐第一种因为最终要移动的是整个.el-dialog元素而 ElementUI 的 Dialog 默认渲染结构是.el-overlay里面套.el-dialog。如果我们把指令挂在.el-dialog上可以在指令的inserted钩子里找到标题栏.el-dialog__title再往它上面挂mousedown监听。3.2 拖拽指令的完整代码下面给出一个能直接用的拖拽指令适配 Vue 2 的写法// dialog-drag.js let currentDragging null; function getDialogElement(el) { return el.classList el.classList.contains(el-dialog) ? el : el.querySelector(.el-dialog); } export default { inserted(el, binding) { const dialog getDialogElement(el); if (!dialog) return; const header dialog.querySelector(.el-dialog__header); if (!header) return; header.style.cursor move; header.style.userSelect none; const handleMouseDown (e) { // 如果点击了 header 里面的按钮比如关闭按钮不要触发拖拽 if (e.target.closest(.el-dialog__headerbtn)) return; // 如果用户配置了 disabled则直接跳过 const value binding.value || {}; if (value.disabled) return; e.preventDefault(); const startX e.clientX; const startY e.clientY; const rect dialog.getBoundingClientRect(); const originLeft rect.left; const originTop rect.top; // 显式覆盖默认居中样式避免 margin 干扰 dialog.style.position fixed; dialog.style.left originLeft px; dialog.style.top originTop px; dialog.style.margin 0; const onMouseMove (ev) { const offsetX ev.clientX - startX; const offsetY ev.clientY - startY; let nextLeft originLeft offsetX; let nextTop originTop offsetY; // 边界限制防止拖出可视区域 const viewportWidth window.innerWidth; const viewportHeight window.innerHeight; const dialogRect dialog.getBoundingClientRect(); const minLeft 0; const minTop 0; const maxLeft viewportWidth - dialogRect.width; const maxTop viewportHeight - dialogRect.height; nextLeft Math.min(Math.max(nextLeft, minLeft), maxLeft); nextTop Math.min(Math.max(nextTop, minTop), maxTop); dialog.style.left nextLeft px; dialog.style.top nextTop px; // 可选触发的回调方便外部记录位置 if (typeof value.onDrag function) { value.onDrag({ left: nextLeft, top: nextTop }); } }; const onMouseUp () { document.removeEventListener(mousemove, onMouseMove); document.removeEventListener(mouseup, onMouseUp); currentDragging null; if (typeof value.onDragEnd function) { value.onDragEnd({ left: parseFloat(dialog.style.left), top: parseFloat(dialog.style.top) }); } }; document.addEventListener(mousemove, onMouseMove); document.addEventListener(mouseup, onMouseUp); }; header.addEventListener(mousedown, handleMouseDown); el.__dialogDragHandler__ handleMouseDown; }, unbind(el) { const dialog getDialogElement(el); if (dialog) { const header dialog.querySelector(.el-dialog__header); if (header el.__dialogDragHandler__) { header.removeEventListener(mousedown, el.__dialogDragHandler__); } } delete el.__dialogDragHandler__; } };这里有个细节getDialogElement的处理方式兼顾了两种用法指令可以挂在el-dialog上也可以挂在它的父元素上。如果你在模板里这样写el-dialog v-dialog-drag title测试弹窗 :visible.syncvisible那么在inserted钩子里拿到的el实际上是组件根元素而不是.el-dialog。ElementUI 的 Dialog 默认结构里组件根元素是.el-dialog本身所以classList.contains(el-dialog)就能命中。如果外层还有自定义包裹就需要querySelector兜底。3.3 拉伸指令的完整代码拉伸指令要复杂一些。我选择在.el-dialog元素内部插入八个热区元素并通过mousedown启动拉伸。// dialog-resize.js const directions [n, s, e, w, ne, nw, se, sw]; const cursorMap { n: ns-resize, s: ns-resize, e: ew-resize, w: ew-resize, ne: nesw-resize, sw: nesw-resize, nw: nwse-resize, se: nwse-resize }; const edgeSize 6; const cornerSize 14; let currentResize null; function createHandle(dialog, dir) { const handle document.createElement(div); handle.className el-dialog-resize-handle; handle.setAttribute(data-dir, dir); handle.style.position absolute; handle.style.zIndex 10; handle.style.cursor cursorMap[dir]; // 根据方向设置位置和大小 Object.assign(handle.style, getHandleStyle(dir)); dialog.appendChild(handle); return handle; } function getHandleStyle(dir) { const style {}; switch (dir) { case n: style.top 0; style.left cornerSize px; style.right cornerSize px; style.height edgeSize px; break; case s: style.bottom 0; style.left cornerSize px; style.right cornerSize px; style.height edgeSize px; break; case e: style.top cornerSize px; style.right 0; style.bottom cornerSize px; style.width edgeSize px; break; case w: style.top cornerSize px; style.left 0; style.bottom cornerSize px; style.width edgeSize px; break; case ne: style.top 0; style.right 0; style.width cornerSize px; style.height cornerSize px; break; case nw: style.top 0; style.left 0; style.width cornerSize px; style.height cornerSize px; break; case se: style.bottom 0; style.right 0; style.width cornerSize px; style.height cornerSize px; break; case sw: style.bottom 0; style.left 0; style.width cornerSize px; style.height cornerSize px; break; } return style; } export default { inserted(el, binding) { const dialog el.classList el.classList.contains(el-dialog) ? el : el.querySelector(.el-dialog); if (!dialog) return; const value binding.value || {}; // 确保弹窗的定位是 fixed否则热区绝对定位会错乱 dialog.style.position fixed; // 保证子元素可以相对它定位 dialog.style.overflow hidden; const handles []; directions.forEach(dir { const handle createHandle(dialog, dir); handles.push(handle); }); const makeResizeStart (dir, handle) (e) { if (value.disabled) return; e.preventDefault(); e.stopPropagation(); const rect dialog.getBoundingClientRect(); const startX e.clientX; const startY e.clientY; const startWidth rect.width; const startHeight rect.height; const startLeft rect.left; const startTop rect.top; const minWidth value.minWidth || 400; const minHeight value.minHeight || 200; const onMouseMove (ev) { const dx ev.clientX - startX; const dy ev.clientY - startY; let newWidth startWidth; let newHeight startHeight; let newLeft startLeft; let newTop startTop; if (dir.includes(e)) { newWidth startWidth dx; } if (dir.includes(s)) { newHeight startHeight dy; } if (dir.includes(w)) { newWidth startWidth - dx; newLeft startLeft dx; } if (dir.includes(n)) { newHeight startHeight - dy; newTop startTop dy; } // 最小尺寸限制并防止 left/top 越过右下边界 if (newWidth minWidth) { if (dir.includes(w)) { newLeft startLeft startWidth - minWidth; } newWidth minWidth; } if (newHeight minHeight) { if (dir.includes(n)) { newTop startTop startHeight - minHeight; } newHeight minHeight; } // 防止拖出视口右侧和底部 const maxLeft window.innerWidth - newWidth; const maxTop window.innerHeight - newHeight; newLeft Math.min(Math.max(newLeft, 0), maxLeft); newTop Math.min(Math.max(newTop, 0), maxTop); dialog.style.width newWidth px; dialog.style.height newHeight px; dialog.style.left newLeft px; dialog.style.top newTop px; }; const onMouseUp () { document.removeEventListener(mousemove, onMouseMove); document.removeEventListener(mouseup, onMouseUp); currentResize null; }; document.addEventListener(mousemove, onMouseMove); document.addEventListener(mouseup, onMouseUp); }; handles.forEach((handle, index) { const dir directions[index]; const handler makeResizeStart(dir, handle); handle.addEventListener(mousedown, handler); handle.__resizeHandler__ handler; }); el.__dialogResizeHandles__ handles; }, unbind(el) { const handles el.__dialogResizeHandles__ || []; handles.forEach(handle { if (handle.__resizeHandler__) { handle.removeEventListener(mousedown, handle.__resizeHandler__); } handle.remove(); }); delete el.__dialogResizeHandles__; } };这里需要注意一个容易踩坑的点热区的zIndex必须大于弹窗内容的层级但实际操作中.el-dialog__body可能会遮挡热区。我在项目里常用另一个方式——不往 Dialog 里插入元素而是监听mousemove事件动态计算鼠标是否位于弹窗边缘然后切换cursor并使用一个全局的mousemove做拉伸。这种方式对 DOM 结构侵入更小但代码会更绕。上面这份热区方案胜在直观而且因为热区本身覆盖了边缘鼠标一上去光标自然变化不需要额外计算。3.4 Vue 3 与 Element Plus 的差异如果你用的是 Vue 3 Element Plus指令钩子名称会变化。Vue 2 里的inserted对应 Vue 3 的mountedunbind对应unmounted。此外 Element Plus 的弹窗 DOM 结构有调整标题栏类名依然是.el-dialog__header但根元素可能带el-dialog类。基本逻辑可以平移只要把钩子名改掉即可。// Vue 3 指令写法 export default { mounted(el, binding) { // ... 同样的逻辑 }, unmounted(el) { // ... } };如果你是全局注册指令Vue 2 用Vue.directive(dialog-drag, DialogDrag)Vue 3 用app.directive(dialog-drag, DialogDrag)这点勿搞混。4. 实操过程中的关键步骤与常见坑4.1 初始化位置的正确处理很多人在接入拖拽后发现弹窗第一次拖拽时会“跳”一下原因就是没有在拖拽前把默认的left、top同步成当前实际渲染位置。ElementUI 默认top: 15vh和margin: 0 auto会在不动的时候生效但一旦你设置了position: fixed; left: 0; top: 0就会强制跳到左上角然后再加到计算好的位置视觉上就会闪烁。解决办法是在mousedown里先读取getBoundingClientRect()拿到当前左上角坐标立即设置left和top。要注意顺序先设置再开始计算位移不要反过来。实测下来这样首次拖拽也是平滑的。如果弹窗是append-to-body模式还要注意弹窗可能会因为页面滚动导致定位基准变化。我通常在mousedown里调用window.scrollTo前的位置或者在拖拽过程中直接使用clientX/clientY差值而不依赖getBoundingClientRect()之外的参考系。4.2 边界限制与回弹策略边界限制最简单的做法是限制left不能小于 0top不能小于 0同时left width不能大于window.innerWidthtop height不能大于window.innerHeight。上面代码里已经做了这个限制。但有个细节要注意如果浏览器窗口本身比弹窗还小限制left为 0 会导致弹窗左边贴死用户无法看到右侧被截断的内容。这种情况下我一般会加一个阈值判断当window.innerWidth dialog.offsetWidth时允许left为负值只要保证弹窗一部分内容可见即可。这在笔记本外接显示器分辨率切换的时候特别有用。回弹策略是指松开鼠标后弹窗是否要回到原来的位置。默认情况下不需要回弹但有些产品同学会要求“弹窗拖出去后释放自动回到可视区域内”。实现方式是记录onDragEnd时的坐标如果超出边界就做一个简单的过渡动画回到边界内。const onMouseUp () { const finalLeft parseFloat(dialog.style.left); const finalTop parseFloat(dialog.style.top); const maxLeft window.innerWidth - dialog.offsetWidth; const maxTop window.innerHeight - dialog.offsetHeight; if (finalLeft 0 || finalTop 0 || finalLeft maxLeft || finalTop maxTop) { dialog.style.transition left 0.2s ease, top 0.2s ease; dialog.style.left Math.min(Math.max(finalLeft, 0), maxLeft) px; dialog.style.top Math.min(Math.max(finalTop, 0), maxTop) px; setTimeout(() { dialog.style.transition ; }, 200); } };这个回弹过程不要直接拿原生animate去搞transition就够而且要注意结束之后必须清空transition否则下一次拖拽会因为过渡动画导致位置延迟产生“跟手慢半拍”的问题。4.3 与 ElementUI 内部滚动条、表单控件的冲突弹窗内部如果有滚动条拖拽标题栏没问题但在内容区按下鼠标不应该触发拖拽。我的做法指令的mousedown只绑定在标题栏上内容区天然不受影响。但拉伸热区覆盖在弹窗边缘如果内容区有横向滚动条用户点击滚动条位置时可能被热区拦截。所以热区不能覆盖太宽边缘 6px 是相对平衡的值既容易命中又不至于遮挡内容。表单控件方面的坑主要是select下拉和date-picker弹层。ElementUI 的select展开后内容是渲染在 body 下的独立浮层拖拽弹窗时这个浮层不会跟着动。如果你希望下拉跟随弹窗移动必须给select设置popper-append-to-body为false让它渲染在弹窗内部但这样又可能被弹窗的overflow: hidden裁剪掉。实际项目中我建议保持默认的append-to-body拖拽时下拉浮层的位置问题属于可接受范围因为用户拖拽时通常不会同时操作下拉。4.4 iframe 覆盖问题弹窗里如果嵌了iframe比如预览打印、嵌入地图拖拽时会发现鼠标一旦移到 iframe 区域mousemove事件就失灵了因为 iframe 自成一个文档外部的document监听不到它内部的事件。解决方式有两种。一种是给 iframe 设置透明的遮罩层拖拽开始时在弹窗上覆盖一个不会影响视觉的透明 div鼠标事件重新落到外部文档上。这个遮罩层在拖拽结束后移除。// 拖拽开始时 const iframeMask document.createElement(div); iframeMask.style.position fixed; iframeMask.style.top 0; iframeMask.style.left 0; iframeMask.style.width 100%; iframeMask.style.height 100%; iframeMask.style.zIndex 9999; iframeMask.style.background transparent; document.body.appendChild(iframeMask); // 拖拽结束时 document.body.removeChild(iframeMask);另一种是直接监听 iframe 的contentDocument事件做法比较繁琐。实际项目中我倾向第一种简单有效。5. 多弹窗层级与记忆位置5.1 z-index 管理与多弹窗切换弹窗变多了以后拖拽其中一个时某些弹窗可能被遮罩层盖住。ElementUI 的modal遮罩会在每次弹窗打开时创建一个半透明遮罩多个弹窗叠加时 z-index 是递增的。如果我们只修改了弹窗自身的left、top没有处理遮罩层级可能拖拽一个弹窗时它的遮罩把其他弹窗盖住了。处理思路监听弹窗的open事件在打开时手动提升当前弹窗的 z-index。ElementUI 的 Dialog 实例可以通过ref拿到$refs.dialog.$el然后动态设置style.zIndex。不过大多数后台项目弹窗不会同时开很多个简单的做法是弹窗互相独立谁后打开谁在上面即可。如果确实需要类似任务窗口的效果我建议给每个弹窗绑定一个自增索引拖拽mousedown时把当前弹窗的 z-index 提到最高。5.2 记忆位置与多分辨率适配弹窗位置能不能在刷新后保持这个需求很常见。实现方式是把onDragEnd回调里的坐标写入sessionStorage下次打开弹窗时在created或open事件中读取并设置left、top。// 在 Dialog 的 open 事件里 const savedPos JSON.parse(sessionStorage.getItem(dialog-position) || null); if (savedPos) { this.$nextTick(() { const dialogEl this.$refs.dialog.$el; dialogEl.style.left savedPos.left px; dialogEl.style.top savedPos.top px; dialogEl.style.margin 0; }); }一个需要警惕的细节是分辨率变化。用户在公司外接显示器上把弹窗拖到了右下角回家用笔记本打开屏幕变小弹窗可能跑到可视区域外。所以保存位置的时候最好同时保存视口尺寸重新打开时做一次比例换算或者直接做边界限制超出就弹回默认位置。实测下来比例换算对用户来说更友好但代码复杂度会上升我一般在管理后台里只做边界限制够用。5.3 指令的全局注册与按需引入最终把这些指令放进项目里建议在directives/index.js统一导出import DialogDrag from ./dialog-drag; import DialogResize from ./dialog-resize; export default { install(Vue) { Vue.directive(dialog-drag, DialogDrag); Vue.directive(dialog-resize, DialogResize); } };然后在入口文件import DialogDirectives from /directives; Vue.use(DialogDirectives);这样模板里直接写el-dialog v-dialog-drag{ disabled: false, onDragEnd: savePosition } v-dialog-resize{ minWidth: 600, minHeight: 400 } title订单详情 :visible.syncvisible width800px 要注意width属性依然可以给 ElementUI 设置初始宽度拉伸时我们用style.width覆盖它两者不冲突。6. 常见问题与排查技巧实录6.1 指令不生效弹窗完全不能拖这种情况十有八九是指令挂载时机的问题。ElementUI 的 Dialog 默认是懒渲染visible为false时组件不渲染 DOMinserted钩子根本不会触发。解决方式有两种给 Dialog 加destroy-on-close属性每次打开都重新渲染指令重新绑定在open事件里手动执行指令的初始化逻辑。更稳妥的做法是把拖拽逻辑封装成方法在open回调里调用。因为open事件触发时弹窗 DOM 已经挂载完成。6.2 拖拽时页面跟着滚动弹窗定位是fixed的情况下鼠标在弹窗上按下并移动页面不应该滚动。但某些浏览器里如果mousedown的目标在文本输入框内拖拽标题栏时可能出现误选文本的情况。解决办法是e.preventDefault()并给标题栏设置user-select: none。如果页面还是滚动可能是没有阻止默认行为或者拖拽事件被绑定到了错误元素。检查一下mousedown是否绑定在.el-dialog__header上同时确认header.style.userSelect none生效。6.3 拉伸后弹窗内容布局错乱很多人在拉伸弹窗后发现表格宽度没有跟着变或者弹窗内部组件溢出。这是因为 ElementUI 的el-table默认不是百分之百自适应父容器高度需要设置height100%或者监听弹窗尺寸变化后调用table.doLayout()。如果只是简单的内容展示给.el-dialog__body设置overflow: auto就能扛住。但如果是表格、表单这类复杂组件建议使用ResizeObserver监听弹窗尺寸变化const observer new ResizeObserver((entries) { // 触发表格重新布局 vm.$refs.table vm.$refs.table.doLayout(); }); observer.observe(dialogEl);实测下来这种方式最实用。直接监听window.resize不够因为弹窗尺寸变化不一定会触发窗口 resize 事件。6.4 热区与 ElementUI 圆角的冲突ElementUI 弹窗默认border-radius: 4px而左上角和右上角的热区是个正方形会凸出圆角之外。为了视觉统一我给角落热区也设置了相同的border-radius同时用overflow: hidden配合。不过热区本身是透明背景圆角效果主要体现在鼠标光标上影响不大。真正要注意的是热区不能挡住关闭按钮。.el-dialog__headerbtn位于右上角被ne热区覆盖后点击失效的风险很大。我的处理方式是将右上角的热区尺寸缩小到 8px × 8px或者把关闭按钮的 z-index 提到更高。6.5 第三方库与自定义指令该怎么选如果你只是临时给一两个弹窗加功能而且不需要精细定制用vue2-draggable-resizable这类库确实更快。但如果你在多个项目里都会遇到弹窗拖拽需求或者需要在拖拽结束后保存位置、与业务状态联动我强烈建议自己维护这套指令。它不复杂而且能完全掌控行为。这里可以给一个简单的对比表维度第三方库自定义指令上手速度快中等体积额外引入依赖无新增依赖定制能力受组件 API 限制完全可控ElementUI 适配需要额外处理 DOM 结构天然适配维护成本依赖上游更新自己负责就我个人的项目经验凡是长时间迭代的后台系统最终都会走向自定义指令因为产品经理一定会提出“弹窗位置记忆”“某些弹窗禁止拉伸”“拖拽到边缘自动吸附”这些个性化需求第三方库很难全部满足。7. 扩展玩法与实际心得7.1 吸附边缘的实现拖拽到接近屏幕边缘时自动吸附是桌面软件常见行为。实现方式是在mousemove中判断当前left是否小于某个阈值比如 10px如果是就把left直接设置为 0同理右侧和上下边缘。要注意别在拖拽过程中过早吸附否则用户很难把弹窗停在边缘附近。我一般只在mouseup时做一次吸附判断平时不做。7.2 拖拽手柄与拉伸提示有的设计稿会在弹窗右下角放一个类似“斜纹”的拉伸图标。ElementUI 没有自带这个可以在右下角热区里放一个伪元素用 CSS 画两条斜线。.el-dialog-resize-handle[data-dirse]::after { content: ; position: absolute; right: 2px; bottom: 2px; width: 10px; height: 10px; border-right: 2px solid #c0c4cc; border-bottom: 2px solid #c0c4cc; transform: rotate(45deg); }注意这个伪元素会占掉热区的一部分点击面积所以样式和热区尺寸要平衡。我的经验边缘 6px角落 14px视觉提示不要超过 8px。7.3 我最后想说的几点体会弹窗拖拽拉伸这个问题看起来是个小功能但做起来牵扯的细节不少。从监听事件到边界计算从遮罩层级到 resize 后的布局刷新每一项都是“鼠标体验”的直接来源。用户不会因为你弹窗能拖拽就表扬你但弹窗卡顿、跳闪、拖不到预期位置一定会被吐槽。所以实现时宁可多写几行代码把边界和状态处理干净也不要只做表面上的功能。如果让我给新人一个建议先自己写一版纯指令实现不要急着上第三方库。写完之后你自然就理解mousedown、mousemove、mouseup这个事件循环在真实业务里是怎么运作的。之后再遇到其他拖拽需求比如拖拽调整表单行高、拖拽改变分割面板宽度都能顺手解决。这套指令代码不长但能复用的场景非常多值得沉淀成项目里的公共能力。
返回列表