真正的焦点循环需手动拦截Tab键并控制首尾跳转,配合tabindex设置可聚焦边界、过滤真实可聚焦元素、记录并恢复触发点焦点,避免SPA卸载导致的focus失败。

focus() 和 tabindex 配合不等于焦点循环
直接调用 element.focus() 只能聚焦单个元素,无法形成“按 Tab 键在弹窗内循环、Esc 关闭、离开弹窗区域不跳转到页面其他位置”的行为。真正的焦点循环需要手动拦截 Tab 键事件,并控制焦点在弹窗内首尾元素之间跳转。
关键点在于:弹窗必须有明确的可聚焦元素边界(通常用 tabindex="-1" 包裹容器,再用 tabindex="0" 标记内部可交互节点),否则浏览器默认会把焦点交给 <body> 或地址栏。
- 弹窗打开时,立即用
firstFocusableElement.focus()聚焦首个可操作项(如确认按钮或第一个输入框) - 监听
keydown事件,捕获Tab键;若焦点已在最后一个可聚焦元素且用户按了Shift+Tab,则跳回最后一个;反之,已在第一个时按Tab,跳到第一个 - 必须同时处理
Shift键状态,否则反向 Tab 会失效
如何获取弹窗内所有可聚焦元素
不能只靠 querySelectorAll('button, input, select, textarea, [tabindex]') —— 这会漏掉 tabindex="0" 的 div、禁用元素(disabled)、隐藏元素(display: none 或 visibility: hidden)也会被选中但无法聚焦,导致循环中断。
推荐用这个过滤逻辑:
立即学习“前端免费学习笔记(深入)”;
const focusableSelector = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])';
const focusableElements = Array.from(modalElement.querySelectorAll(focusableSelector))
.filter(el => {
const style = window.getComputedStyle(el);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
el.offsetParent !== null;
});
注意:tabindex="-1" 元素本身不可通过 Tab 进入,但可通过 JS 主动 .focus(),适合做容器锚点或关闭按钮的备用聚焦目标。
Escape 关闭 + 焦点回归原触发点
弹窗关闭后,焦点不应留在 <body> 或丢失,而应回到触发弹窗的元素(比如点击的 <button id="open-modal">)。否则屏幕阅读器用户会迷失上下文。
- 打开弹窗前,用
document.activeElement记录当前聚焦元素,并保存其id或引用 - 关闭弹窗时,检查该元素是否仍存在于 DOM 且可聚焦;若已销毁或不可聚焦,则 fallback 到弹窗触发按钮或
main入口 - 对
Escape事件需event.preventDefault(),避免触发浏览器默认行为(如退出全屏)
常见坑:SPA 中路由切换后原触发元素可能已被卸载,此时强行 .focus() 会静默失败,建议加一层 if (el && el.focus) 安全判断。
modal.open() 后焦点没进弹窗?检查这三处
即使代码写全,焦点仍卡在背景页,大概率是以下某一处断链:
-
modalElement没设tabindex="-1",导致它本身不可被.focus(),而内部元素又未及时渲染(如 Vue/React 异步更新后未 await nextTick) - CSS 中设置了
outline: none且没提供替代焦点样式(视觉焦点消失 ≠ 焦点没生效),可用:focus-visible补救 - 弹窗使用
display: none切换而非visibility: hidden或opacity+pointer-events,导致querySelectorAll找不到元素(display: none元素不参与渲染,offsetParent为null)
最稳妥的做法是:弹窗结构始终在 DOM 中,仅靠 CSS 控制显隐;首次打开时确保所有子元素已挂载完毕再执行焦点逻辑。



















