dialog.showModal() 不实现 Tab 循环,因其不监听 focusin、不维护可聚焦元素列表;须手动用 focusin 监听+缓存可见可聚焦元素+边界跳转,并用 requestAnimationFrame 确保 DOM 就绪后聚焦,关闭时通过强引用触发按钮并校验其有效性来安全回焦。

dialog.showModal() 为什么不能直接实现 Tab 循环
showModal() 只负责首次聚焦和基础模态行为(遮罩、Esc 关闭、aria-modal="true"),它**不控制后续 Tab 键的循环逻辑**。Safari 全版本、旧 Edge、甚至部分 Chrome 场景下,焦点仍会跳到页脚按钮或地址栏——这不是 bug,是规范本就没承诺这事。
常见表现:弹窗打开后按一次 Tab,焦点直接落到页面顶部导航链接;屏幕阅读器读完标题就停住,不进表单字段;移动端 VoiceOver 点击弹窗区域无响应。
根本原因:showModal() 不监听 focusin,也不维护可聚焦元素列表,更不会在边界点自动跳转。你必须手动补上这层逻辑。
如何用 focusin 实现安全的 Tab 循环
监听 focusin 是唯一兼容 NVDA、JAWS 和 VoiceOver 的方式。拦 keydown 会破坏辅助技术的方向键和列表导航(比如 NVDA 的 Insert+F7)。
立即学习“前端免费学习笔记(深入)”;
- 给
<dialog>元素设tabindex="-1",确保它可被.focus()主动进入 - 提前缓存可聚焦项:
Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex="0"]')) - 过滤掉不可见元素:
el.offsetParent !== null && getComputedStyle(el).visibility !== 'hidden' - 在
focusin回调里判断:if (event.target === lastFocusable && !event.shiftKey) { firstFocusable.focus(); event.preventDefault(); },同理处理Shift+Tab到开头的情况 - 务必在
dialog.close()前移除该监听器,否则多个弹窗叠加时互相干扰
为什么 requestAnimationFrame 是必须的
showModal() 调用后 DOM 可能还没完成渲染,尤其是内容由 JS 异步插入(如 fetch 后 append 表单字段)时,querySelector 会返回 null,导致首次聚焦失败,焦点卡在 body 上。
正确做法是:
- 调用
dialog.showModal()后立即用requestAnimationFrame(() => { /* 聚焦逻辑 */ }) - 优先查找
button、input、select、textarea或[tabindex="0"],跳过[tabindex="-1"] - 查不到时 fallback 到
dialog自身并设tabindex="-1",再调用.focus()
别用 setTimeout(..., 0) —— 它不保证 DOM 已就绪,尤其在 SSR 或 hydration 场景下容易失效。
关闭 dialog 时焦点回不到触发按钮的真正原因
不是没存触发源,而是存的方式让 .focus() 静默失败:闭包里直接引用 document.querySelector('#trigger-btn') 返回的 DOM 节点,在 SPA 路由切换或组件重渲染后,该节点很可能已从文档中移除。
可靠做法:
- 在触发点击时就保存强引用:
openDialog(triggerButton),函数内把triggerButton存为实例属性或 WeakMap 键值对 - 关闭前检查该元素是否仍在 DOM 中且可聚焦:
triggerEl.isConnected && triggerEl.matches(':focusable') - 满足则
triggerEl.focus();否则 fallback 到document.querySelector('main')?.focus() - 避免依赖
document.activeElement—— 它可能已被其他脚本修改,且在 Shadow DOM 中不可靠
复杂点在于:嵌套弹窗、动态渲染、SSR hydration 不同步——这些场景下,DOM 节点生命周期和焦点状态不同步,是绝大多数“回焦失效”问题的根源。



















