
目标:用最小可用实现拆解并还原 Element Plus 的 Dialog 核心交互与技术设计,包括显隐控制、遮罩、ESC 关闭、滚动锁定、层级管理、Teleport、过渡动画与可访问性。
v-model 双向绑定控制显隐modal 遮罩与点击遮罩关闭close-on-press-escape 键盘 ESC 关闭lock-scroll 打开时锁定页面滚动z-index 层级管理支持多弹窗叠加Teleport 将弹窗渲染到 bodydestroy-on-close 关闭时卸载内容open/opened/close/closedrole="dialog"、aria-modal、aria-labelledbyuseZIndex 管理层级,useLockScroll 管理滚动锁定Overlay + Panel,通过 Teleport 渲染到 bodyTransition 包裹 Panel,进入与离开过渡钩子承载事件流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 }
}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
}
}<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><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=true → open → 渲染内容 → 进入动画 → openedupdate:modelValue=false → close → 离开动画 → 解锁滚动 → closedcloseOnClickModal=true 时触发更新关闭closeOnPressEscape=true 时触发更新关闭z.next() 获取更高 z-indexz.value()-1,Panel 使用 z.value() 保持正确遮盖关系role="dialog" 与 aria-modal="true"aria-labelledby 与唯一 idopened 后聚焦首个可聚焦元素,复杂场景可引入 Focus TrapElOverlay 与更完整的 Focus Trapbodybody 后应避免祖先 transform 干扰定位modelValue、modal、closeOnClickModal、closeOnPressEscapelockScroll、appendToBody、destroyOnClose、zIndex、width、center、showCloseopen/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 指向标题aria-label="Close",键盘可触达<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>z-1 保持遮罩层级正确lockCount 防止提前释放;关闭到计数为 0 才恢复 overflowPopupManager 统一管理 z-index 与锁定,避免跨组件不一致opened;离开后触发 closeddestroy-on-close 减少常驻 DOM 的内存占用;保留时可加速再次打开body 消除祖先 overflow/transform 对定位的影响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()
})useZIndex 与 useLockScroll,供所有弹层类组件复用(Dialog/Drawer/MessageBox)