仿写优秀组件:还原 Element Plus 的 Dialog 弹窗核心逻辑

仿写优秀组件:还原 Element Plus 的 Dialog 弹窗核心逻辑

目标:用最小可用实现拆解并还原 Element Plus 的 Dialog 核心交互与技术设计,包括显隐控制、遮罩、ESC 关闭、滚动锁定、层级管理、Teleport、过渡动画与可访问性。

功能要点

  • v-model 双向绑定控制显隐
  • modal 遮罩与点击遮罩关闭
  • close-on-press-escape 键盘 ESC 关闭
  • lock-scroll 打开时锁定页面滚动
  • z-index 层级管理支持多弹窗叠加
  • Teleport 将弹窗渲染到 body
  • destroy-on-close 关闭时卸载内容
  • 过渡动画与开合事件流:open/opened/close/closed
  • 可访问性:role="dialog"aria-modalaria-labelledby

技术选型与设计

  • 框架:Vue 3 Composition API(与 Element Plus 一致的范式)
  • 组合式能力:useZIndex 管理层级,useLockScroll 管理滚动锁定
  • 组件结构:Overlay + Panel,通过 Teleport 渲染到 body
  • 动画:Transition 包裹 Panel,进入与离开过渡钩子承载事件流

最小可用实现

useZIndex.ts

ts 复制代码
import { ref } from 'vue'

const globalZIndex = ref(2000)

export function useZIndex(initial?: number) {
  const current = ref(initial ?? 0)
  const next = () => {
    globalZIndex.value += 1
    current.value = globalZIndex.value
    return current.value
  }
  const value = () => (current.value || next())
  return { value, next }
}

useLockScroll.ts

ts 复制代码
let lockCount = 0
let prevOverflow = ''

export function lockScroll() {
  if (lockCount === 0) {
    const body = document.body
    prevOverflow = body.style.overflow
    body.style.overflow = 'hidden'
  }
  lockCount += 1
}

export function unlockScroll() {
  lockCount -= 1
  if (lockCount <= 0) {
    const body = document.body
    body.style.overflow = prevOverflow
    lockCount = 0
  }
}

Dialog.vue

vue 复制代码
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useZIndex } from './useZIndex'
import { lockScroll, unlockScroll } from './useLockScroll'

interface Props {
  modelValue: boolean
  title?: string
  modal?: boolean
  closeOnClickModal?: boolean
  closeOnPressEscape?: boolean
  lockScroll?: boolean
  destroyOnClose?: boolean
  appendToBody?: boolean
  zIndex?: number
  showClose?: boolean
  width?: string | number
  center?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  modal: true,
  closeOnClickModal: true,
  closeOnPressEscape: true,
  lockScroll: true,
  destroyOnClose: false,
  appendToBody: true,
  showClose: true,
  center: false
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void
  (e: 'open'): void
  (e: 'opened'): void
  (e: 'close'): void
  (e: 'closed'): void
}>()

const rendered = ref(props.modelValue)
const visible = ref(props.modelValue)
const titleId = `dialog-title-${Math.random().toString(36).slice(2)}`
const z = useZIndex(props.zIndex)

const panelStyle = computed(() => ({
  zIndex: z.value(),
  width: typeof props.width === 'number' ? `${props.width}px` : props.width
}))

function open() {
  emit('open')
  if (props.lockScroll) lockScroll()
  z.next()
  rendered.value = true
  visible.value = true
}

function close() {
  emit('close')
  visible.value = false
}

function onAfterEnter() {
  emit('opened')
}

function onAfterLeave() {
  if (props.lockScroll) unlockScroll()
  if (props.destroyOnClose) rendered.value = false
  emit('closed')
}

function onOverlayClick() {
  if (props.closeOnClickModal) emit('update:modelValue', false)
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && props.closeOnPressEscape) emit('update:modelValue', false)
}

watch(() => props.modelValue, v => {
  if (v) open()
  else close()
})

onMounted(() => {
  document.addEventListener('keydown', onKeydown)
})

onBeforeUnmount(() => {
  document.removeEventListener('keydown', onKeydown)
  if (props.lockScroll && visible.value) unlockScroll()
})
</script>

<template>
  <Teleport to="body" v-if="props.appendToBody">
    <div v-if="props.modal && visible" class="dialog-overlay" :style="{ zIndex: z.value() - 1 }" @click="onOverlayClick" />
    <Transition name="dialog-fade" @after-enter="onAfterEnter" @after-leave="onAfterLeave">
      <div v-show="rendered && visible" class="dialog-panel" :style="panelStyle" role="dialog" aria-modal="true" :aria-labelledby="titleId">
        <div class="dialog-header" :class="{ center: props.center }">
          <span :id="titleId">{{ props.title }}</span>
          <button v-if="props.showClose" class="dialog-close" @click="emit('update:modelValue', false)">✕</button>
        </div>
        <div class="dialog-body">
          <slot />
        </div>
        <div class="dialog-footer">
          <slot name="footer" />
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); }
.dialog-panel { position: fixed; top: 15vh; left: 50%; transform: translateX(-50%); background: #fff; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); min-width: 360px; }
.dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-weight: 600; }
.dialog-header.center { justify-content: center; }
.dialog-close { border: 0; background: transparent; font-size: 18px; cursor: pointer; }
.dialog-body { padding: 16px; }
.dialog-footer { padding: 12px 16px; text-align: right; }
.dialog-fade-enter-active, .dialog-fade-leave-active { transition: opacity .2s, transform .2s; }
.dialog-fade-enter-from, .dialog-fade-leave-to { opacity: 0; transform: translate(-50%, -10px); }
</style>

使用示例

vue 复制代码
<template>
  <button @click="visible = true">打开</button>
  <Dialog v-model="visible" title="标题" :width="480" :close-on-click-modal="false" :lock-scroll="true">
    内容
    <template #footer>
      <button @click="visible = false">关闭</button>
    </template>
  </Dialog>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Dialog from './Dialog.vue'
const visible = ref(false)
</script>

事件与状态流转

  • 打开:modelValue=trueopen → 渲染内容 → 进入动画 → opened
  • 关闭:update:modelValue=falseclose → 离开动画 → 解锁滚动 → closed
  • 遮罩点击:closeOnClickModal=true 时触发更新关闭
  • ESC:closeOnPressEscape=true 时触发更新关闭

多弹窗叠加与层级

  • 每次打开调用 z.next() 获取更高 z-index
  • Overlay 使用 z.value()-1,Panel 使用 z.value() 保持正确遮盖关系
  • 通过全局计数避免滚动锁定在多弹窗场景下提前释放

可访问性基础

  • role="dialog"aria-modal="true"
  • 标题元素使用 aria-labelledby 与唯一 id
  • 初始焦点可在 opened 后聚焦首个可聚焦元素,复杂场景可引入 Focus Trap

与 Element Plus 的差异

  • 未覆盖拖拽、全屏、内置 ElOverlay 与更完整的 Focus Trap
  • 动画与样式为最小集,可根据设计体系扩展
  • 层级管理与滚动锁定为轻量实现,生产环境建议与应用全局管理统一

常见边界与坑位

  • 弹窗内长内容滚动需在 Panel 内部开启滚动而非解锁 body
  • 移动端需关注键盘弹起与视口变化导致定位偏移
  • Teleport 到 body 后应避免祖先 transform 干扰定位

总结

  • 通过最小可用实现可以完整还原 Element Plus Dialog 的核心交互与技术栈思路
  • 将能力拆解为组合式函数与明确事件流,利于扩展与维护
  • 在生产环境中进一步完善无障碍、动画体系、拖拽与更细粒度的交互参数即可对齐专业组件库体验

源码结构与能力矩阵

  • 能力划分:显隐控制、遮罩、焦点管理、滚动锁定、层级管理、动画、可访问性、销毁策略
  • 关键 Props:
    • modelValuemodalcloseOnClickModalcloseOnPressEscape
    • lockScrollappendToBodydestroyOnClosezIndexwidthcentershowClose
  • 关键事件:open/opened/close/closed,承载动画生命周期与业务监听

交互时序图(开合流程)

复制代码
用户触发 → 设置 modelValue=true
│
├─ open()
│   ├─ 锁滚动(可选)
│   ├─ 提升 z-index
│   └─ rendered=visible=true → 进入动画
│
└─ after-enter → opened 事件

关闭流程:
modelValue=false → close() → 离开动画 → after-leave
└─ 解锁滚动(可选) → destroyOnClose 卸载 → closed 事件

可访问性与焦点管理(增强版)

  • role="dialog"aria-modal="true"aria-labelledby 指向标题
  • 初始焦点:打开后将焦点置于弹窗容器或首个可聚焦元素
  • 焦点圈定:在弹窗打开时限制 Tab 循环到弹窗内,关闭后还原到触发源
  • 关闭按钮应具备 aria-label="Close",键盘可触达

升级版 Dialog.vue(加入焦点管理)

vue 复制代码
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import { useZIndex } from './useZIndex'
import { lockScroll, unlockScroll } from './useLockScroll'

// ... 省略 props 与 emits 定义(同前)

const panelRef = ref<HTMLElement | null>(null)
let prevActive: Element | null = null

async function open() {
  emit('open')
  if (props.lockScroll) lockScroll()
  z.next()
  rendered.value = true
  visible.value = true
  prevActive = document.activeElement
  await nextTick()
  panelRef.value?.focus()
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && props.closeOnPressEscape) emit('update:modelValue', false)
  if (e.key === 'Tab' && visible.value && panelRef.value) {
    const focusables = Array.from(panelRef.value.querySelectorAll<HTMLElement>(
      'a,button,input,textarea,select,[tabindex]:not([tabindex="-1"])'
    )).filter(el => !el.hasAttribute('disabled'))
    if (focusables.length === 0) return
    const first = focusables[0]
    const last = focusables[focusables.length - 1]
    const active = document.activeElement as HTMLElement
    if (e.shiftKey && active === first) { last.focus(); e.preventDefault() }
    else if (!e.shiftKey && active === last) { first.focus(); e.preventDefault() }
  }
}

function onAfterLeave() {
  if (props.lockScroll) unlockScroll()
  if (props.destroyOnClose) rendered.value = false
  // 还原焦点到触发源
  (prevActive as HTMLElement | null)?.focus?.()
  prevActive = null
  emit('closed')
}
</script>

<template>
  <Teleport to="body" v-if="props.appendToBody">
    <div v-if="props.modal && visible" class="dialog-overlay" :style="{ zIndex: z.value() - 1 }" @click="onOverlayClick" />
    <Transition name="dialog-fade" @after-enter="onAfterEnter" @after-leave="onAfterLeave">
      <div
        v-show="rendered && visible"
        class="dialog-panel"
        :style="panelStyle"
        role="dialog" aria-modal="true" :aria-labelledby="titleId"
        tabindex="-1" ref="panelRef"
      >
        <div class="dialog-header" :class="{ center: props.center }">
          <span :id="titleId">{{ props.title }}</span>
          <button v-if="props.showClose" class="dialog-close" aria-label="Close" @click="emit('update:modelValue', false)">✕</button>
        </div>
        <div class="dialog-body"><slot /></div>
        <div class="dialog-footer"><slot name="footer" /></div>
      </div>
    </Transition>
  </Teleport>
  <!-- 无 Teleport 场景可直接渲染到父容器 -->
</template>

堆叠管理与滚动锁定(并发场景)

  • 堆叠原则:后开先上;Overlay 使用 z-1 保持遮罩层级正确
  • 滚动锁并发:通过全局计数 lockCount 防止提前释放;关闭到计数为 0 才恢复 overflow
  • 建议引入全局 PopupManager 统一管理 z-index 与锁定,避免跨组件不一致

动画与卸载策略

  • 动画钩子承载事件流:进入后触发 opened;离开后触发 closed
  • destroy-on-close 减少常驻 DOM 的内存占用;保留时可加速再次打开
  • 遮罩与面板可分离过渡,支持更细致的动画编排

Teleport 注意事项

  • Teleport 到 body 消除祖先 overflow/transform 对定位的影响
  • 弹窗内部使用固定定位与居中策略,避免受页面滚动影响
  • SSR/同构时需在客户端激活后再计算滚动与焦点

单测建议(思路与片段)

  • 场景覆盖:遮罩点击关闭、ESC 关闭、滚动锁计数、层级递增、事件流顺序
  • 片段(以 Vitest/Vue Test Utils 为例):
ts 复制代码
import { mount } from '@vue/test-utils'
import Dialog from './Dialog.vue'

test('overlay click to close', async () => {
  const wrapper = mount(Dialog, { props: { modelValue: true } })
  await wrapper.vm.$nextTick()
  await wrapper.find('.dialog-overlay').trigger('click')
  expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})

test('esc to close', async () => {
  const wrapper = mount(Dialog, { props: { modelValue: true } })
  await wrapper.vm.$nextTick()
  document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
  expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})

工程化建议

  • 将样式与动画抽象为主题变量,避免硬编码宽度与阴影
  • 建立统一的 useZIndexuseLockScroll,供所有弹层类组件复用(Dialog/Drawer/MessageBox)
  • 关注移动端体验:全屏模式、底部安全区、键盘弹起适配
相关推荐
an86950011 小时前
vue新建项目
前端·javascript·vue.js
w***95492 小时前
SQL美化器:sql-beautify安装与配置完全指南
android·前端·后端
顾安r3 小时前
11.22 脚本打包APP 排错指南
linux·服务器·开发语言·前端·flask
万邦科技Lafite3 小时前
1688图片搜索商品API接口(item_search_img)使用指南
java·前端·数据库·开放api·电商开放平台
yinuo4 小时前
网页也懂黑夜与白天:系统主题自动切换
前端
Coding_Doggy4 小时前
链盾shieldchain | 项目管理、DID操作、DID密钥更新消息定时提醒
java·服务器·前端
用户21411832636024 小时前
dify案例分享-国内首发!手把手教你用Dify调用Nano Banana2AI画图
前端
wa的一声哭了4 小时前
Webase部署Webase-Web在合约IDE页面一直转圈
linux·运维·服务器·前端·python·区块链·ssh
han_5 小时前
前端性能优化之CSS篇
前端·javascript·性能优化