1. 选型思路:这个登录弹窗为什么可以不碰框架
1.1 为一个登录框引入几百 KB 的依赖,真的划算吗
先把场景说清楚。你要给一个内容型网站做登录弹窗,得弹出来、填邮箱、填密码、校验格式、提交接口,窗口也就巴掌大一块。如果项目本身已经是 Vue 或 React 技术栈,顺手用组件库里的 Modal 完全没问题。但如果你要维护的是一个多页面老项目,或者一个轻量的活动页,这时候为了一个登录弹窗去引一套 UI 框架,我劝你先算笔账:光是核心代码加依赖关系,就是少则几十 KB、多则几百 KB 的体积,这还没算样式冲突、主题定制、暗黑模式适配这些隐性成本。
我见过不少团队这么干:老项目里塞了个 React 再挂组件库,就为做一个弹窗。结果全局样式被覆盖、包体积报警、构建配置要动,最后弹窗上线了,老页面却出了几个视觉回归,得不偿失。所以当时的考虑很直接:能不能完全靠浏览器自带的能力,把这个弹窗做得像一个正经组件库的产物。HTML Dialog 加原生表单校验,就是这套思路里最成熟的组合,代码量压到最小,行为反而更贴近规范。
1.2 Dialog 元素到底帮你省掉了哪些脏活
很多人听到<dialog>,第一反应是“这不是新东西吗,怎么还拿出来讲”。没错,它确实不是什么新技术,但与之并列的一个事实是,它一直没有被认真对待。很多前端开发者的习惯是把弹窗交给组件库,库内部帮你实现遮罩、关闭按钮、事件管理、焦点锁定,而你在使用它的时候,并没有意识到这些东西原本可以零成本拿到。
原生 dialog 的showModal()方法一调用,浏览器会替你做这些事:创建一个层级最高的顶层图层,自动覆盖后面的页面;自动生成一个遮罩,这个遮罩可以通过::backdrop伪元素来定制样式;把焦点强制移到弹窗内部,并将背景页面标记为不可交互,防止用户透过弹窗操作底层内容;按下 Esc 键默认触发cancel事件,可以关闭弹窗。这些全是规范层面定好的行为,不需要你写一行 JavaScript。
对比一下,如果是手写一个弹窗组件,你至少得自己管理遮罩元素、监听按钮触发、写焦点处理、处理背景滚动穿透问题。这些工作散落在从 jQuery 时代到现在的无数项目里,每做一个新弹窗就重复一遍。而 dialog 把这些全部打包成了原生能力,你需要做的只是决定用它,而不是再从零发明一次轮子。
1.3 校验这件事,浏览器的原生 API 其实早就够用了
校验逻辑往往也是前端开发里写得最脏的部分。正则表达式写一长串、每个输入框配一个错误提示元素、提交时遍历所有字段手动判断,搞下来又是几十行代码。而浏览器自带的约束校验 API,其实已经覆盖了绝大多数业务场景:required表示必填、type="email"校验邮箱格式、minlength控制最小长度、pattern补充自定义正则,这些都是声明式属性,HTML 里写上就生效。
用的时候,checkValidity()返回当前表单是否合法,reportValidity()会把不合法的字段显示出来并聚焦到第一个错误位置。如果你还想在接口返回“密码错误”的时候把这个提示挂在具体输入框下方,那就用setCustomValidity(),这是原生 API 专门留给自定义校验的入口。看完你会发现,表单校验这部分代码,根本不需要引入任何第三方库,甚至不需要写多少 JavaScript,大部分逻辑用属性就能表达完。
2. 基础实现:从弹出一个窗口到完整的登录交互
2.1 先跑通 showModal():两种打开方式的区别
讲具体代码之前,有一个概念必须先分清:show()和showModal()是两个不同的东西。show()打开的是非模态弹窗,它和页面内容并列存在,用户可以继续与背景元素交互;showModal()打开的是模态弹窗,背景被遮罩挡住,键盘焦点被限制在弹窗内部,Esc 键默认触发取消事件,行为上就更像一个正经的登录弹窗。
登录弹窗这种场景,选showModal()是最合理的。原因很简单,登录需要用户临时中断手头操作,把注意力集中到表单上,模态化是默认预期。而且showModal()自带的焦点锁定,能避免用户用 Tab 键把焦点跳到弹窗后面的页面元素上,这是手写弹窗组件时最容易做漏的一个点。
有一个很隐蔽的坑先说在前面:调用showModal()时,如果 dialog 元素还没被插入到 document 里,浏览器会直接抛一个非法操作异常。新手常常在脚本顶部拿到 dialog 引用后立刻调用,结果报错找不到原因。记住一点,先要用document.body.appendChild(dialog)或者确保 HTML 结构里已经写了这个标签,再执行dialog.showModal()。
<dialog id="loginDialog"> <h2>欢迎回来</h2> </dialog> <script> const dialog = document.getElementById('loginDialog'); document.getElementById('openLoginBtn').addEventListener('click', () => { dialog.showModal(); }); </script>2.2 关闭的三条路径:按钮、Esc 键和点击遮罩
实现一个弹窗,打开只是第一步,关闭逻辑才是最考验细节的地方。登录弹窗一般有这么几种关闭方式:用户点了“取消”按钮,用户按了 Esc 键,用户点击了遮罩区域,以及用户登录成功后自动关闭。这四种路径,原生 dialog 覆盖了前两种的基础行为,但后面两种需要你自己补代码。
先看最基本的:用dialog.close()方法可以主动关闭弹窗,同时可以传入一个字符串参数作为返回值,后续可以通过close事件的returnValue属性读到。这里的字符串传什么都行,我习惯传有语义的标识,比如'cancel'、'success',这比单纯一个空值好判断得多。
按 Esc 键的关闭是默认行为,它会先触发cancel事件。如果你想在用户按 Esc 的时候拦截一次确认操作,比如表单有未提交的内容,可以在cancel事件里调用preventDefault()阻止关闭。这个能力在登录弹窗里用得不多,但属于必须知道的基础知识。而点击遮罩区域关闭,原生并不帮你做,这个后面单独讲。
dialog.addEventListener('close', (event) => { console.log('弹窗关闭,返回值:', dialog.returnValue); });2.3 点击遮罩关闭的两种常见写法
原生 dialog 的遮罩叫::backdrop,它是一个伪元素,不能直接绑定事件。所以点击遮罩关闭,必须绕个弯。网上最常见的做法是给 dialog 本身监听click事件,判断点击位置是否在内容区域之外。
dialog.addEventListener('click', (event) => { const rect = dialog.getBoundingClientRect(); const isInside = ( event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom ); if (!isInside) { dialog.close('cancel'); } });这个写法能工作,但也有一个不够严谨的地方:dialog 的矩形区域包括了它自身的内边距区域,如果用户在内容区边缘的内边距上点击,事件目标其实是 dialog 本身,这时候判断坐标仍然会把它当作“内部”,但用户的本意可能是点到了空白区域。更稳妥的做法是在 dialog 内部把所有内容都包进一层子容器,比如<div class="dialog-content">,然后判断事件目标是否等于 dialog 自身:
dialog.addEventListener('click', (event) => { if (event.target === dialog) { dialog.close('cancel'); } });两者都可行,我推荐后者,因为判断逻辑更直接。但不管用哪种,都需要你记得把表单和内容包在子元素里,或者计算矩形时考虑内边距,否则会出现点弹窗空白处结果把弹窗关掉的误操作。
再补一句,登录弹窗带表单,用户点击输入框和按钮这些操作都发生在弹窗内容里面,事件目标不会是 dialog 本身,所以这个判断在实际使用中很安全。
2.4 returnValue 和事件:把登录结果带出弹窗
弹窗逻辑不能只停留在“打开和关闭”这个层面。登录成功后,外层页面可能要更新用户信息、跳转页面或者关闭弹窗后执行某个回调,这时候就需要一种通信机制。原生 dialog 的方案是:close 事件配合 returnValue。
dialog.addEventListener('close', function handleLogin() { if (dialog.returnValue === 'success') { // 更新页面中的用户信息 window.location.reload(); } });这种通信方式足够轻量,不需要自定义回调队列。需要注意的是,returnValue会一直保留上一次的值,除非你手动重置。最保险的习惯是每次打开弹窗之前显式设置dialog.returnValue = '',这样不会读取到上一次的结果。
3. 表单校验细节:把原生约束校验用到极致
3.1 声明式校验属性:HTML 里的规则声明
先写一个完整的登录表单结构,后面所有逻辑都围绕这个结构展开:
<dialog id="loginDialog"> <div class="dialog-content"> <form id="loginForm" novalidate> <h2>欢迎回来</h2> <p class="dialog-subtitle">登录后继续访问全部内容</p> <div class="field"> <label for="email">邮箱</label> <input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com" /> </div> <div class="field"> <label for="password">密码</label> <input type="password" id="password" name="password" required minlength="8" autocomplete="current-password" placeholder="至少 8 位字符" /> </div> <p class="form-error" id="formError" role="alert"></p> <div class="form-actions"> <button type="submit" class="btn-primary">登录</button> <button type="button" class="btn-ghost" id="closeBtn">取消</button> </div> </form> </div> </dialog>注意我在 form 上加了novalidate属性,这个后面会解释原因。先看校验规则怎么声明:required声明这个字段不能为空,type="email"让浏览器对输入内容做邮箱格式判断,minlength="8"要求密码至少 8 位,autocomplete属性让浏览器可以自动填充用户名和密码,这个对登录场景很重要,很多人写弹窗容易漏掉,结果密码管理器不识别。
3.2 为什么用了 novalidate,再配合 checkValidity 和 reportValidity
HTML 表单如果在没有novalidate的情况下点击提交按钮,浏览器会直接弹出一个默认校验气泡,阻止表单提交。这个气泡的样式受浏览器控制,很不统一,Chrome 一个样、Firefox 一个样,定制能力也几乎为零。为了让错误提示的样式和交互与整个弹窗保持一致,我们需要关掉浏览器的默认行为,改用 JavaScript 里的校验 API 手动控制展示逻辑。
这就是novalidate的意义。表单提交时,submit事件回调里先调用form.checkValidity(),如果返回true,说明所有字段都通过了约束校验;如果返回false,就调用form.reportValidity(),浏览器会把校验失败的信息集中展示出来,并把焦点定位到第一个出错的字段。
实际上,reportValidity()会再次触发浏览器默认的气泡提示,这并不是我们想要的。所以这里有一个更好的组合方式:用form.checkValidity()判断合法性,配合 CSS 的:user-invalid伪类做样式提示,而不是依赖浏览器的气泡。checkValidity()只做判断,不产生任何 UI 副作用,把展示逻辑完全留给开发者控制。
form.addEventListener('submit', async (event) => { event.preventDefault(); if (!form.checkValidity()) { form.querySelectorAll(':invalid').forEach((field) => { field.classList.add('input-invalid'); }); return; } submitBtn.disabled = true; submitBtn.textContent = '登录中...'; try { const user = await mockLogin(emailInput.value, passwordInput.value); if (user) { dialog.close('success'); } } catch (err) { formError.textContent = err.message; formError.style.display = 'block'; } finally { submitBtn.disabled = false; submitBtn.textContent = '登录'; } });3.3 checkValidity、自定义规则与接口错误的边界
除了声明式规则,业务里经常有更复杂的校验逻辑。比如用户输入的邮箱格式正确,但服务端返回“该邮箱尚未注册”。这种错误不应该用type="email"这种强制格式规则去表达,它的正确做法是调用输入框的setCustomValidity()方法,把一个自定义错误消息挂到字段上。
emailInput.addEventListener('input', () => { emailInput.setCustomValidity(''); }); // 模拟接口返回邮箱未注册的情况 emailInput.setCustomValidity('该邮箱尚未注册,请先创建账号');setCustomValidity()有一个很关键的行为:只要传入的字符串非空,这个字段就始终处于校验失败状态,直到你把消息清空。相应的,form.checkValidity()也会返回false。所以一个常见的易错点是:第一次调用了setCustomValidity('错误'),后面字段改对了,但忘记重新调用setCustomValidity('')清空,结果表单永远校验不通过。
我在登录弹窗里最常用这套机制的地方有两个:一个是确认密码字段,判断两次密码是否一致;另一个是接口返回登录失败时,把“邮箱或密码错误”这个提示挂到整个表单上。注意接口相关的错误用formError.textContent来显示,而不要直接赋给某个输入框,否则用户还没修改,错误提示就已经存在了。
3.4 输入时即时反馈:利用 invalid 事件
原生表单事件里,invalid事件是校验失败时触发的,它在checkValidity()或reportValidity()被调用时都会冒泡到表单。利用这一点,可以实现输入过程的即时反馈:用户第一次提交时标出错误字段,之后每输入一个字符,就自动消掉该字段的错误样式。
form.addEventListener('invalid', (event) => { event.target.classList.add('input-invalid'); }, true); form.addEventListener('input', (event) => { if (event.target.classList.contains('input-invalid')) { event.target.classList.remove('input-invalid'); } });这里用捕获方式监听invalid,因为事件目标是具体的输入框,冒泡到 form 时的target可能会被覆盖。捕获阶段能保证拿到真正的输入框元素。这种实时反馈的实现成本极低,但体感提升非常明显。
4. 弹窗外观与交互动效:样式细节决定“现代感”
4.1 定制 ::backdrop 与弹窗本体
浏览器给对话框默认的样式其实比较朴素,没有边框圆角,没有阴影,背景则是半透明的黑色遮罩。想要做出现代感,必须自己重写样式。其中::backdrop是唯一能定制遮罩的伪元素,用来设置半透明背景、毛玻璃效果或者模糊渐变都行。
#loginDialog { border: none; border-radius: 16px; padding: 0; width: min(90vw, 400px); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); } #loginDialog::backdrop { background: rgba(15, 23, 42, 0.6); backdrop-filter: blur(4px); }dialog 默认有浏览器自带的边框和内边距,我全部重置为0,把内容区域的间距交给内部容器去负责。宽度用min(90vw, 400px),保证手机屏幕上不会溢出,在桌面上又不显得过宽。backdrop-filter: blur(4px)可以给遮罩加一层轻微模糊,视觉层次一下就出来了,但注意这个属性在部分老移动设备上会导致渲染卡顿,如果目标用户中低端机型占比高,建议去掉模糊只保留半透明。
登录弹窗的内容区,我给了一个干净的卡片布局:内边距 32px,标题字号稍大,副标题用浅灰的次级文字。输入框的设计上,我习惯把 label 浮在输入框上方而非占位符里,因为占位符在用户开始输入以后就消失了,可读性差。输入框本身边框用 1px 的浅灰,聚焦时边框变为主题色,同时加上一个淡淡的阴影提示。
4.2 打开和关闭的过渡动画,以及那个让人头疼的退出动画
现代弹窗如果没有过渡动画,打开和关闭都硬生生地出现和消失,观感会很生硬。打开动画实现起来简单,利用 dialog 的显示状态配合关键帧动画就行。但要注意一个细节:showModal()是同步显示元素,没有类似 CSS transition 从display: none到display: block的过渡过程,直接写transition不会生效。所以打开动画必须用自定义关键帧,比如从透明和轻微上移开始淡入。
#loginDialog[open] { animation: dialogFadeIn 0.25s ease-out; } @keyframes dialogFadeIn { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } #loginDialog[open]::backdrop { animation: backdropFadeIn 0.25s ease-out; } @keyframes backdropFadeIn { from { opacity: 0; } to { opacity: 1; } }真正麻烦的是关闭动画。当调用dialog.close()时,浏览器会立刻把 dialog 元素从文档流中移出,这意味着你还没来得及播放关闭动画,弹窗就已经消失了。一个比较常见的处理策略是:监听cancel事件,拦截默认关闭行为,给 dialog 手动添加一个退出动画的 class,等动画播放完成后再手动调用close()。
let isClosing = false; dialog.addEventListener('cancel', (event) => { event.preventDefault(); closeDialogWithAnimation(); }); function closeDialogWithAnimation() { if (isClosing) return; isClosing = true; dialog.classList.add('dialog-closing'); setTimeout(() => { dialog.close('cancel'); isClosing = false; }, 200); }#loginDialog.dialog-closing { animation: dialogFadeOut 0.2s ease-in forwards; } @keyframes dialogFadeOut { to { opacity: 0; transform: translateY(8px) scale(0.98); } }这个方案引入了isClosing标志位来防止动画重复触发,是因为cancel事件在动画播放期间如果用户再按 Esc 会再次触发。我实际开发中遇到过这个 bug,加了标志位以后就稳定了。需要注意,拦截cancel事件会影响默认的 Esc 行为,所以上面这段也等于是你在接管退出动画的完整控制权。如果项目对动画要求没那么高,也可以只做打开动画,关闭直接消失,由一个小的延迟回调配合遮罩淡出实现,但效果和上述方案相比有差距。
4.3 可访问性细节:焦点管理和 inert 的正确用法
弹窗的可访问性不是可有可无的加分项,而是实打实涉及用户体验的模块。showModal()自动把焦点移入弹窗,关闭后焦点会恢复到触发打开的那个元素上,这是原生行为,比你自己写焦点管理的稳定性和兼容性都要好。
但有两个细节需要手动处理。第一,如果你希望弹窗打开后,焦点自动定位到邮箱输入框,而不是先聚焦到标题或者第一个可聚焦元素,可以在打开弹窗后手动执行emailInput.focus()。第二,背景页面的可访问性问题,现代浏览器在模态 dialog 显示时会尝试阻止背景交互,但历史上某些浏览器的键盘访问并不能被完全锁住,用户依然可以通过 Tab 键在背景元素之间跳转。
针对这个隐患,最可靠的解决方案是给背景内容加上inert属性。inert是 HTML 提供的属性,加上它的元素会从 Tab 键的焦点顺序中移除,同时也不响应鼠标事件。
const mainContent = document.getElementById('mainContent'); openBtn.addEventListener('click', () => { mainContent.inert = true; dialog.showModal(); emailInput.focus(); }); dialog.addEventListener('close', () => { mainContent.inert = false; });打开弹窗时给页面主体标记inert,关闭后移除。这个做法的好处在于,即使用键盘操作,用户也无法把焦点移出弹窗范围,可访问性提升立竿见影。
5. 踩坑记录:兼容性、边界情况与检查清单
5.1 浏览器兼容性:桌面无障碍,移动端才见真章
聊原生 API 不讲兼容性就是在耍流氓。令人意外的是,<dialog>和showModal()的最主要兼容问题恰恰不在你得以为更老的浏览器上,而是在一个现代浏览器里。桌面端 Chrome、Edge、Firefox、Safari 都已经支持多年,问题出在 iOS Safari 上。iOS 16.4 以前,dialog 元素只是部分支持,最典型的症状是showModal()不生效,需要到处找 polyfill 兜底。
如果你要支持的设备包含 iPhone 上旧一点的系统版本,那就必须评估两个方向的决定:要么引入轻量的 polyfill,要么升级对用户的最低系统版本要求。我的建议是,低于 0.1% 用量的旧设备,可以直接放弃兼容;但如果你的产品是面向下沉市场的工具型应用,iPhone 6 级别硬件还占着不少比例,那我建议这个场景不要用原生 dialog,而是老老实实手写一个弹窗。
另外还有一个细节要注意,移动端虚拟键盘弹出时,dialog的定位可能遮挡输入框。虽然 dialog 默认是居中定位的,但在键盘弹出后视觉焦点会低于弹窗中部,用户会被键盘遮住正在输入的内容。你可以用 CSS 把 dialog 的垂直定位在键盘影响下时向顶部靠拢,或者直接在移动端屏幕尺寸上让 dialog 贴着屏幕上方显示。这个属于体验细节,放到滚动测试里看效果。
5.2 按优先级整理的坑清单
我在实际做这个登录弹窗时,遇到了一些值得记录的坑,这里按“踩坑频率”加“解决成本”排个序,方便你对照自查。
第一,setCustomValidity没有清空导致表单始终无法提交。这个问题出现频率非常高,解决方案是每次修改字段内容时,都执行一次setCustomValidity('')清空自定义错误。最好是放在input事件监听器的第一行,养成习惯。
第二,点击遮罩关闭时误伤了弹窗内部。分析原因,是因为把点击事件绑定在整个 dialog 上,然后判断坐标,但 dialog 的内边距也会被算进内容区域,导致点击 padding 区域时弹窗没反应,或者点击接近边缘的内容时弹窗反而被关了。解决方案就是前面提到的,把内容全部包进dialog-content子容器,用event.target === dialog来判断。
第三,iOS Safari 上backdrop-filter引起整个弹窗闪烁。这不是功能性 bug,但观感极差。如果你在样式里用了毛玻璃效果,建议先在 iOS 真机上测一遍,如果出现闪烁,直接去掉backdrop-filter是一个性价比最高的选择。
第四,打开弹窗后背景页面还能滚动。原生的模态 dialog 会禁止背景滚动吗?大多数现代浏览器在弹窗打开时确实阻止了鼠标滚动交互,但有些情况下,背景滚动是透过遮罩直接发生的,尤其是触屏设备上滚动事件依然可能穿透。最稳妥的方案是弹窗打开时把外层容器的overflow: hidden加上,或者使用inert,同时配合overscroll-behavior: contain来隔离滚动链。
html:has(dialog[open]) { overflow: hidden; }这个写法的原理是通过:has()选择器在页面里存在打开状态的 dialog 时,锁住整个 html 层的滚动,关闭时自动恢复。兼容性方面,iOS 15.4 以上的 Safari 都支持:has(),如果你要把 iOS 版本卡在 15.4 以下,那这个方案就不能用,需要改成 JS 给 body 添加 class 的方式。
5.3 完整的登录弹窗代码,可以直接抄走用
把上面涉及的所有内容整合一下,这里给出一个完整可运行的登录弹窗实现,不需要任何第三方库,直接保存成 HTML 文件就能在浏览器里跑。样式的细节你自己按产品风格调整。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>登录弹窗与原生校验</title> <style> :root { color-scheme: light; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f8fafc; color: #0f172a; } main { text-align: center; } .open-btn { padding: 12px 24px; border: none; border-radius: 8px; background: #6366f1; color: white; font-size: 16px; cursor: pointer; } dialog { border: none; border-radius: 16px; padding: 0; width: min(90vw, 400px); box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); } dialog::backdrop { background: rgba(15, 23, 42, 0.6); } dialog[open] { animation: dialogIn 0.25s ease-out; } @keyframes dialogIn { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } .dialog-content { padding: 32px; } .dialog-content h2 { margin: 0 0 8px; font-size: 22px; } .dialog-subtitle { margin: 0 0 24px; color: #64748b; font-size: 14px; } .field { margin-bottom: 18px; } .field label { display: block; margin-bottom: 6px; font-size: 14px; font-weight: 500; } .field input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 16px; outline: none; transition: border-color 0.2s, box-shadow 0.2s; } .field input:focus { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); } .field input.input-invalid { border-color: #dc2626; } .form-error { display: none; margin: 8px 0 16px; color: #dc2626; font-size: 14px; text-align: center; } .form-actions { display: flex; gap: 12px; } .form-actions button { flex: 1; padding: 10px 16px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; } .btn-primary { background: #6366f1; color: white; } .btn-primary:disabled { opacity: 0.7; cursor: wait; } .btn-ghost { background: transparent; color: #475569; border: 1px solid #cbd5e1; } </style> </head> <body> <main id="mainContent"> <button type="button" class="open-btn" id="openBtn">打开登录弹窗</button> </main> <dialog id="loginDialog"> <div class="dialog-content"> <form id="loginForm" novalidate> <h2>欢迎回来</h2> <p class="dialog-subtitle">登录后继续访问全部内容</p> <div class="field"> <label for="email">邮箱</label> <input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com" /> </div> <div class="field"> <label for="password">密码</label> <input type="password" id="password" name="password" required minlength="8" autocomplete="current-password" placeholder="至少 8 位字符" /> </div> <p class="form-error" id="formError" role="alert"></p> <div class="form-actions"> <button type="submit" class="btn-primary">登录</button> <button type="button" class="btn-ghost" id="closeBtn">取消</button> </div> </form> </div> </dialog> <script> const dialog = document.getElementById('loginDialog'); const main = document.getElementById('mainContent'); const form = document.getElementById('loginForm'); const emailInput = document.getElementById('email'); const passwordInput = document.getElementById('password'); const formError = document.getElementById('formError'); const closeBtn = document.getElementById('closeBtn'); const submitBtn = form.querySelector('[type="submit"]'); const openBtn = document.getElementById('openBtn'); let isAnimatingClose = false; function openLogin() { dialog.returnValue = ''; formError.style.display = 'none'; formError.textContent = ''; form.reset(); main.inert = true; dialog.showModal(); emailInput.focus(); } function closeDialogWithAnimation() { if (isAnimatingClose || !dialog.open) return; isAnimatingClose = true; dialog.classList.add('dialog-closing'); setTimeout(() => { dialog.close('cancel'); dialog.classList.remove('dialog-closing'); isAnimatingClose = false; }, 200); } openBtn.addEventListener('click', openLogin); dialog.addEventListener('click', (event) => { if (event.target === dialog) { closeDialogWithAnimation(); } }); dialog.addEventListener('cancel', (event) => { event.preventDefault(); closeDialogWithAnimation(); }); dialog.addEventListener('close', () => { main.inert = false; }); closeBtn.addEventListener('click', () => { closeDialogWithAnimation(); }); form.addEventListener('invalid', (event) => { event.target.classList.add('input-invalid'); }, true); form.addEventListener('input', (event) => { if (event.target.classList.contains('input-invalid')) { event.target.classList.remove('input-invalid'); } }); form.addEventListener('submit', async (event) => { event.preventDefault(); if (!form.checkValidity()) { form.querySelectorAll(':invalid').forEach((field) => { field.classList.add('input-invalid'); }); return; } submitBtn.disabled = true; submitBtn.textContent = '登录中...'; try { await new Promise((resolve, reject) => { setTimeout(() => { if (emailInput.value === 'demo@example.com' && passwordInput.value === 'password123') { resolve(); } else { reject(new Error('邮箱或密码错误')); } }, 800); }); dialog.close('success'); } catch (err) { formError.textContent = err.message; formError.style.display = 'block'; } finally { submitBtn.disabled = false; submitBtn.textContent = '登录'; } }); emailInput.addEventListener('input', () => { emailInput.setCustomValidity(''); }); </script> </body> </html>这个版本把前面所有讨论过的点都集成了:坐标点击遮罩关闭、Esc 拦截加退出动画、invalid事件单个字段标记、checkValidity判断加提交状态的按钮禁用、接口失败时把错误消息展示在表单顶部。把这 200 多行代码保存下来,就是一个不依赖框架的、能直接交付的登录弹窗组件,核心逻辑代码量很小,剩下的都是样式。
最后的这个完整示例可以直接用于项目原型,也可以作为进一步封装的基础,你自己决定是把它改造成可在多个页面复用的暴露 API 形态,还是保持这种简单的命令式调用方式。
我个人用了原生 dialog 一段时间以后的体会是,它的上限取决于你对 Web 平台自身的了解程度,而不是它本身缺失什么功能。这个登录弹窗里用到的大部分能力,包括showModal、::backdrop、约束校验 API、焦点管理和inert,都是现代浏览器早就具备的。以前我们习惯性打开框架文档查 Modal 组件的 API,现在越来越多的场景,反而值得先翻开 MDN 的 dialog 页面看看,原生方案能省掉的那部分工作量,比你想象中多得多。