我写过几年 Vue 2也写过一阵子 React最后又落回 Vue 3。说实话差不多我遇到的十个人里八个写 Modal 的时候都栽过跟头要么弹层被父级容器的transform限制住fixed 失效要么关掉弹窗之后 body 滚动锁死要么 Loading 遮罩和弹窗的生命周期各管各的代码越写越乱。这个项目标题正好戳中我一直在琢磨的两个点用 TypeScript 把 Vue 3 的 Modal 组件写扎实以及把 Loading 组件的插入、销毁收归到 Modal 组件自身来管理。这两件事看起来是“封装技巧”实际上是把弹窗的生命周期边界理清楚。如果你现在正好在写 Vue 3 中后台项目或者想沉淀一套自己的组件库这篇文章应该能帮你省掉不少试错时间。1. 整体设计思路拆解弹窗难写的根源是生命周期归属1.1 Modal 难点不在模板而在“渲染位置”和“生命周期”先聊个基本问题Modal 到底难在哪很多人以为是样式其实不是。模板结构无非是遮罩、面板、关闭按钮、插槽内容样式抄一抄都很容易。真正难的是三件事。第一渲染位置。弹窗一般需要盖在整个页面之上但组件如果写在某个列表页的子组件里position: fixed很容易被父级元素的transform、filter、perspective拉进一个新的包含块结果弹窗不是相对于视口定位而是相对于父级定位表现就是“弹窗出现在组件内部还盖不住其他区域”。Vue 2 时代常见的解决办法是手动把弹窗 DOM 移到body下麻烦不说事件和样式还容易串。Vue 3 的teleport就是为此设计的。第二生命周期。弹窗有两个维度视图的开合和组件本身的创建销毁。v-if控制模板很容易但关联的东西——比如全局的 Esc 监听、body 滚动锁定、Loading 遮罩的挂载与销毁——都需要跟着弹窗一起走。一旦这些逻辑散落在业务组件里每次用一个弹窗都要复制粘贴一遍改一个地方漏两个地方。第三层级与状态隔离。多个弹窗叠加时后面的要盖住前面的遮罩弹窗关闭时内部表单状态是保留还是重置destroyOnClose为 true 时DOM 是立即销毁还是等过渡动画结束再销毁。这些细节直接决定用户体感。1.2 teleport 的新思路把弹窗当成独立的渲染树很多教程对 teleport 的讲解就停留在“teleport tobody搞定”这一步。但实际写组件库时teleport 的用法还能再往前一步它不只是把 DOM 移过去而是让你在组件树之外建立一棵独立的渲染分支但这棵分支的逻辑父子关系仍然属于当前组件。这句话怎么理解举个例子。组件里写了teleport tobodydiv classmodal.../div/teleport按钮点击触发的是当前组件的状态变化modal里的插槽内容可以正常访问当前组件的数据和方法。从 Vue 的响应式和依赖注入角度看弹窗就像是当前组件“远程投射”过去的一部分。这带来的好处是我可以把 Loading 遮罩、错误提示、确认框都通过同一个 teleport 目标投射出去它们在 DOM 上是兄弟节点但在逻辑上共享当前组件的状态不需要额外搞一个全局事件总线。实际项目中我倾向把 teleport 目标做成可配置项默认挂到body但也支持传入#app-modal-container这类容器。为什么因为某些特殊场景比如页面嵌在第三方 iframe 里或者某些桌面端框架要求弹窗渲染在特定节点下固定写死body反而被动。把to暴露成 prop就留出了扩展余地。1.3 TypeScript 在这里的真正价值类型即约束再说 TypeScript。标题里特意点出 TypeScript不是赶时髦。模态框组件的 props、事件、插槽比较多如果全靠 JS 写调用方很容易传错属性名或者漏掉必填事件。用 TS 定义后编辑器会直接提示每个 prop 的类型事件名也有约束重构时改类型一处生效。另外interface 的继承在这里特别顺手。热搜词里大家老在问 TypeScript 的 interface 怎么继承其实组件类型设计就是最好的练习场基础交互层定义modelValue、title展示层定义尺寸、位置、过渡动画加载扩展层定义showLoading、loadingText。你单独看每一层都简单组合到一起才是真正有用的完整类型。组件写多了你会发觉类型系统不是考古学而是工程设计的一部分。2. 核心细节解析与实操要点API 设计比写模板更重要2.1 组件 API 设计协议先行打开编辑器之前我会先把 Modal 的对外协议定下来。这不是走流程而是避免写着写着突然想加一个 prop结果和已有功能互相打架。先看基础 props 设计Prop类型默认值说明modelValuebooleanfalse控制开合配合v-model使用titlestring标题文本也可以走 header 插槽sizesmall | medium | large | numbermedium弹窗宽度数字表示像素closablebooleantrue是否显示右上角关闭按钮maskClosablebooleantrue点击遮罩是否关闭closeOnPressEscbooleantrue按 Esc 是否关闭appendTostring | HTMLElementbodyteleport 目标lockScrollbooleantrue是否锁定 body 滚动destroyOnClosebooleanfalse关闭时是否销毁内容showLoadingbooleanfalse是否显示内置 Loading 遮罩loadingTextstringLoading 文案zIndexnumber1000基础层叠值transitionNamestringmodal-fade过渡动画名关键点是用modelValue而不是visible。这是 Vue 3 组件库的通用约定组件内部emit(update:modelValue)外部就可以直接v-modelvisible不用自己维护一个onVisibleChange回调。事件方面除了update:modelValue之外我一般还会暴露open和close两个事件方便调用方在弹窗开合时做数据加载、埋点等操作。注意open事件在弹窗已经显示后触发close事件在开始关闭时触发不是等动画结束。插槽设计上default放主体内容header和footer分别覆盖头部和底部。为什么单独拆 header因为很多业务场景需要自定义标题栏——比如放一个 tag、一个 loading 状态图标——只靠title字符串根本不够用。2.2 teleport 使用的三个关键细节第一to属性默认是字符串body但实际项目中经常遇到“页面结构里已经有一个弹窗容器希望弹窗挂到那里”的情况。所以appendTo的入参类型我写成string | HTMLElement内部统一用resolveTarget解析。如果解析不到目标元素组件要给出警告日志而不是静默失败。第二teleport 的disabled属性。默认弹窗使用 teleport但在极少数场景——比如你在做一个可视化编辑器弹窗需要作为画布子元素参与导出——可以设置disabled让它原地渲染。注意原地渲染时position: fixed仍然有效只是变成了相对于视口但受父级包含块影响的定位。我通常在 prop 里加一个teleportDisabled?: boolean默认false。第三teleport 到同一个目标时后挂载的节点排在前面的后面。这在浏览器原生规则里就决定了后渲染的弹窗天然会盖住先渲染的弹窗相同 z-index 时。所以不要每个弹窗都去手动堆一个巨大的 z-index而是给一个基础值多个弹窗靠 DOM 顺序竞争。自定义zIndex只在特殊情况下用。2.3 TypeScript 类型如何用 interface 继承组织这是类型设计里我很喜欢的一部分。用继承把配置项分层避免一个接口里堆 20 个字段读起来全是?。// types.ts export type ModalSize small | medium | large | number export interface BaseModalProps { modelValue: boolean title?: string closable?: boolean maskClosable?: boolean } export interface ModalDisplayProps extends BaseModalProps { size?: ModalSize appendTo?: string | HTMLElement teleportDisabled?: boolean zIndex?: number transitionName?: string } export interface ModalLifecycleProps extends ModalDisplayProps { lockScroll?: boolean destroyOnClose?: boolean closeOnPressEsc?: boolean } export interface ModalLoadingProps extends ModalLifecycleProps { showLoading?: boolean loadingText?: string }这样设计的直接好处写业务组件时如果你只需要 Modal 的基础能力类型可以收窄到BaseModalProps要用 Loading再逐步扩展。接口本身也变成一份文档别人读类型定义就知道这个组件分几层能力。有时候还会用泛型辅助类型比如WithModalLoadingT这其实就是 TypeScript 里常见的“交叉类型 泛型约束”的小技巧export type WithModalLoadingT extends object T { showLoading?: boolean loadingText?: string }面试里常问的 interface 继承放到真实组件设计里就是这么用——不是背语法而是真的能减少重复、增加可读性。3. 实操过程与核心环节实现完整代码和 Loading 自我管理方案3.1 组件结构与模板实现老规矩先把目录结构定下来。我习惯按组件库的规范拆src/components/Modal/ ├── Modal.vue # 主组件 ├── Loading.vue # 内置 Loading 组件 ├── types.ts # 类型定义 └── index.ts # 导出入口主组件模板的核心部分用teleport包裹全部弹窗 DOM内部用Transition处理开合动画template teleport :toresolvedTarget :disabledteleportDisabled transition :nametransitionName after-leavehandleAfterLeave div v-ifisVisible classmodal-root :classrootClasses :stylerootStyles roledialog aria-modaltrue click.selfhandleMaskClick div classmodal-panel header v-iftitle || $slots.header classmodal-header slot nameheader span{{ title }}/span /slot button v-ifclosable classmodal-close aria-labelClose clickhandleClose × /button /header div classmodal-body slot/slot /div footer v-if$slots.footer classmodal-footer slot namefooter/slot /footer !-- Loading 遮罩随弹窗自身管理 -- teleport :toloadingTarget :disabled!showLoading div v-ifshowLoading classmodal-loading-mask :style{ zIndex: loadingZIndex } slot nameloadingContent div classmodal-loading-spinner/div p v-ifloadingText classmodal-loading-text{{ loadingText }}/p /slot /div /teleport /div /div /transition /teleport /template这里有几个细节值得展开。模板里的teleport可以嵌套使用。我把 Loading 遮罩也放进一个 teleport但目标默认可选一是body二是当前弹窗面板容器。为什么有两套选择因为有的设计里 Loading 要盖住整个页面比如提交表单时禁止一切操作有的设计里 Loading 只要盖住弹窗面板本身。通过loadingTarget暴露配置默认走“盖面板”的逻辑更稳妥。click.self只监听点击遮罩本身的情况点击面板内部不会触发关闭。这是很多人容易忽略的细节——直接在遮罩上clickhandleClose结果点击面板时冒泡到遮罩弹窗也跟着关了体验很差。3.2 脚本部分的 TypeScript 实现脚本部分我用script setup langts。先看核心状态与逻辑import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from vue import Loading from ./Loading.vue const props withDefaults(definePropsModalLoadingProps(), { title: , size: medium, closable: true, maskClosable: true, closeOnPressEsc: true, appendTo: body, lockScroll: true, destroyOnClose: false, showLoading: false, loadingText: , zIndex: 1000, transitionName: modal-fade, teleportDisabled: false }) const emit defineEmits{ update:modelValue: [visible: boolean] open: [] close: [] }()Vue 3.4 以后可以用defineModel直接替代modelValue的声明和update:modelValue的 emit代码更简洁。如果你用的是 3.4 以下就得手写modelValueprop update:modelValueemit。这里我按 3.4 兼容写法演示const visible defineModelboolean({ default: false }) const isVisible ref(false)visible是外部 v-model 绑定的值isVisible是内部控制 DOM 渲染的值。为什么要分两个因为过渡动画。如果v-model一变 false 就直接销毁 DOMleave动画会来不及播放。所以我的做法是visible变化后先让 DOM 保留等动画结束再决定是否销毁。watch(visible, (val) { if (val) { isVisible.value true nextTick(() { emit(open) bindGlobalEvents() if (props.lockScroll) lockBodyScroll() }) } else { emit(close) } }) function handleClose() { if (!visible.value) return visible.value false if (!props.destroyOnClose) { isVisible.value false } } function handleAfterLeave() { if (!visible.value) { isVisible.value false unbindGlobalEvents() unlockBodyScroll() } } function handleMaskClick() { if (props.maskClosable) handleClose() }Esc 关闭和 body 滚动锁定的实现function onKeydown(e: KeyboardEvent) { if (e.key Escape props.closeOnPressEsc visible.value) { handleClose() } } function bindGlobalEvents() { window.addEventListener(keydown, onKeydown) } function unbindGlobalEvents() { window.removeEventListener(keydown, onKeydown) } let openedModalCount 0 function lockBodyScroll() { openedModalCount 1 document.body.style.overflow hidden } function unlockBodyScroll() { openedModalCount Math.max(0, openedModalCount - 1) if (openedModalCount 0) { document.body.style.overflow } } onBeforeUnmount(() { unbindGlobalEvents() if (props.lockScroll) unlockBodyScroll() })这里必须提一个坑body 滚动锁定不能只做overflow: hidden就不管了。如果页面里有两个弹窗同时打开关掉一个就把 overflow 恢复另一个弹窗背后的页面就又能滚了。所以上面用了一个模块级计数openedModalCount只有归零时才真正解除锁定。关于 Esc 键还有个细节多个弹窗叠加时按一次 Esc 应该只关最上面那个。最可靠的做法是检查当前页面上所有.modal-root元素找到最后一个也就是 z 序最顶层的再关。上面代码只监听当前弹窗实例如果你在同一个页面开了两个 Modal 实例它们各自的 keydown 监听都会收到 Esc 事件导致两个都关掉。我的经验是在onKeydown里加一个判断只有当前弹窗是页面上最后一个.modal-root时才执行关闭function onKeydown(e: KeyboardEvent) { if (e.key ! Escape || !props.closeOnPressEsc || !visible.value) return const roots document.querySelectorAll(.modal-root) const lastRoot roots[roots.length - 1] const selfEl teleportContentRef.value?.parentElement?.querySelector(.modal-root) if (lastRoot ! selfEl) return handleClose() }这里teleportContentRef需要在模板里给根元素加ref。简单说就是“我只负责自己的关闭如果还有更上面那层弹窗让那层去响应”。3.3 核心亮点Loading 组件插入与销毁的自我管理现在聊标题里说的重点Loading 组件的插入和销毁集成在组件自身。常规写法下业务方使用一个带 Loading 的弹窗时通常要自己管三件事loadingVisible状态、Loading DOM 挂到哪、请求完成后销毁 Loading。这三件事散落在业务代码里复用性很差。我的目标是业务方只传showLoading组件内部自动完成挂载、销毁、层级管理。上面模板里我已经写了一个 teleport 方案用v-ifshowLoading控制。但如果你想做得更“黑盒”可以在组件内部手动管理 Loading 的 VNode 和渲染容器不依赖模板的v-if。这种思路的极处是Loading 组件完全脱离当前组件的模板响应系统用createVNoderender手动挂载和销毁生命周期和 Modal 强绑定。import { createVNode, render, shallowRef, type VNode } from vue import Loading from ./Loading.vue const loadingHost shallowRefHTMLElement | null(null) let loadingVNode: VNode | null null function mountLoading() { if (loadingHost.value) return const host document.createElement(div) host.className global-loading-host document.body.appendChild(host) loadingHost.value host loadingVNode createVNode(Loading, { text: props.loadingText, zIndex: (props.zIndex ?? 1000) 10 }) render(loadingVNode, host) } function unmountLoading() { if (loadingVNode loadingHost.value) { render(null, loadingHost.value) loadingHost.value.remove() loadingHost.value null loadingVNode null } } watch(() props.showLoading, (val) { if (val) mountLoading() else unmountLoading() }) onBeforeUnmount(() { unmountLoading() })这套方案的逻辑非常清晰Loading 不是业务方临时创建的浮层它是 Modal 组件的一部分。只要 Modal 还活着就能随时通过showLoading召唤 Loading 出来Modal 销毁Loading 必然跟着销毁不存在忘记清理的问题。为什么用shallowRef而不直接ref因为loadingHost保存的是一个 DOM 元素这个元素一旦被remove()移除再被响应式追踪没有意义用shallowRef避免无谓的深层代理开销。loadingVNode用普通变量保存每次操作直接赋值不需要响应式。这是性能洁癖但写组件库时这些习惯会让代码更干净。Loading 组件本身也可以写得非常简单核心是遮罩文案template div classloading-mask :style{ zIndex } div classloading-spinner/div p v-iftext classloading-text{{ text }}/p /div /template script setup langts defineProps{ text?: string zIndex?: number }() /script注意 Loading 的 z-index 要比弹窗基础zIndex高个 10 或 100 之类的余量确保出现在面板之上。这是我的习惯写法为了避免每次都要计算也可以直接传一个算好的值。3.4 样式、过渡动画和 scoped 的注意事项弹窗样式这里挑重点。首先遮罩和面板的基础定位用fixed但注意——如果appendTo指到 bodyfixed 没问题如果指到某个 transform 的父容器下并且 teleport 被禁用fixed 就会失效。所以我通常会在内部计算rootStyles时做个兜底const rootStyles computed(() { const base: Recordstring, string { zIndex: String(props.zIndex) } if (!props.teleportDisabled) { base.position fixed base.inset 0 } return base })其次遮罩的颜色、面板的圆角阴影这些每家设计规范不同不展开。重点说 scoped 样式为什么会让人困惑。teleport 把 DOM 移到 body 之后很多人以为 scoped 样式会失效因为“DOM 不在组件树里了”。其实这是误解。Vue 的 scoped 样式机制是给组件模板里的元素加上>