focusin 是唯一可靠焦点入口,须在模态框绑定并用 contains 判断目标是否在框内;遮罩层需设 tabindex="-1" 和 aria-hidden="true";可聚焦元素须手动筛选,空时根元素 tabindex="-1" 后 focus;Tab 循环应重定向而非阻止;关闭后须安全回归触发源。

focusin 事件是唯一可靠入口
浏览器对 Tab、Shift+Tab、鼠标点击、屏幕阅读器切换等所有焦点迁移方式,只统一触发 focusin 事件。只监听 keydown 会漏掉鼠标点出、语音导航跳出等真实场景,导致焦点“无声溜走”。
必须在模态框容器上绑定 focusin,并用 modalEl.contains(e.target) 判断目标是否仍在框内——这是最轻量、兼容性最好的判断方式,连 IE11 都支持。
- 别用
document.activeElement === modalEl做判断:它可能返回body或input,无法反映“是否在模态框后代中” - 遮罩层(overlay)要设
tabindex="-1"和aria-hidden="true",否则它自己可能被意外聚焦,成为焦点逃逸通道 - 如果模态框是动态插入的(比如 fetch 后渲染),确保事件监听器在 DOM 挂载后立即绑定,不要依赖未就绪的节点
可聚焦元素列表必须手动筛选
querySelectorAll('*:focusable') 是无效写法,CSS 没这个伪类。必须显式列出合法选择器,并排除干扰项。
正确做法是:modal.querySelectorAll('button, [href], input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])')。注意:
立即学习“前端免费学习笔记(深入)”;
-
tabindex="-1"元素不能进列表——它们只支持 JS 聚焦,不参与原生 Tab 顺序 -
input[type="hidden"]必须排除,否则它会占位但不可见,破坏循环逻辑 - 若列表为空,需给模态框根元素加
tabindex="-1"后调用.focus(),否则首次打开时焦点悬空
Tab 循环不是“阻止 Tab”,而是重定向焦点
直接 e.preventDefault() 所有 Tab 键会破坏屏幕阅读器的内置导航(如 NVDA 的 Insert+F7 列表),属于反模式。真正该做的是:当焦点即将离开边界时,主动跳转到对应端点。
关键逻辑在 keydown 中实现:
- 仅当
e.key === 'Tab'且当前聚焦元素是列表第一个/最后一个时才干预 - 按下 Shift+Tab 且当前是第一个 →
focusableList[focusableList.length - 1].focus() - 按下 Tab 且当前是最后一个 →
focusableList[0].focus() - 务必
e.preventDefault(),否则浏览器仍会按默认顺序跳到外部
关闭模态框时焦点回归容易失效
很多实现直接 document.body.focus(),这会让屏幕阅读器从头读起,用户完全丢失上下文。真正安全的做法是:打开前存触发源引用,关闭时校验其有效性再聚焦。
推荐方案:
- 打开前记录:
const triggerEl = event.target; modal.dataset.triggerId = triggerEl.id; - 关闭时检查:
const el = document.getElementById(modal.dataset.triggerId); if (el && el.offsetParent !== null) el.focus(); - 避免用
setTimeout(() => ..., 0):Firefox 中可能失效;改用queueMicrotask(() => ...) - 如果触发按钮是动态生成的(如表格行操作),需用事件委托 +
data-modal-trigger-id绑定关系,不能依赖闭包缓存
焦点管理最复杂的点不在“怎么锁”,而在“怎么开”和“怎么关”——首次聚焦时机稍早,元素还没挂载;关闭时触发源可能已被销毁;而这些错误都不会报错,只会让键盘用户卡住不动。



















