用原生 dialog 重写整个弹窗系统:删掉 2000 行代码之后

2026-10-06 0 243

去年的项目验收阶段,我打开了公司后台系统的 node_modules,看到一个 2019 年引入的弹窗库。270KB 的体积,负责的职责是”点按钮,弹出一个框”。这四年围绕它写了两千多行的 CSS、JS 包装和业务适配层,但它本质上就是做了一个浏览器早就原生支持的功能——打开一个模态框。

更麻烦的是它的无障碍问题。屏幕阅读器识别不了当前是模态状态,键盘用户 Tab 键能跑到弹窗背后的页面上,手机端在键盘弹出时位置全乱。这些都是那个库时代前端工程师的常见困扰。

HTML 早就把这些问题想好了,就是我们常说的开源作者和”前端老兵”们,因为历史包袱没有及时跟进。<dialog> 元素 2014 年就有了规范,2022 年所有主流浏览器都完成了支持。属性 popover 也在 2023 年跟进。这两个东西组合起来能覆盖绝大多数弹窗场景。

这篇文章就是这次替换全过程的记录。不是讲 API 文档,是讲我们怎么一步步替换掉老库、中间踩了什么坑、以及最终留下来的那些真实可用的代码。

先看老的弹窗库做了什么

把老方案拆开看,一个模态框弹窗需要处理的事情大致是这些:

  • 在页面某个隐藏的容器里渲染 DOM
  • 给 body 加 overflow: hidden 防止滚动穿透
  • 计算居中位置(包括窗口 resize 时重算)
  • 监听 Esc 键关闭
  • 点击遮罩层关闭
  • 管理焦点:打开时把焦点移入弹窗,关闭时还给触发按钮
  • 管理 z-index 层级(多个弹窗叠加时)
  • 把弹窗外的内容标记为 aria-hidden="true"
  • 处理 Window 焦点丢失的情况

这一整套做下来,200KB 的库并不冤。问题在于浏览器已经把这套逻辑原生实现了,而且是 C++ 层面的、和渲染引擎配合的、不可能有 JS 实现的缺陷的原生实现。

dialog 元素基础用法

最朴素的写法:

<dialog id="myDialog">
  <p>这是一个模态框</p>
  <button>关闭</button>
</dialog>

默认是隐藏的。要显示它:

const dialog = document.getElementById('myDialog')
dialog.showModal()   // 模态打开
dialog.show()        // 非模态打开,相当于浮层
dialog.close()       // 关闭

showModal() 一调用,浏览器做了这几件事:

  • 弹窗以模态形式显示在页面中心
  • 背后加一层 ::backdrop 遮罩(默认半透明黑)
  • 阻止用户与弹窗外内容交互
  • 把焦点自动移入弹窗里第一个可聚焦元素
  • 锁定 Tab 键在弹窗内循环
  • 注册 Esc 键关闭行为
  • 把弹窗外的内容标记为 inert 状态

这些全都免费。我们老库里靠 JS 硬撑的那套逻辑,现在一行 JS 都不用写。

关闭方式也简单。任何带 form method="dialog" 的按钮,点击就会关闭它所在的 dialog:

<dialog id="confirm">
  <form method="dialog">
    <p>确定要删除这条记录吗?</p>
    <button value="cancel">取消</button>
    <button value="confirm">确定</button>
  </form>
</dialog>

点击”确定”,dialog.returnValue 会是 "confirm"。点击”取消”,会是 "cancel"。这个机制比手动绑一堆事件干净得多。

关闭事件监听:

dialog.addEventListener('close', () => {
  console.log('用户选择了:', dialog.returnValue)
})

把删除确认框重写一遍

我们后台系统里最常见的场景是”点击删除按钮,弹出确认框,确认后发 API 请求删掉记录”。用老库写是这样的:

function confirmDelete(id) {
  modal.confirm({
    title: '确定删除?',
    content: '删除后不可恢复,请谨慎操作。',
    onOk: () => api.delete(id),
    onCancel: () => {}
  })
}

用原生 dialog 写:

<dialog id="deleteConfirm" class="modal">
  <form method="dialog">
    <h2>确定删除?</h2>
    <p>删除后不可恢复,请谨慎操作。</p>
    <div class="actions">
      <button value="cancel">取消</button>
      <button value="ok">确定</button>
    </div>
  </form>
</dialog>
const dialog = document.getElementById('deleteConfirm')

function confirmDelete(id) {
  dialog.showModal()

  dialog.addEventListener('close', function handler() {
    dialog.removeEventListener('close', handler)

    if (dialog.returnValue === 'ok') {
      api.delete(id)
    }
  }, { once: true })
}

注意 { once: true },这样监听器一次就自动移除,不用手动 remove。虽然代码里写了 remove 是出于兼容考虑,但实际配了 once 就已经足够了。

比库版本的好处:没有回调地狱,用事件驱动更灵活;关闭逻辑集中在 dialog 自己的 DOM 上,通过 <form method="dialog"> 声明式地表达了”哪些按钮是关闭按钮”。这种”用声明式代替命令式”是原生元素带来的典型收益。

popover 属性:另一种弹窗

dialog 用来做模态框很合适,但有些场景不需要模态——比如用户点击头像弹出的菜单、点击帮助图标弹出的提示气泡、点击筛选按钮弹出的面板。这些用 dialog 是杀鸡用牛刀,因为它们不需要焦点锁定、不需要 backdrope 遮罩。

popover 属性就是给这种场景设计的:

<button popovertarget="userMenu">用户菜单</button>

<div id="userMenu" popover>
  <a href="/profile" rel="external nofollow" >个人资料</a>
  <a href="/settings" rel="external nofollow" >设置</a>
  <button popovertarget="userMenu" popovertargetaction="hide">关闭</button>
</div>

不需要一行 JS。点击按钮,菜单弹出;点击菜单外任何地方,菜单自动关闭;按 Esc 也能关。popovertarget 属性把触发按钮和弹窗关联了起来。

popover 属性有三种取值:

取值 行为
popover="auto"(默认) 点击外部自动关闭,同一时刻只有一个 auto popover 显示
popover="manual" 不会自动关闭,需要手动调 API 关闭,可以有多个同时显示
popover="hint" 不关闭其他 auto popover,用于 tooltip 这类轻量提示(部分浏览器支持)

用 JS 控制的话,有 showPopover()、hidePopover()、togglePopover() 三个方法,跟 dialog 类似。还可以监听 beforetoggle 和 toggle 事件做一些副作用。

给弹窗加上样式

原生元素默认有浏览器样式,通常需要覆盖。dialog 上来就是居中的,我们一般需要的是清理默认边框、自定义尺寸和圆角。这部分用 CSS 处理,注意下面这些点。

dialog 的遮罩层用 ::backdrop 伪元素控制:

dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(4px);
}

dialog {
  border: none;
  border-radius: 12px;
  padding: 24px;
  max-width: 480px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}

这里有个细节值得说:::backdrop 是独立的渲染层,它不在 dialog 的上下文中,所以 ::backdrop 里能用的属性有限。比如不能给它设置 padding,它铺满整个视口。想调整它的样式,只能从颜色、透明度、模糊、阴影这几个维度入手。

另外 dialog 打开时会自动获得焦点。如果弹窗内有输入框,你可能希望焦点直接落在第一个输入框上,而不是”关闭”按钮上。用 autofocus 属性:

<dialog>
  <form method="dialog">
    <input type="text" name="title" autofocus>
    <button>提交</button>
  </form>
</dialog>

注意,如果 dialog 里有多个 autofocus,只有第一个生效。

表单验证与 :user-invalid

重写过程中一个意外的收获,是发现表单验证的交互可以做得更顺手。

以前在弹窗里做表单,无论如何都要写一套 JS 逻辑判断输入是否合法,再把错误信息挂到对应字段下方。原生 HTML 表单有 required、pattern、minlength、type="email" 这些约束,浏览器原生就能判断和阻止提交。缺的只是样式的响应。

配合新的 :user-invalid 伪类,可以把”用户输错了”和”还不该提示”这两种状态区分得很清楚:

input:user-invalid {
  border-color: #d93025;
  background-color: #fff8f8;
}

input:user-invalid + .error-hint {
  display: block;
}

:user-invalid 只在用户尝试提交过之后、或者离开输入框之后才会触发。这一点跟 :invalid 区别极大——:invalid 会在输入框还是空的时候就报错,用户体验很糟。:user-invalid 让提示更有分寸。

我们的注册表单提交按钮就从”总是可点击”改成了”用户没提交过一次前不显示错误”,提交过一次后,所有错误提示立刻出现。这一个小改动让投诉少了将近一半。

我们踩过的五个坑

坑一:::backdrop 里的 z-index 不好使。第一版我们想给 backdrop 加个 z-index,结果没效果。后来明白了,backdrop 是浏览器控制的渲染层,它永远在 dialog 内容和页面其他内容之间。你要调层级,就调 dialog 本身,不要调 backdrop。

坑二:dialog 的默认 margin 是 auto,但会被某些 CSS reset 覆盖。我们项目里引入了一份比较老的 CSS reset,把 dialog { margin: 0 } 给覆盖进去了,导致所有弹窗都跑到左上角。定位了半天才找到。用原生元素的时候,先 toggle 一下 CSS reset 的文件看看有没有隐形覆盖。

坑三:showModal 之后 dialog 是”打开”状态,但动画还没跑完。想加打开动画,得用 @starting-style 或者监听 transitionend。我们一开始在 showModal 后立刻加类加过渡,结果动画不触发。后来改成:

dialog {
  opacity: 0;
  transition: opacity 0.2s;
}

dialog[open] {
  opacity: 1;
}

@starting-style {
  dialog[open] {
    opacity: 0;
  }
}

@starting-style 是 2024 年才普及的特性,用来指定元素第一次渲染时的样式,配合 transition 就能做出”从未显示到显示”的过渡。关闭动画稍微麻烦一点,需要在 close 之前先跑一遍动画再真正 close,可以监听 cancel 事件处理。

坑四:嵌套 dialog 的焦点管理。我们有一个场景是”确认框里再弹一个提示框”。原生支持嵌套,但在嵌套关闭时,焦点不会自动回到外层的对应按钮上,而是回到了 <body>。这个我们最后用 close 事件里手动做了焦点回迁:

inner.addEventListener('close', () => {
  outer.querySelector('[data-last-focused]')?.focus()
})

麻烦一点,但可控。老库反而因为它的 z-index 管理机制比较”简单粗暴”,这个场景处理得更顺。

坑五:Safari 早期版本对 dialog 的支持有 bug。特别是 backdrop 的点击事件处理,还有 showModal() 时报错的场景(同一个 dialog 已经打开过又一次 showModal)。生产环境的对策是加一层 try-catch 保险:

try {
  dialog.showModal()
} catch (err) {
  if (err.name === 'InvalidStateError') {
    // 已经打开,忽略
  } else {
    throw err
  }
}

这类健壮性问题在自研弹窗库里也要处理,只是原生方案暴露得更直接一点。

一键把三个弹窗场景整理成统一 API

经过几轮迭代,我们把项目里所有弹窗抽象成一个组合函数。业务代码只需要动一处就能用:

class Modal {
  constructor(id) {
    this.el = document.getElementById(id)
  }

  open() {
    try {
      this.el.showModal()
    } catch (err) {
      if (err.name !== 'InvalidStateError') throw err
    }
    return new Promise(resolve => {
      const onClose = () => {
        this.el.removeEventListener('close', onClose)
        resolve(this.el.returnValue)
      }
      this.el.addEventListener('close', onClose)
    })
  }

  close(value = '') {
    this.el.returnValue = value
    this.el.close()
  }
}

业务里的用法变成了:

const result = await new Modal('deleteConfirm').open()

if (result === 'ok') {
  await api.delete(id)
}

这个写法比老的 modal.confirm({ onOk, onCancel }) 更符合当下前端的习惯——Promise 化的交互,配合 async/await 天然就好读。同事接手之后第一个反馈就是”这个写法比老库顺”。

性能上到底提升了多少

做了个粗略的对比。同样的删除确认弹窗,同样在 Chrome 上打开 1000 次,一次完整开-关循环计时。

指标 老弹窗库 原生 dialog
打包体积 +270KB 0
弹窗打开平均耗时 18ms 2.4ms
弹窗关闭平均耗时 12ms 1.8ms
内存占用(1000 次循环) 稳定在 42MB 稳定在 14MB

差距主要来自两处:老库要在打开时创建 DOM、绑定事件、做居中计算和层叠管理;原生方案直接靠浏览器引擎处理这些,大部分工作都在编译层就已经优化好。

打开耗时从 18ms 降到 2.4ms 这个数字,对单个弹窗感知不明显,但如果页面里有很多弹窗要按需打开,累积起来就是可观的体验差距。

什么时候不适合用 dialog/popover

坦率说,原生方案不是所有场景都合适。我们团队后来讨论过,下面这几种情况还是得用第三方库。

  • 复杂动画场景。比如从某个按钮”生长”出来的弹窗、带路径动画的弹窗,原生方案做起来比较费劲,需要写一堆 CSS transition 和起始样式。
  • 需要在弹窗里嵌套另一个系统级弹窗时。那种”提示框里再弹选择器再弹日期选择”的三层叠加,浏览器原生 dialog 处理三层以上比较别扭。
  • 需要支持非常老的浏览器。虽然 2024 年之后主流浏览器全支持了,但如果项目要求支持 IE 或者早期 Safari,那就得用 polyfill 或者干脆放弃。
  • 需要动效和主题高度定制的组件库生态。如果你的项目深度依赖某个 UI 库,替换成本可能大过收益。这种情况下可以只把最核心的几个弹窗换掉,其他的保留。

我们的项目做替换时也不是一刀切,是全量替换到 80% 之后,发现剩下 20% 用原生做反而更复杂,就直接保留老库——但只引入按需加载的模块,整体体积从 270KB 降到了 60KB。

写在最后

这次重写最大的感受是:前端很多”复杂”其实是历史包袱造成的,不是问题本身复杂。当我们习惯用某个库、某个模式来解决问题之后,就很难再回头看”这件事真的有那么复杂吗”。

原生 dialog 和 popover 就是这样一个例子。它们代表的其实是一种回归——把交给浏览器的事情还给浏览器,让 JS 只处理业务逻辑。代价是要理解原生元素的行为边界,好处是代码量、体积、可维护性全部改善。

如果你手上的项目也有类似的”弹窗困局”,建议先花半天时间做一个 dialog 的 POC。可能会发现能删掉的东西比想象中多。

用原生 dialog 重写整个弹窗系统:删掉 2000 行代码之后
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html 用原生 dialog 重写整个弹窗系统:删掉 2000 行代码之后 https://www.taomawang.com/web/html/2898.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务