技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

【Vue 3】使用自定义指令实现 Element Plus Dialog 拖拽

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/54-【vue3】使用自定义指令,实现el-dialog的拖拽功能。
本文目录8 个章节
  1. 一、先使用 Element Plus 自带能力
  2. 二、什么情况下还需要自定义指令
  3. 三、Vue 3 自定义指令的生命周期
  4. 四、为什么不能简单把指令写在 el-dialog 上
  5. 五、一个可清理的 Pointer Events 实现
  6. 六、使用方式
  7. 七、SSR 注意事项
  8. 八、总结

【Vue 3】使用自定义指令实现 Element Plus Dialog 拖拽

本文保留原文“用自定义指令实现 Dialog 拖拽”的思路,并补充当前 Element Plus 和 Vue 3 的正确用法。

版本说明:Vue 2.7 使用 bindinsertedcomponentUpdatedunbind 等旧指令钩子;Vue 3.5 使用 createdbeforeMountmountedupdatedbeforeUnmountunmounted。下文自定义实现以 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。如果内置能力已经满足需求,不要再叠加一套自定义拖拽,否则两套逻辑可能同时修改位置。

参考:Element Plus Dialog

二、什么情况下还需要自定义指令

自定义指令适合封装低层 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-toappend-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 实现

原文使用 onmousedownonmousemoveonmouseup,只能覆盖鼠标,并且把事件处理器直接写到 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?.()
  },
}

上面的示例有几个重要边界:

  1. getBoundingClientRect() 使用视口坐标,边界计算不会依赖 Element Plus 某个版本的子节点层级;
  2. setPointerCapture() 可以在指针离开标题区域后继续接收移动和抬起事件;
  3. pointercancel、组件卸载和事件监听器都需要清理;
  4. touch-action: none 只应该设置在拖拽句柄上,避免整个页面失去正常滚动;
  5. 代码使用 transform,因此不要同时让 Element Plus 内置拖拽或其他动画逻辑修改同一个 transform
  6. 这只是教学实现,生产环境还应处理窗口 resize、Dialog 动画、无障碍键盘操作和移动端滚动策略。

如果需要把拖拽状态持久化,应该把 offsetXoffsetY 存入业务状态,并在 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 行为。不要在模块顶层读取 windowdocument,也不要在 SSR 阶段计算随机位置。Vue 3 的 mountedunmounted 只在客户端执行;如果指令确实需要给服务端输出属性,才考虑 getSSRProps

Nuxt 中可以把拖拽指令放在客户端执行的组件或插件中,但服务端首屏的 HTML 结构必须和客户端一致。普通拖拽指令通常不需要 getSSRProps,只要把 DOM 事件绑定放进 mounted 即可。

八、总结

  • 优先使用 Element Plus 的 draggableoverflow
  • 自定义指令应绑定到真正的 header DOM,而不是猜测 el-dialog 的内部层级;
  • 不要用固定 setTimeout 代替生命周期或 opened/nextTick
  • 使用 Pointer Events、pointer capture,并清理所有监听器;
  • 避免与 Element Plus 内置拖拽同时修改同一个位置样式;
  • 旧版实现仍有学习价值,但应明确它依赖的 Vue、Element Plus 和浏览器版本。

原文出处:使用自定义指令,实现 el-dialog 的拖拽功能。 原文示例源码:nf-rollup-ui-element-plus

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS