仿写优秀组件:还原 Element Plus 的 Dialog 弹窗核心逻辑
目标:用最小可用实现拆解并还原 Element Plus 的 Dialog 核心交互与技术设计,包括显隐控制、遮罩、ESC 关闭、滚动锁定、层级管理、Teleport、过渡动画与可访问性。
功能要点
v-model双向绑定控制显隐modal遮罩与点击遮罩关闭close-on-press-escape键盘 ESC 关闭lock-scroll打开时锁定页面滚动z-index层级管理支持多弹窗叠加Teleport将弹窗渲染到bodydestroy-on-close关闭时卸载内容- 过渡动画与开合事件流:
open/opened/close/closed - 可访问性:
role="dialog"、aria-modal、aria-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=true→open→ 渲染内容 → 进入动画 →opened - 关闭:
update:modelValue=false→close→ 离开动画 → 解锁滚动 →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:
modelValue、modal、closeOnClickModal、closeOnPressEscapelockScroll、appendToBody、destroyOnClose、zIndex、width、center、showClose
- 关键事件:
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()
})
工程化建议
- 将样式与动画抽象为主题变量,避免硬编码宽度与阴影
- 建立统一的
useZIndex与useLockScroll,供所有弹层类组件复用(Dialog/Drawer/MessageBox) - 关注移动端体验:全屏模式、底部安全区、键盘弹起适配