【Vue 3】使用自定义指令实现 Element Plus Dialog 拖拽
本文保留原文“用自定义指令实现 Dialog 拖拽”的思路,并补充当前 Element Plus 和 Vue 3 的正确用法。
版本说明:Vue 2.7 使用
bind、inserted、componentUpdated、unbind等旧指令钩子;Vue 3.5 使用created、beforeMount、mounted、updated、beforeUnmount、unmounted。下文自定义实现以 Vue 3 为主。
一、先使用 Element Plus 自带能力
原文发布时,el-dialog 可能还没有内置拖拽能力。当前 Element Plus Dialog 已经提供了 draggable 属性,默认拖拽 Dialog 的 header:
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
</script>
<template>
<el-button @click="visible = true">打开</el-button>
<el-dialog
v-model="visible"
title="可拖拽 Dialog"
width="500px"
draggable
>
这是 Element Plus 内置的拖拽能力。
</el-dialog>
</template>
Element Plus 还提供了 overflow 属性。默认情况下,Dialog 会尽量保持在视口内;需要允许拖出视口时,可以写:
<el-dialog v-model="visible" draggable overflow>
内容
</el-dialog>
fullscreen 时不能同时依赖 draggable。如果内置能力已经满足需求,不要再叠加一套自定义拖拽,否则两套逻辑可能同时修改位置。
二、什么情况下还需要自定义指令
自定义指令适合封装低层 DOM 操作,例如:
- 需要自定义拖拽边界或吸附规则;
- 需要兼容旧版 Element Plus;
- 需要支持鼠标、触摸和手写笔的统一交互;
- 需要将拖拽句柄限制为标题中的某一部分;
- 内置
draggable的行为不能满足业务需求。
如果只是把一个 DOM 元素变成可拖拽元素,指令通常比在每个组件中重复绑定事件更容易复用。但指令不能绕过组件的 DOM 边界,也不应该依赖第三方组件不稳定的内部节点层级。
三、Vue 3 自定义指令的生命周期
Vue 3 自定义指令常用的钩子如下:
| Vue 3 指令钩子 | 作用 |
|---|---|
created | 元素属性和事件监听器应用前调用 |
beforeMount | 元素挂载到父节点前调用 |
mounted | 元素已经挂载后调用 |
beforeUpdate | 包含组件更新的 VNode 更新前调用 |
updated | 包含组件更新的 VNode 更新后调用 |
beforeUnmount | 元素卸载前调用 |
unmounted | 元素卸载后调用,适合最终清理 |
指令的 binding.value 是模板中传入的值,binding.instance 是组件实例。Vue 3 已移除 Vue 2 的 binding.expression,也不要再从 vnode.context 取组件实例。
Vue 2 的对应关系大致是:
Vue 2 bind -> Vue 3 beforeMount
Vue 2 inserted -> Vue 3 mounted
Vue 2 update -> Vue 3 beforeUpdate
Vue 2 componentUpdated -> Vue 3 updated
Vue 2 unbind -> Vue 3 unmounted
更多说明:Vue 3 自定义指令、Vue 2/3 指令迁移。
四、为什么不能简单把指令写在 el-dialog 上
Vue 3 把组件上的指令应用到组件的单一根 DOM 节点上,但这不等于指令会自动找到组件内部的 header。多根组件上的指令还可能被忽略并产生警告。
Element Plus Dialog 内部还可能使用 Teleport,实际 DOM 位置受 append-to、append-to-body 等属性影响。原文通过外层 div 绕过了组件根节点限制,但又依赖了 firstElementChild.firstElementChild 这样的内部层级,这种写法很脆弱。
更明确的做法是:在 Dialog 的 #header 插槽中给真实的 header 元素绑定指令。这样指令的 el 就是拖拽句柄,而不是猜测 Dialog 内部结构:
<template>
<el-dialog
ref="dialogRef"
v-model="visible"
width="500px"
:show-close="false"
>
<template #header>
<div
v-dialog-drag="{ dialog: dialogRef }"
class="dialog-drag-handle"
>
自定义标题
</div>
</template>
内容
</el-dialog>
</template>
Dialog 的内容可能是 lazy render。指令挂在真正的 header 上时,它会随着 header 一起挂载,不需要用固定的 setTimeout(300) 猜测渲染完成时间。也可以监听 Dialog 的 opened 事件并在 nextTick() 后初始化,但不能把固定延时当成渲染完成保证。
五、一个可清理的 Pointer Events 实现
原文使用 onmousedown、onmousemove 和 onmouseup,只能覆盖鼠标,并且把事件处理器直接写到 DOM0 属性上会覆盖其他逻辑。下面使用 Pointer Events,同时处理鼠标、触摸和手写笔:
// directives/dialogDrag.ts
import { unref, type Directive } from 'vue'
type DialogRef = {
value?: { $el?: HTMLElement } | HTMLElement | null
}
type DialogDragOptions = {
dialog: DialogRef | { $el?: HTMLElement } | HTMLElement | null
overflow?: boolean
}
type DragState = {
pointerId: number
startX: number
startY: number
offsetX: number
offsetY: number
rect: DOMRect
}
function getDialogElement(source: DialogDragOptions['dialog']) {
const value = unref(source as never) as { $el?: HTMLElement } | HTMLElement | null
if (!value) return null
if (value instanceof HTMLElement) return value.querySelector<HTMLElement>('.el-dialog')
return value.$el?.querySelector<HTMLElement>('.el-dialog') ?? value.$el ?? null
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), Math.max(min, max))
}
export const vDialogDrag: Directive<HTMLElement, DialogDragOptions> = {
mounted(handle, binding) {
const options = binding.value
const dialog = options && getDialogElement(options.dialog)
if (!dialog) {
console.warn('没有找到 Dialog 面板,请确认指令挂在 #header 的真实元素上')
return
}
let state: DragState | null = null
let offsetX = 0
let offsetY = 0
const previousCursor = handle.style.cursor
const previousTouchAction = handle.style.touchAction
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0 && event.pointerType === 'mouse') return
const rect = dialog.getBoundingClientRect()
state = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX,
offsetY,
rect,
}
handle.setPointerCapture(event.pointerId)
handle.style.cursor = 'move'
event.preventDefault()
}
const onPointerMove = (event: PointerEvent) => {
if (!state || event.pointerId !== state.pointerId) return
const rawX = state.offsetX + event.clientX - state.startX
const rawY = state.offsetY + event.clientY - state.startY
const nextX = options?.overflow
? rawX
: clamp(rawX, -state.rect.left, window.innerWidth - state.rect.right)
const nextY = options?.overflow
? rawY
: clamp(rawY, -state.rect.top, window.innerHeight - state.rect.bottom)
offsetX = nextX
offsetY = nextY
dialog.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0)`
}
const stopDragging = (event: PointerEvent) => {
if (!state || event.pointerId !== state.pointerId) return
if (handle.hasPointerCapture(event.pointerId)) {
handle.releasePointerCapture(event.pointerId)
}
state = null
handle.style.cursor = previousCursor
}
handle.style.touchAction = 'none'
handle.addEventListener('pointerdown', onPointerDown)
handle.addEventListener('pointermove', onPointerMove)
handle.addEventListener('pointerup', stopDragging)
handle.addEventListener('pointercancel', stopDragging)
// 保存清理函数,供 unmounted 使用。
;(handle as HTMLElement & { __dialogDragCleanup?: () => void }).__dialogDragCleanup = () => {
handle.removeEventListener('pointerdown', onPointerDown)
handle.removeEventListener('pointermove', onPointerMove)
handle.removeEventListener('pointerup', stopDragging)
handle.removeEventListener('pointercancel', stopDragging)
if (state && handle.hasPointerCapture(state.pointerId)) {
handle.releasePointerCapture(state.pointerId)
}
handle.style.cursor = previousCursor
handle.style.touchAction = previousTouchAction
}
},
unmounted(handle) {
;(handle as HTMLElement & { __dialogDragCleanup?: () => void }).__dialogDragCleanup?.()
},
}
上面的示例有几个重要边界:
getBoundingClientRect()使用视口坐标,边界计算不会依赖 Element Plus 某个版本的子节点层级;setPointerCapture()可以在指针离开标题区域后继续接收移动和抬起事件;pointercancel、组件卸载和事件监听器都需要清理;touch-action: none只应该设置在拖拽句柄上,避免整个页面失去正常滚动;- 代码使用
transform,因此不要同时让 Element Plus 内置拖拽或其他动画逻辑修改同一个transform; - 这只是教学实现,生产环境还应处理窗口 resize、Dialog 动画、无障碍键盘操作和移动端滚动策略。
如果需要把拖拽状态持久化,应该把 offsetX、offsetY 存入业务状态,并在 Dialog 尺寸变化时重新校正,而不是读取 style.cssText 的第一段来猜测宽度。
六、使用方式
在 <script setup> 中,命名为 vDialogDrag 的变量会自动对应模板中的 v-dialog-drag:
<script setup lang="ts">
import { ref } from 'vue'
import { vDialogDrag } from './directives/dialogDrag'
const visible = ref(false)
const dialogRef = ref()
</script>
<template>
<el-button @click="visible = true">打开</el-button>
<el-dialog
ref="dialogRef"
v-model="visible"
width="500px"
:show-close="false"
>
<template #header>
<div
v-dialog-drag="{ dialog: dialogRef }"
class="dialog-drag-handle"
>
拖拽这里移动 Dialog
</div>
</template>
<span>拖拽测试</span>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="visible = false">确定</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-drag-handle {
cursor: move;
user-select: none;
}
</style>
如果把指令注册为插件,则可以使用:
import type { App } from 'vue'
import { vDialogDrag } from './directives/dialogDrag'
export default {
install(app: App) {
app.directive('dialog-drag', vDialogDrag)
},
}
原文把 visible 字段写死在指令内部,这会限制复用。上面的实现把 Dialog 引用作为指令值传入,不要求业务状态必须叫 visible,也没有把 ref 错误地说成会失去响应性。模板中的顶层 ref 会自动解包;指令若需要监听值,应明确约定传入 Ref、getter 或对象。
七、SSR 注意事项
拖拽是客户端 DOM 行为。不要在模块顶层读取 window、document,也不要在 SSR 阶段计算随机位置。Vue 3 的 mounted 和 unmounted 只在客户端执行;如果指令确实需要给服务端输出属性,才考虑 getSSRProps。
Nuxt 中可以把拖拽指令放在客户端执行的组件或插件中,但服务端首屏的 HTML 结构必须和客户端一致。普通拖拽指令通常不需要 getSSRProps,只要把 DOM 事件绑定放进 mounted 即可。
八、总结
- 优先使用 Element Plus 的
draggable和overflow; - 自定义指令应绑定到真正的 header DOM,而不是猜测
el-dialog的内部层级; - 不要用固定
setTimeout代替生命周期或opened/nextTick; - 使用 Pointer Events、pointer capture,并清理所有监听器;
- 避免与 Element Plus 内置拖拽同时修改同一个位置样式;
- 旧版实现仍有学习价值,但应明确它依赖的 Vue、Element Plus 和浏览器版本。
原文出处:使用自定义指令,实现 el-dialog 的拖拽功能。 原文示例源码:nf-rollup-ui-element-plus。