
上周被问到一个很常见的问题“一个确认删除的弹窗原生 JS 要写多少行”我反手给他发了一段不到二十行的 HTML然后他问我 JS 在哪里。我说这个场景不需要 JS或者更准确地说需要写的 JS 从几十行变成了一行。这是浏览器原生能力和 button 按钮协作的结果。现代浏览器里弹窗不再意味着必须由 JS 管理显隐状态、焦点、遮罩层和 ESC 键行为。dialog处理语义化对话框popover处理轻量浮层而 button 按钮从“触发者”变成了“完全控制者”。这篇文章聊的是我这一年里在真实项目中怎么用这套原生方案把 80% 的弹窗交互场景从 JS 里解放出来。适合那些还在手写弹窗组件、想简化代码量的前端开发者也适合刚入门、想搞明白“原生弹窗到底能不能用”的人。1. 我为什么决定把弹窗组件从项目里删掉1.1 手写弹窗的五宗罪大概三年前我维护过一个老后台管理系统里面有个全局弹窗组件功能很齐全遮罩、动画、ESC 关闭、焦点锁定加起来五十多行 JS这还不算 CSS 和模板。麻烦的不是第一次写而是后续维护。产品加一个“点击按钮打开弹窗”的交互我要确认事件绑定有没有清理、遮罩层级有没有被别的组件盖住、关闭后输入框要不要重置。每换一个场景我都要在那一大坨代码里找开关在哪。说白了手写弹窗的头痛来自几件事。一是层级管理弹窗盖在页面上新弹窗盖在旧弹窗上全靠 z-index 硬堆堆着堆着就出 bug。二是焦点管理键盘 Tab 应该只在弹窗内循环但如果不写 focus trap用户一按 Tab 就跳到背后页面去了。三是 ESC 键行为、点击遮罩关闭、关闭后的焦点恢复每一项都是隐藏的工作量。四是滚动穿透弹窗里滚动背后页面也跟着滚这是最容易被测试忽略的。五是状态残留关闭后 DOM 还留在那里下次打开忘记重置就出了脏数据。1.2 原生方案到底改了什么原生dialog和popover把这些事情都收走了。它们的共同点是进入了浏览器的顶层Top Layer这个层天然在所有页面内容之上不参与 z-index 竞争所以层级问题根本不存在。焦点管理也内置了showModal()打开的 dialog 会把焦点困在弹窗内关闭后自动还原到打开前的元素上ESC 能触发关闭而且这个行为也是浏览器兜底的。这意味着如果我现在再写类似的后台系统大多数弹窗根本不需要“弹窗组件”只需要一个dialog标签加一个 button剩下的交互逻辑交给浏览器。这套方案不是要取代复杂交互组件库而是把之前那 80% 本来就很简单的场景用更简单的方式解决掉。提示原生 dialog 适合传达明确信息、等待用户确认的场景popover 适合提示、浮层、轻交互。两者分工明确别混着用。2.dialog原生对话框语义最正的弹窗2.1 open 属性、show() 和 showModal() 的差别先看最基本的用法。一个 dialog 元素加不加open属性决定了它默认显不显示dialog open p默认直接显示的内容/p /dialog不带 open 的 dialog 默认隐藏要用 JS 调方法才显示。这里有三个入口open属性、show()、showModal()。很多人分不清后面两个show()是非模态方式弹窗显示以后背后的页面还可以正常点击交互也不会显示遮罩层showModal()是模态方式弹窗打开后页面其他部分被遮罩盖住默认不可交互而且只能通过弹窗内按钮关闭或close()方法关闭。实际项目里 90% 的“弹窗”需求都是模态的也就是showModal()。既然标题说零 JS那就得说清楚如果弹窗是页面加载时就要出现的比如公告、首次引导可以不加 open 属性而是在 button 上写一行内联onclickdialog.showModal()这算“一行 JS”但绝大多数项目的做法其实是给按钮挂事件逻辑量从几十行降到一行。若想真正做到零 JS请看后面 popover 那一节。2.2 form methoddialog按钮顺便把弹窗关了dialog 有一个隐藏大招内部表单可以指定methoddialog。这种情况下点任何 type 为 submit 的按钮都会触发“带着返回值关闭弹窗”。dialog idconfirmDialog p确定要删除这条记录吗/p form methoddialog button valuecancel取消/button button valueconfirm autofocus确认删除/button /form /dialog button onclickconfirmDialog.showModal()删除记录/button这个写法的价值在于弹窗的关闭、按钮取值、焦点默认落位autofocus全被浏览器处理了。点击“确认删除”后dialog 会关闭并且dialog.returnValue变成 confirm。如果你需要在关闭后做点业务逻辑只需要监听一次 close 事件confirmDialog.addEventListener(close, () { if (confirmDialog.returnValue confirm) { // 执行删除逻辑 } });所以“零 JS”严格说是“业务逻辑之外的零 JS”——展示、关闭、取值不再需要手工管理唯一保留的 JS 是响应结果的那一两行。2.3 cancel 事件和 close 事件的本质区别取消和关闭是两码事。cancel事件表示用户按了 ESC 键或者浏览器认为这是取消操作此时弹窗还没关闭你可以在事件里阻止比如防止用户误按 ESC 关掉填写了一半的对话框。close事件表示弹窗已经关闭完成此时再想阻止就晚了。我见过不少新手把关闭逻辑全塞在 cancel 里结果发现点击遮罩关闭时不触发逻辑漏了。规范的行为是ESC 会先触发 cancel如果不阻止默认行为紧接着触发 close此时returnValue才是真正能用的值。点击遮罩层本身默认不会关闭 dialog需要自己加逻辑这点和 popover 正好相反。3. popover真正零 JS 的按钮开关3.1 一个属性实现打开、关闭、切换如果说 dialog 还需要那一行 JS 来打开那么 popover 就是我真的可以拍着胸脯说“零 JS”的东西。它的核心是按钮属性popovertarget加上目标元素的popover属性纯 HTML 配对连内联 onclick 都不用写。button popovertargettip popovertargetactiontoggle查看提示/button div idtip popover p这是一段不需要 JS 的浮层内容/p button popovertargettip popovertargetactionhide关闭/button /divpopovertargetaction有三个值toggle默认、show、hide。toggle最常用——第一次点击打开再点一次关闭show只负责打开比如你想实现“点 A 打开、点 B 关闭”就可以给 B 挂hide。这套属性语义本来就按“按钮开关”设计的所以非常适合各种“点击按钮出现浮层”的场景。3.2 点击外部自动关闭这个默认行为值钱popover 默认是popoverauto模式它有三个内置行为点击弹层外部会关闭、按 ESC 会关闭、在顶层里和后出现的 popover 互斥打开后面一个会自动关前面一个。这三个行为不需要写任何代码对很多原型和内部工具来说已经够用了。要注意的是popovermanual模式。manual 模式下点击外部和 ESC 都不会自动关适合那种必须用户主动点确认的浮层比如“登录后才能继续”的提示。我记得早期项目里用 manual 做引导浮层省掉了不少“判断用户点哪里”的逻辑。auto/manual 的选择标准很简单浮层是临时提示就选 auto浮层需要用户明确回应就用 manual或者用 dialog 更合适。3.3 为什么 popover 比“自定义浮层”更值得信赖我至今还记得自己手写浮层时那些奇怪 bug点外部关闭时事件冒泡到触发按钮上又立刻打开、滚动时浮层位置没跟着滚、两个浮层同时显示导致遮罩错乱。popover 把这些场景全部收敛成浏览器行为因为它进入的是 Top Layer不占文档流不需要计算定位浏览器自己处理了“浮层在视口里的位置”以及和触发按钮的空间关系。虽然定位样式还是要自己写但你不必再监听 scroll/resize 去更新位置了。对于我这种“能用平台能力就绝不多写代码”的人来说这个改变是实打实的减负。4. 四种常见场景的零 JS 落地模板4.1 详情预览popover 浮层直接套后台列表最常见的操作是“查看详情”。以前的做法是弹窗组件里塞一堆字段其实大多数场景只需要一个浮层。用 popover 实现非常简单button popovertargetorderDetail订单详情/button div idorderDetail popovermanual h3订单号A10086/h3 p金额¥299.00/p button popovertargetorderDetail popovertargetactionhide关闭/button /div我用 manual 模式是因为详情必须用户主动关闭避免误触外部把详情点没了。这种浮层不需要读取返回值也不需要动态塞数据数据在页面里本来就有配合模板渲染或动态渲染完全够用。4.2 危险操作确认dialog methoddialog 是标准答案删除、封禁、重置这类危险操作要做二次确认。我通常这样写button onclickdeletePost.showModal()删除文章/button dialog iddeletePost p删除后不可恢复确定继续/p form methoddialog button valuecancel再想想/button button valueok autofocus删除/button /form /dialog配合那一行 close 监听读取 returnValue 为 ok 时执行删除。整个过程代码量极少而且键盘用户可以直接按 Tab 到确认按钮回车触发无障碍基础是浏览器自带的。autofocus 放在“删除”上是为了防止用户习惯性回车直接删掉如果希望更保守就把 autofocus 放“取消”上这个可以根据产品习惯调。4.3 表单内联校验required 直接生效在 dialog 里放表单提交按钮在约束校验失败时不会关闭弹窗dialog idloginDialog form methoddialog input nameemail typeemail required placeholder邮箱 button valuesubmit登录/button /form /dialog这个方法最大的价值在于required、typeemail 这些约束验证是浏览器原生行为校验失败会有气泡提示不需要自己写校验逻辑校验通过后才触发 dialog 关闭。我以前用传统弹窗时表单校验要引入第三方库或者手写正则现在原生就够了。如果业务校验更复杂可以在一定条件下阻止默认提交然后自己处理。4.4 轻提示结合动画做成自动消失的效果popover 本身不会自动消失但它支持动画。第一种方式是用 CSS 动画 4 秒后自动隐藏这算是 CSS 技巧不是 JS第二种是监听 toggle 事件里面放一个定时器延迟 4 秒调用hidePopover()。这两种在“轻提示”场景都常见第一种零 JS更贴合本文主题keyframes autoHide { 0%, 75% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } } #toast { animation: autoHide 4s forwards; }只有当 popover 打开时动画触发4 秒后 opacity 变 0视觉上消失。严格说元素还在 Top Layer但对用户来说已经看不到了如果很介意可以用 toggle 事件定时隐藏。我在内部工具里大量用这个方案做“保存成功”提示简单粗暴。5. 样式定制让原生弹窗看起来像设计稿5.1 干掉默认外观和居中方式dialog默认在视口居中宽度撑满内容还有一圈浏览器默认边框。要改样式最直接的是dialog { border: none; border-radius: 8px; padding: 24px; width: min(420px, 90vw); }如果你希望它不居中、固定在某个角落可以直接用 position 定位比如position: fixed; top: 20px; left: 50%; transform: translateX(-50%);。需要注意的是dialog 一旦用 position 移出默认居中遮罩层的大小仍然覆盖整个视口这点不受影响。popover 默认是定位在触发按钮附近的需要手动设置位置时也直接用 position 或 margin 控制。5.2 遮罩层 ::backdrop 的各种玩法::backdrop是唯一一个用于 Top Layer 元素的伪元素dialog 用 showModal 打开时会出现popover 默认也有Safari 17.4、Chrome 114 已支持。以前遮罩层要单独写 div现在伪元素搞定dialog::backdrop { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); }加了backdrop-filter: blur(4px)背景虚化效果很容易做出来这是不少设计稿里的“毛玻璃遮罩”。还能做渐变、加动画比如让遮罩淡入淡出和弹窗本身的过渡动画一起配合。5.3 零 JS 的入场出场动画原生弹窗从 display:none 到 display:block 的切换默认不能做 transition但是现代 CSS 提供了starting-style和allow-discrete这两个关键字可以在不写一行 JS 的情况下让弹窗和遮罩层做淡入淡出dialog { transition: opacity 0.3s, transform 0.3s, display 0.3s allow-discrete; opacity: 0; transform: translateY(20px); } dialog[open] { opacity: 1; transform: translateY(0); } starting-style { dialog[open] { opacity: 0; transform: translateY(20px); } }要点在于transition里要带上display 0.3s allow-discrete以及starting-style是定义起始状态的。这个写法在 Chrome 117、Safari 17.5、Firefox 129 都好使。如果目标浏览器不支持退回一步就直接不做过渡弹窗的显示关闭至少是正确的比动画花哨但交互出错强。6. 避坑清单原生弹窗的边界问题6.1 兼容性现状与降级思路没有任何理由装成“所有浏览器都支持”。dialog 在 Safari 15.4、Chrome 37、Firefox 98 已经很好用popover 相对新一些Chrome 114、Edge 114、Firefox 125、Safari 17。老浏览器要降级的话我的思路是核心功能用 dialog老浏览器把open属性当普通块级元素渲染内容还能看popover 则在检测不到popover属性时用简单的隐藏/显示 class 兜底业务不要依赖自动关闭。6.2 滚动穿透弹窗打开后 body 还能滚dialog 的模态框并不天生阻止背景滚动在部分浏览器上打开弹窗后滚动鼠标页面背景还是会动。最常见也最直接的方案是给 body 加 overflow:hidden打开时锁住关闭时恢复这算弹窗场景下我唯一默认接受的 JS。popover 没有模态概念滚动穿透问题更明显如果不希望背景滚动同样锁 body或者给弹层加overscroll-behavior: contain让滚动链不要传导到父级。6.3 popover 的焦点缺口键盘用户需要注意popover 打开后焦点默认还停在触发按钮上不会自动进入弹层。对于只读浮层问题不大但如果是表单浮层键盘用户要按 Tab 才能进弹层操作路径有点绕。解决办法很简单给弹层里第一个可交互元素加autofocus。此外popover 没有内置 focus trap如果弹层里有输入用户 Tab 还是能跑到背后页面这属于“popover 本身不是为了模态设计的”的边界我一般建议弹出表单用 dialogpopover 只放提示和只读内容。6.4 多弹层嵌套与自动互斥auto 模式的 popover 之间有“后者关前者”的互斥行为这在构建二级菜单时会表现出“打开子菜单父菜单关闭”的效果有时符合预期有时不是。如果希望多个浮层同时存在就要给它们设成 manual 模式。而 dialog 之间没有这种互斥可以嵌套嵌套时焦点还是会正确限制在最上层 dialog 里这个体验比我想象中好但嵌套层级太深对用户不友好能不套就不套。6.5 移动端和真机测试不能省弹窗在桌面浏览器上看着正常不代表手机上没事。dialog 在小屏端默认宽度可能超出视口要设置max-width: 100%popover 的“点击外部关闭”在触摸屏上是“点弹层外的任意处关闭”对移动端交互很自然但要注意误触。真机上格外留意弹层在软键盘弹出后的位置有些浏览器会滚到输入框处弹层如果 fixed 定位可能被顶上去或者盖住输入框。建议移动端适配时把弹层宽度和 padding 都写成 viewport 相关的相对值。我自己的经验是凡是涉及原生弹窗的项目上线前至少要在一台旧版本 Chrome、一台 Safari、一台安卓手机上真实点一遍。这个成本远小于用户群里来一句“弹窗打不开”再回头排查。原生弹窗不是玩具它把交互控制权还给了 HTML 和浏览器你只要选对方案代码量自然就下来了。