ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端模态框实战:焦点陷阱、滚动锁定与无障碍访问

前端模态框实战:焦点陷阱、滚动锁定与无障碍访问

1. 从“弹窗”到“模态框”:一个被低估的前端基础组件

很多人第一次接触前端开发,写的第一个交互组件就是弹窗。点一下按钮,屏幕上浮出一块内容,再点一下关闭,完事。看起来简单到不值一提,但真正在项目里用过几年之后你会发现,模态框(Modal)是那种“写出来只要半小时,写好却要半年”的典型组件。它牵扯到的东西远比表面看到的要多:焦点管理、滚动锁定、层级堆叠、无障碍访问、动画过渡、移动端适配、键盘交互,每一项单独拎出来都够写一篇长文。

我见过太多项目,模态框的实现方式就是随手写一个position: fixed的 div,加个半透明遮罩,然后display: none和display: block来回切。功能上确实能用,但一旦遇到复杂场景就开始出问题:打开模态框后背景还能滚动、按 Tab 键焦点跑到了背后的页面上、多个模态框叠加时层级乱掉、移动端弹出时页面跳动、关闭后焦点丢失导致键盘用户找不到位置。这些问题在开发阶段往往不会被发现,等到测试或者上线后才暴露出来,修起来又牵一发动全身。

所以这篇内容我想把模态框这个东西从头到尾拆一遍。不管你是刚入门前端不久,还是已经写过不少组件但总觉得自己的模态框“差点意思”,应该都能从中找到一些有用的东西。我会从最基础的 HTML 结构讲起,逐步深入到焦点陷阱、滚动锁定、无障碍属性、动画方案、多实例管理等实战细节,并且解释每一个决策背后的原因,而不是只丢一段代码让你复制。

需要说明的是,下面涉及的具体实现方案是基于我在多个项目中反复验证过的常见实践总结出来的,不同技术栈和业务场景下会有不同的取舍,你可以根据自己的实际情况调整。

2. 模态框的本质:它到底解决了什么问题

2.1 模态框与非模态浮层的核心区别

在动手写代码之前,有必要先把概念理清楚。很多人把“弹窗”和“模态框”混为一谈,实际上它们有本质区别。

模态框(Modal)的核心特征是“模态”——当它出现时,用户必须对它做出响应,才能继续操作页面的其他部分。它会在当前页面上方创建一个“阻断层”,把用户的注意力强制聚焦到模态框内部。典型的例子是删除确认对话框、登录表单弹窗、重要通知提示。

非模态浮层(Non-modal Popover / Tooltip)则不会阻断用户对页面其他区域的操作。比如下拉菜单、气泡提示、侧边通知栏,用户可以在浮层显示的同时继续点击页面其他位置。

这个区别直接决定了实现方式的不同。模态框需要处理焦点陷阱(focus trap)、背景滚动锁定、遮罩层点击关闭等逻辑,而非模态浮层通常不需要这些。如果你用实现非模态浮层的思路去做模态框,就会出现前面提到的那些问题。

从无障碍访问的角度看,模态框在 WAI-ARIA 规范中有明确的角色定义:role="dialog"或role="alertdialog"。前者用于一般的对话框,后者用于需要用户立即关注的警告类对话框。同时还需要配合aria-modal="true"来告诉辅助技术,当前内容之外的部分是惰性的。

2.2 浏览器原生方案:dialog 元素的能与不能

HTML 规范中其实已经提供了一个原生模态框元素:<dialog>。它通过showModal()方法打开时会自动获得模态行为,包括焦点陷阱、Esc 关闭、背景惰性化等。听起来很美好,但实际使用中还是有一些限制需要了解。

<dialog id="myDialog"> <h2>确认操作</h2> <p>你确定要执行这个操作吗?</p> <button id="confirmBtn">确认</button> <button id="cancelBtn">取消</button> </dialog>
const dialog = document.getElementById('myDialog'); const confirmBtn = document.getElementById('confirmBtn'); const cancelBtn = document.getElementById('cancelBtn'); // 打开模态框 document.getElementById('openBtn').addEventListener('click', () => { dialog.showModal(); }); // 关闭 confirmBtn.addEventListener('click', () => dialog.close('confirmed')); cancelBtn.addEventListener('click', () => dialog.close('cancelled')); // 监听关闭事件 dialog.addEventListener('close', () => { console.log('关闭原因:', dialog.returnValue); });

<dialog>的优点很明显:原生支持、代码量少、浏览器自动处理焦点和 Esc 键。但它的局限性也需要知道:

  • 样式定制受限:::backdrop伪元素可以定制遮罩,但动画控制不如自定义方案灵活。
  • 浏览器兼容性:现代浏览器支持已经很好,但如果需要兼容较老的环境,可能需要 polyfill。
  • 嵌套模态框:多个<dialog>叠加时的行为在不同浏览器中表现不完全一致。
  • 动画过渡:dialog的显示和隐藏是瞬时的,要做入场/出场动画需要额外处理。

我的建议是:如果你的项目不需要兼容很老的环境,且模态框的交互需求不复杂,优先考虑<dialog>。如果需要对动画、层级、嵌套有精细控制,自定义方案会更灵活。

2.3 什么时候不该用模态框

这是一个经常被忽略的问题。模态框是一种“打断式”的交互模式,它会强制中断用户当前的流程。滥用模态框会严重影响用户体验。

以下场景通常不适合用模态框:

  • 展示补充信息:比如帮助说明、详细参数,用非模态的侧边栏或展开面板更合适。
  • 频繁触发的操作:如果用户需要反复打开关闭某个面板,模态框的打断感会让人烦躁。
  • 移动端的大量内容:在小屏幕上,模态框的可用空间有限,全屏页面或底部抽屉(bottom sheet)可能是更好的选择。
  • 非紧急的通知:用 toast 或通知栏就够了,不需要打断用户。

判断标准很简单:用户是否必须立即对这个内容做出响应?如果是,用模态框;如果不是,考虑其他方案。

3. 手写一个靠谱的模态框:结构、样式与交互的完整拆解

3.1 HTML 结构设计:语义化与可访问性并重

一个结构良好的模态框 HTML 应该包含以下几个部分:

<!-- 触发按钮 --> <button type="button">/* 模态框容器:固定定位,铺满视口 */ .modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; } /* 遮罩层 */ .modal__overlay { position: absolute; inset: 0; background-color: rgba(0, 0, 0, 0.5); backdrop-filter: blur(2px); } /* 内容区域 */ .modal__content { position: relative; z-index: 1; width: 100%; max-width: 520px; max-height: calc(100vh - 32px); overflow-y: auto; background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); display: flex; flex-direction: column; } /* 头部 */ .modal__header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px 0; } /* 主体:可滚动 */ .modal__body { padding: 16px 24px; overflow-y: auto; flex: 1; } /* 底部 */ .modal__footer { display: flex; justify-content: flex-end; gap: 12px; padding: 16px 24px 20px; border-top: 1px solid #eee; }

这里有几个容易踩坑的地方:

inset: 0的兼容性:inset是top/right/bottom/left的简写,现代浏览器支持良好。如果需要兼容较老的环境,展开写四个属性即可。

max-height: calc(100vh - 32px):这个计算确保模态框内容不会超出视口高度。减去 32px 是因为容器有 16px 的 padding(上下各 16px)。这样当内容很长时,.modal__body会出现滚动条,而不是整个模态框溢出屏幕。

z-index的管理:模态框的z-index设为 1000 是一个约定俗成的值。但如果项目中还有其他固定定位的元素(比如顶部导航栏、侧边栏),需要确保模态框的层级高于它们。建议在项目中维护一个层级变量表:

层级用途z-index 值说明
普通内容1默认文档流
悬浮按钮100固定定位的操作按钮
顶部导航500固定顶栏
模态框遮罩1000模态框容器
模态框内容1001模态框内部内容
全局提示2000Toast、全局通知

动画过渡:用 CSS transition 实现入场和出场动画。推荐用opacity配合transform: scale()或translateY(),因为这两个属性可以由 GPU 加速,性能更好。

.modal { opacity: 0; transition: opacity 0.25s ease; } .modal.is-open { opacity: 1; } .modal__content { transform: translateY(16px) scale(0.98); transition: transform 0.25s ease; } .modal.is-open .modal__content { transform: translateY(0) scale(1); }

注意:出场动画需要在关闭时先移除.is-open类,等 transition 结束后再设置hidden。这需要 JS 配合监听transitionend事件,或者用setTimeout估算时间。

3.3 JavaScript 交互逻辑:打开、关闭与状态管理

下面是一个完整的模态框控制类,涵盖了打开、关闭、遮罩点击、Esc 键关闭等核心逻辑:

class Modal { constructor(element) { this.el = element; this.overlay = element.querySelector('.modal__overlay'); this.content = element.querySelector('.modal__content'); this.closeTriggers = element.querySelectorAll('[data-modal-close]'); this.isOpen = false; this.previousActiveElement = null; this._bindEvents(); } _bindEvents() { // 关闭触发器 this.closeTriggers.forEach(trigger => { trigger.addEventListener('click', () => this.close()); }); // Esc 键关闭 this._onKeydown = (e) => { if (e.key === 'Escape' && this.isOpen) { this.close(); } }; document.addEventListener('keydown', this._onKeydown); } open() { if (this.isOpen) return; // 记录当前焦点元素,关闭时恢复 this.previousActiveElement = document.activeElement; // 显示模态框 this.el.hidden = false; // 强制重绘,确保 transition 生效 this.el.offsetHeight; this.el.classList.add('is-open'); this.isOpen = true; // 锁定背景滚动 this._lockScroll(); // 将焦点移入模态框 this._focusFirstElement(); } close() { if (!this.isOpen) return; this.el.classList.remove('is-open'); this.isOpen = false; // 等待动画结束后隐藏 const onTransitionEnd = () => { this.el.hidden = true; this.content.removeEventListener('transitionend', onTransitionEnd); }; this.content.addEventListener('transitionend', onTransitionEnd); // 恢复背景滚动 this._unlockScroll(); // 恢复焦点 if (this.previousActiveElement) { this.previousActiveElement.focus(); } } _lockScroll() { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; document.body.style.overflow = 'hidden'; document.body.style.paddingRight = `${scrollbarWidth}px`; } _unlockScroll() { document.body.style.overflow = ''; document.body.style.paddingRight = ''; } _focusFirstElement() { const focusable = this.content.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusable) { focusable.focus(); } else { this.content.setAttribute('tabindex', '-1'); this.content.focus(); } } destroy() { document.removeEventListener('keydown', this._onKeydown); } }

这段代码里有几个值得展开说的细节:

this.el.offsetHeight强制重绘:当我们把hidden移除后立即添加.is-open类,浏览器可能会把这两次样式变更合并成一次,导致 transition 不生效。读取offsetHeight会强制浏览器重新计算布局,确保hidden移除后的初始状态被渲染,然后再添加.is-open触发过渡。

滚动条宽度补偿:当body设置overflow: hidden时,滚动条消失会导致页面内容向右跳动。通过计算滚动条宽度并给body添加等量的padding-right,可以避免这个跳动。这是一个很小的细节,但在实际项目中非常影响体验。

焦点恢复:关闭模态框后,把焦点恢复到打开之前的元素上。这对键盘用户非常重要——如果没有这一步,关闭模态框后焦点会回到body,用户需要重新 Tab 很多次才能回到原来的位置。

4. 焦点陷阱:模态框无障碍的核心战场

4.1 为什么焦点陷阱是必须的

焦点陷阱(Focus Trap)是模态框最核心的无障碍特性。它的作用是:当模态框打开时,用户的 Tab 键焦点被“困”在模态框内部,不会跑到背后的页面上。

为什么这很重要?想象一下你是一个只用键盘操作的用户。你打开了一个登录模态框,按 Tab 键想在“用户名”“密码”“登录按钮”之间切换。如果没有焦点陷阱,Tab 几次之后焦点就跑到了模态框背后的页面元素上,你根本看不到焦点在哪里,也无法完成登录操作。这就是焦点陷阱存在的意义。

实现焦点陷阱的基本思路是:监听keydown事件,当用户按下 Tab 键时,判断当前焦点是否在模态框的第一个或最后一个可聚焦元素上,如果是,则手动把焦点循环到另一端。

4.2 可聚焦元素的识别与边界处理

首先需要知道哪些元素是可聚焦的。标准如下:

const FOCUSABLE_SELECTORS = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]' ].join(',');

注意几个细节:

  • a标签必须有href属性才可聚焦。
  • 禁用的表单元素不可聚焦。
  • tabindex="-1"的元素可以通过 JS 聚焦,但不参与 Tab 顺序。
  • contenteditable="true"的元素也是可聚焦的。

另外,还需要过滤掉不可见的元素(display: none、visibility: hidden、hidden属性等),因为不可见元素虽然匹配选择器,但实际上无法聚焦。

function getFocusableElements(container) { const elements = container.querySelectorAll(FOCUSABLE_SELECTORS); return Array.from(elements).filter(el => { return el.offsetParent !== null || el === document.activeElement; }); }

offsetParent !== null是一个常用的可见性检测方法。如果元素的offsetParent为null,说明它或者它的祖先元素被隐藏了。

4.3 完整的焦点陷阱实现

_trapFocus(e) { if (e.key !== 'Tab') return; const focusable = getFocusableElements(this.content); if (focusable.length === 0) { e.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey) { // Shift + Tab:如果焦点在第一个元素上,跳到最后一个 if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { // Tab:如果焦点在最后一个元素上,跳到第一个 if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }

把这个方法绑定到模态框容器的keydown事件上即可。注意是绑定到模态框容器而不是document,这样可以避免影响模态框外部的键盘操作。

还有一个边界情况:如果模态框内没有任何可聚焦元素怎么办?这时候应该给内容区域设置tabindex="-1"并聚焦它,确保焦点至少在模态框内部。

注意:焦点陷阱只应该在模态框打开时生效。如果页面上有多个模态框叠加,只有最顶层的模态框应该 trap 焦点。这需要在多实例管理中处理。

5. 滚动锁定与移动端适配的坑

5.1 body 滚动锁定的三种方案对比

模态框打开时锁定背景滚动,这个需求看起来简单,但实现方案各有优劣:

方案实现方式优点缺点
overflow: hiddenbody { overflow: hidden }简单直接滚动条消失导致页面跳动;iOS Safari 上可能无效
position: fixedbody { position: fixed; top: -scrollY }iOS 上有效需要记录和恢复滚动位置;页面结构变化
touchmove 阻止监听touchmove并preventDefault精确控制需要区分模态框内外的滚动;影响性能

我在项目中用得最多的是第一种方案配合滚动条宽度补偿。对于大多数桌面端场景,这已经足够了。但如果你的用户主要在 iOS Safari 上,可能需要考虑第二种方案。

第二种方案的核心逻辑是:

_lockScroll() { this.scrollY = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${this.scrollY}px`; document.body.style.width = '100%'; } _unlockScroll() { document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; window.scrollTo(0, this.scrollY); }

这种方案的问题是,当body变成fixed后,页面会跳到顶部,然后通过top负值把它“拉”回原来的位置。关闭时需要恢复所有样式并滚回原位。如果处理不当,用户会看到页面闪烁。

5.2 iOS Safari 的滚动穿透问题

在 iOS Safari 上,即使body设置了overflow: hidden,如果模态框内容本身可以滚动,当用户滚动到模态框内容的顶部或底部时继续滑动,滚动行为会“穿透”到背景页面。这是一个非常经典的移动端问题。

解决方案是给模态框内容区域添加overscroll-behavior: contain:

.modal__body { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }

overscroll-behavior: contain告诉浏览器,当滚动到达边界时不要传播到父级滚动容器。-webkit-overflow-scrolling: touch启用 iOS 上的惯性滚动,提升滚动手感。

如果overscroll-behavior不够用(在某些老版本 iOS 上确实不够),可以在模态框打开时给背景内容添加touchmove阻止:

_onTouchMove(e) { // 如果触摸目标在模态框内容内,允许滚动 if (this.content.contains(e.target)) return; e.preventDefault(); }

5.3 移动端模态框的布局策略

在移动端,模态框的布局需要重新考虑。桌面端常用的居中弹窗在手机上可能显得局促。几种常见的移动端策略:

底部抽屉(Bottom Sheet):模态框从屏幕底部滑出,占据下半部分或全屏。这种模式在移动端非常流行,因为拇指操作区域在屏幕下方,用户更容易触达。

@media (max-width: 640px) { .modal { align-items: flex-end; padding: 0; } .modal__content { max-width: 100%; border-radius: 16px 16px 0 0; max-height: 85vh; transform: translateY(100%); } .modal.is-open .modal__content { transform: translateY(0); } }

全屏模式:对于内容较多的模态框(比如表单填写),在移动端直接全屏显示,避免内容被截断。

安全区域适配:iPhone 有底部横条(Home Indicator),模态框底部需要留出安全距离:

.modal__footer { padding-bottom: calc(20px + env(safe-area-inset-bottom)); }

env(safe-area-inset-bottom)会返回设备底部安全区域的尺寸,在支持刘海屏的设备上自动生效。

6. 多模态框叠加与层级管理

6.1 多个模态框同时存在的场景

实际项目中,多个模态框叠加的情况并不少见。比如:一个设置面板里点击“高级选项”打开另一个模态框,或者在确认对话框上再弹出一个提示框。

多模态框管理的核心问题是:只有最顶层的模态框应该响应用户的交互。具体来说:

  • 只有最顶层的模态框应该 trap 焦点。
  • 只有最顶层的模态框应该响应 Esc 键。
  • 只有最顶层的模态框的遮罩层应该可见(或者所有遮罩层叠加,但视觉上要合理)。
  • 关闭最顶层后,焦点应该回到它打开前的元素(也就是下层模态框中的某个元素)。

6.2 用栈结构管理模态框实例

用一个栈(数组)来管理当前打开的模态框实例是最自然的方案:

const modalStack = []; function openModal(modalInstance) { modalStack.push(modalInstance); modalInstance.open(); updateZIndex(); } function closeModal(modalInstance) { const index = modalStack.indexOf(modalInstance); if (index > -1) { modalStack.splice(index, 1); } modalInstance.close(); updateZIndex(); } function getTopModal() { return modalStack[modalStack.length - 1] || null; } function updateZIndex() { modalStack.forEach((modal, i) => { modal.el.style.zIndex = 1000 + i * 10; }); }

Esc 键的处理需要调整:只有栈顶的模态框才响应 Esc。

document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; const topModal = getTopModal(); if (topModal) { topModal.close(); } });

焦点陷阱同理,只有栈顶模态框的_trapFocus应该生效。可以在_trapFocus开头加一个判断:

_trapFocus(e) { if (getTopModal() !== this) return; // ... 其余逻辑 }

6.3 遮罩层叠加的视觉处理

当多个模态框叠加时,如果每个模态框都有自己的遮罩层,视觉上会出现多层半透明黑色叠加,越往下的模态框看起来越暗。这其实是一个合理的视觉效果——它暗示了层级关系。但需要注意遮罩层的z-index要正确设置,确保每个遮罩层都在其对应模态框内容的下方、下层模态框内容的上方。

如果不想让遮罩层叠加变暗,可以只保留最顶层模态框的遮罩层可见:

function updateOverlays() { modalStack.forEach((modal, i) => { const isTop = i === modalStack.length - 1; modal.overlay.style.display = isTop ? '' : 'none'; }); }

两种方案各有优劣,取决于你的设计需求。我个人的偏好是保留所有遮罩层,因为这样层级关系更直观。

7. 从实战中总结的避坑清单与经验心得

7.1 那些只有踩过才知道的细节问题

问题一:模态框打开时页面跳动

前面提到的滚动条宽度补偿是解决方案。但还有一种情况:如果页面内容高度不足以产生滚动条,打开模态框后overflow: hidden不会引起跳动。所以补偿逻辑需要判断当前是否有滚动条:

_lockScroll() { const hasScrollbar = document.documentElement.scrollHeight > document.documentElement.clientHeight; if (hasScrollbar) { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; document.body.style.paddingRight = `${scrollbarWidth}px`; } document.body.style.overflow = 'hidden'; }

问题二:transitionend 不触发

如果模态框内容在动画过程中被隐藏(比如快速连续打开关闭),transitionend可能不会触发,导致hidden永远不被设置。解决方案是加一个兜底定时器:

close() { // ... const duration = 300; // 与 CSS transition 时长匹配 const timer = setTimeout(() => { this.el.hidden = true; }, duration); this.content.addEventListener('transitionend', () => { clearTimeout(timer); this.el.hidden = true; }, { once: true }); }

问题三:模态框内的 iframe 抢焦点

如果模态框内嵌了 iframe(比如嵌入视频、第三方表单),iframe 内的元素也会参与焦点链。焦点陷阱需要把 iframe 当作一个可聚焦元素处理,但无法控制 iframe 内部的 Tab 行为。这种情况下,通常建议在 iframe 加载完成后把焦点移到 iframe 上,让用户主动进入。

问题四:Safari 上 backdrop-filter 的性能问题

backdrop-filter: blur()在 Safari 上可能导致滚动卡顿,尤其是模态框内容较长时。如果遇到性能问题,可以考虑去掉模糊效果,或者只在桌面端启用。

7.2 模态框组件的 API 设计建议

如果你要把模态框封装成可复用的组件,API 设计需要考虑以下几点:

命令式 vs 声明式:命令式 API(modal.open()/modal.close())适合需要精确控制的场景;声明式 API(通过属性控制显示隐藏)适合框架驱动的开发方式。两者可以结合,底层用命令式,上层用声明式包装。

事件回调:提供onOpen、onClose、onConfirm、onCancel等回调,让使用方能在关键节点插入逻辑。

配置项:以下配置项在实战中比较常用:

配置项类型默认值说明
closeOnOverlaybooleantrue点击遮罩是否关闭
closeOnEscbooleantrue按 Esc 是否关闭
lockScrollbooleantrue是否锁定背景滚动
initialFocusstring/Elementnull打开时聚焦的元素
returnFocusbooleantrue关闭时是否恢复焦点
animationDurationnumber250动画时长(毫秒)

销毁方法:提供destroy()方法,移除所有事件监听,避免内存泄漏。这在单页应用的路由切换场景中尤为重要。

7.3 测试模态框时容易遗漏的检查项

最后分享一份我在测试模态框时使用的检查清单,覆盖了常见的遗漏点:

  • 打开模态框后,Tab 键是否只在模态框内循环?
  • Shift + Tab 是否也能正确循环?
  • 按 Esc 是否能关闭?关闭后焦点是否回到触发按钮?
  • 点击遮罩层是否能关闭?点击模态框内容区域是否不会关闭?
  • 模态框打开时,背景页面是否无法滚动?
  • 关闭模态框后,背景页面滚动是否恢复正常?
  • 模态框内容超出视口时,内容区域是否可滚动?
  • 移动端打开模态框时,页面是否跳动?
  • 多个模态框叠加时,Esc 是否只关闭最顶层?
  • 屏幕阅读器是否能正确朗读模态框标题和内容?
  • 模态框内的表单元素是否可以通过键盘正常操作?
  • 快速连续打开关闭模态框,是否会出现状态异常?

这份清单看起来长,但每一条都对应着真实项目中遇到过的问题。尤其是焦点管理和滚动锁定这两块,在开发阶段很容易被忽略,等到测试阶段才发现,修复成本会高很多。

模态框这个组件,说简单也简单,说复杂也复杂。关键是要理解它背后的交互逻辑和无障碍要求,而不是只关注视觉呈现。把焦点管理、滚动锁定、层级控制这几块做扎实了,你的模态框就已经超过了大多数项目里的实现水平。剩下的动画效果、样式定制,都是锦上添花的事情。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进