简单封装 el-dialog 了解 el-dialog 的机制

58 阅读4分钟

可拖拽 el-dialog 组件设计方案总结

1. 项目目标

实现一个可拖拽的 Element Plus Dialog 组件,支持:

  • 对话框可通过头部区域拖拽移动
  • 拖拽时不能将对话框拖出浏览器视口上边界
  • 支持拖出视口左右和下边界

2. 技术选型决策

2.1 为什么不使用自定义指令?

el-dialog 使用 Teleport 机制,内容渲染到 body 中,自定义指令无法直接获取真实 DOM 元素。

2.2 为什么不使用原生 draggable 属性?

原生 draggable 属性功能有限,无法实现自定义边界限制需求。

3. 核心功能实现

3.1 拖拽区域设计

利用 el-dialog 的 header 区域作为拖拽把手:

const initDraggable = () => {
  if (!dialogElement) return
  
  // 获取 el-dialog 的 header 元素作为拖拽区域
  const header = dialogElement.querySelector('.el-dialog__header')
  if (header) {
    header.style.cursor = 'move'           // 设置鼠标样式
    header.style.userSelect = 'none'       // 禁止文本选择
    header.addEventListener('mousedown', startDrag)  // 添加拖拽事件
  }
}

3.2 拖拽数据结构和事件处理

拖拽过程需要的数据和事件监听:

// 存储拖拽过程中的相关数据
let dragData = {
  startX: 0,              // 鼠标按下时的初始X坐标
  startY: 0,              // 鼠标按下时的初始Y坐标
  oldTransformX: 0,       // 开始拖拽前的X轴位移值
  oldTransformY: 0        // 开始拖拽前的Y轴位移值
}
​
let isDragging = false    // 标识是否正在拖拽
let dialogElement = null  // 对话框的真实DOM元素/**
 * 开始拖拽
 * @param {MouseEvent} e 鼠标事件对象
 */
const startDrag = (e) => {
  if (!dialogElement) return
​
  isDragging = true
​
  // 获取元素当前的 transform 值
  const transform = dialogElement.style.transform
  const { x, y } = parseTranslate(transform)
​
  // 记录拖拽起始状态
  dragData.startX = e.clientX
  dragData.startY = e.clientY
  dragData.oldTransformX = x
  dragData.oldTransformY = y
​
  // 添加全局事件监听器
  document.addEventListener('mousemove', onDrag)
  document.addEventListener('mouseup', stopDrag)
  e.preventDefault()  // 防止文本选中
}
​
/**
 * 拖拽过程中更新位置
 * @param {MouseEvent} e 鼠标事件对象
 */
const onDrag = (e) => {
  if (!isDragging || !dialogElement) return
​
  // 计算相对于起始点的移动距离
  const deltaX = e.clientX - dragData.startX
  const deltaY = e.clientY - dragData.startY
​
  // 基于原始位置计算新的位置
  const newTransformX = dragData.oldTransformX + deltaX
  const newTransformY = dragData.oldTransformY + deltaY
​
  // 应用新的位置变换
  dialogElement.style.transform = `translate(${newTransformX}px, ${newTransformY}px)`
}
​
/**
 * 停止拖拽
 */
const stopDrag = () => {
  isDragging = false
  // 移除全局事件监听器
  document.removeEventListener('mousemove', onDrag)
  document.removeEventListener('mouseup', stopDrag)
}

3.3 Transform 解析

解析元素当前的 transform 值:

/**
 * 解析 transform 字符串中的 translate 值
 * @param {string} transform transform CSS 属性值
 * @returns {Object} 包含 x 和 y 坐标的对象
 */
const parseTranslate = (transform) => {
  // 如果没有 transform 或为 none,返回默认值
  if (!transform || transform === 'none') {
    return { x: 0, y: 0 }
  }
​
  // 使用正则表达式匹配 translate(x, y) 格式
  const match = transform.match(/translate(\s*([+-]?\d*.?\d*)px\s*,\s*([+-]?\d*.?\d*)px\s*)/)
  if (match) {
    return {
      x: parseFloat(match[1]) || 0,  // 提取并转换 X 坐标
      y: parseFloat(match[2]) || 0   // 提取并转换 Y 坐标
    }
  }
​
  return { x: 0, y: 0 }
}

4. 边界限制功能

4.1 初始距离计算

计算对话框初始状态下距离顶部的最大可移动距离:

let maxMoveUpDistance = 0  // 记录最大向上移动距离/**
 * 对话框打开后的回调处理
 */
const onDialogOpened = async () => {
  await nextTick()  // 等待 DOM 更新完成
  // 获取对话框的真实 DOM 元素
  dialogElement = dialogRef.value.dialogContentRef.$el
​
  if (dialogElement) {
    // 获取元素相对于视口的位置信息
    const rect = dialogElement.getBoundingClientRect()
    // 初始距离顶部的距离即为最大可向上移动距离
    maxMoveUpDistance = rect.top
​
    // 禁用遮罩层的滚动条,防止弹窗拖拽出视口时出现不必要的滚动条
    const overlay = dialogElement.parentElement
    if (overlay) {
      overlay.style.overflow = 'hidden'
    }
  }
​
  // 初始化拖拽功能
  initDraggable()
}

4.2 上边界限制

防止对话框拖出浏览器视口上边界:

/**
 * 拖拽过程中更新位置(带边界限制)
 * @param {MouseEvent} e 鼠标事件对象
 */
const onDrag = (e) => {
  if (!isDragging || !dialogElement) return
​
  // 计算相对于起始点的移动距离
  const deltaX = e.clientX - dragData.startX
  const deltaY = e.clientY - dragData.startY
​
  // 基于原始位置计算新的位置
  const newTransformX = dragData.oldTransformX + deltaX
  const newTransformY = dragData.oldTransformY + deltaY
​
  // 边界检查 - 只限制上边界
  let finalY = newTransformY
  // 当元素在上半区域时(Y坐标为负值)
  if (newTransformY < 0) {
    // 计算向上移动的距离(取绝对值)
    const upwardDistance = Math.abs(newTransformY)
    // 如果移动距离超过了允许的最大距离
    if (upwardDistance > maxMoveUpDistance) {
      // 限制在最大可移动范围内
      finalY = -maxMoveUpDistance
    }
  }
​
  // 应用新的位置变换
  dialogElement.style.transform = `translate(${newTransformX}px, ${finalY}px)`
}

5. 完整示例代码

<!-- DraggableDialog.vue -->
<template>
  <div>
    <el-button type="primary" @click="openDialog">打开对话框</el-button>
    
    <el-dialog
        ref="dialogRef"
        v-model="visible"
        :before-close="handleBeforeClose"
        @opened="onDialogOpened"
    >
      <template #header>
        <slot name="header"></slot>
      </template>
​
      <slot></slot>
​
      <template #footer>
        <slot name="footer">
          <el-button @click="closeDialog">取消</el-button>
          <el-button type="primary" @click="confirmDialog">确定</el-button>
        </slot>
      </template>
    </el-dialog>
  </div>
</template><script setup>
import {ref, nextTick, onUnmounted, computed} from 'vue'const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    default: '可拖拽对话框'
  }
})
​
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
​
const visible = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})
​
// 对外暴露打开和关闭方法
const openDialog = () => {
  visible.value = true
}
​
const closeDialog = () => {
  visible.value = false
  emit('cancel')
}
​
const confirmDialog = () => {
  emit('confirm')
  closeDialog()
}
​
// 组件引用
const dialogRef = ref(null)
​
// 存储相关变量
let dialogHeaderRef = null
let isDragging = false
let dialogElement = null
let maxMoveUpDistance = 0 // 记录最大向上移动距离// 存储拖拽过程中的相关数据
let dragData = {
  startX: 0,      // 鼠标按下时的初始X坐标
  startY: 0,      // 鼠标按下时的初始Y坐标
  oldTransformX: 0,  // 开始拖拽前的X轴位移值
  oldTransformY: 0   // 开始拖拽前的Y轴位移值
}
​
/**
 * 关闭对话框前的处理
 */
const handleBeforeClose = (done) => {
  // 重置位置
  if (dialogElement) {
    dialogElement.style.transform = ''
  }
  done()
}
​
/**
 * 对话框打开后的回调处理
 */
const onDialogOpened = async () => {
  await nextTick()  // 等待 DOM 更新完成
  // 获取对话框的真实 DOM 元素
  dialogElement = dialogRef.value.dialogContentRef.$el
​
  // 计算初始距离顶部的距离
  if (dialogElement) {
    const rect = dialogElement.getBoundingClientRect()
    maxMoveUpDistance = rect.top // 初始距离顶部的距离
​
    // 禁用遮罩层的滚动条
    const overlay = dialogElement.parentElement
    if (overlay) {
      overlay.style.overflow = 'hidden'
    }
  }
​
  // 初始化拖拽功能
  initDraggable()
}
​
/**
 * 初始化拖拽功能
 */
const initDraggable = () => {
  if (!dialogElement) return
​
  // 获取 el-dialog 的 header 元素作为拖拽区域
  const header = dialogElement.querySelector('.el-dialog__header')
  if (header) {
    dialogHeaderRef = header
    header.style.cursor = 'move'      // 设置拖拽时光标样式
    header.style.userSelect = 'none'  // 禁止拖拽时选中文本
    header.addEventListener('mousedown', startDrag)  // 添加拖拽事件
  }
}
​
/**
 * 解析 transform 字符串中的 translate 值
 * @param {string} transform transform CSS 属性值
 * @returns {Object} 包含 x 和 y 坐标的对象
 */
const parseTranslate = (transform) => {
  // 如果没有 transform 或为 none,返回默认值
  if (!transform || transform === 'none') {
    return { x: 0, y: 0 }
  }
​
  // 使用正则表达式匹配 translate(x, y) 格式
  const match = transform.match(/translate(\s*([+-]?\d*.?\d*)px\s*,\s*([+-]?\d*.?\d*)px\s*)/)
  if (match) {
    return {
      x: parseFloat(match[1]) || 0,  // 提取并转换 X 坐标
      y: parseFloat(match[2]) || 0   // 提取并转换 Y 坐标
    }
  }
​
  return { x: 0, y: 0 }
}
​
/**
 * 开始拖拽
 * @param {MouseEvent} e 鼠标事件对象
 */
const startDrag = (e) => {
  if (!dialogElement) return
​
  isDragging = true
​
  // 获取元素当前的 transform 值
  const transform = dialogElement.style.transform
  const { x, y } = parseTranslate(transform)
​
  // 记录拖拽起始状态
  dragData.startX = e.clientX
  dragData.startY = e.clientY
  dragData.oldTransformX = x
  dragData.oldTransformY = y
​
  // 添加全局事件监听器
  document.addEventListener('mousemove', onDrag)
  document.addEventListener('mouseup', stopDrag)
  e.preventDefault()  // 防止文本选中
}
​
/**
 * 拖拽过程中更新位置(带边界限制)
 * @param {MouseEvent} e 鼠标事件对象
 */
const onDrag = (e) => {
  if (!isDragging || !dialogElement) return
​
  // 计算相对于起始点的移动距离
  const deltaX = e.clientX - dragData.startX
  const deltaY = e.clientY - dragData.startY
​
  // 基于原始位置计算新的位置
  const newTransformX = dragData.oldTransformX + deltaX
  const newTransformY = dragData.oldTransformY + deltaY
​
  // 边界检查 - 只限制上边界
  let finalY = newTransformY
  // 当元素在上半区域时(Y坐标为负值)
  if (newTransformY < 0) {
    // 计算向上移动的距离(取绝对值)
    const upwardDistance = Math.abs(newTransformY)
    // 如果移动距离超过了允许的最大距离
    if (upwardDistance > maxMoveUpDistance) {
      // 限制在最大可移动范围内
      finalY = -maxMoveUpDistance
    }
  }
​
  // 应用新的位置变换
  dialogElement.style.transform = `translate(${newTransformX}px, ${finalY}px)`
}
​
/**
 * 停止拖拽
 */
const stopDrag = () => {
  isDragging = false
  // 移除全局事件监听器
  document.removeEventListener('mousemove', onDrag)
  document.removeEventListener('mouseup', stopDrag)
}
​
// 组件销毁时清理事件监听器
onUnmounted(() => {
  document.removeEventListener('mousemove', onDrag)
  document.removeEventListener('mouseup', stopDrag)
})
​
// 向外暴露方法
defineExpose({
  openDialog,
  closeDialog
})
</script>