inert 属性实战:抽屉、轮播与保存中状态下的焦点管理

2026-10-26 0 867

前端有块代码,几乎每个项目都写过,写完还得改好几轮——焦点陷阱。抽屉打开的时候,按 Tab 键不能跑到后面的页面上去;侧栏浮层出现的时候,后面的按钮不能被点到;轮播翻页的时候,屏幕外的那些链接不能被键盘聚焦。

传统做法是在 keydown 里拦截 Tab,算出当前容器里所有可聚焦元素,到头了就手动转到第一个。这套逻辑写起来一百行左右,边界情况却多得离谱:动态插入的元素、tabindex="-1" 的特殊节点、iframe、Shadow DOM 里的内容、还有那个永远处理不干净的”Shift+Tab 反向环绕”。

其实浏览器早就提供了答案:inert。它做的事情只有一句——让这个子树不许被交互。不用算焦点顺序,不用拦键盘事件,四件事浏览器一次性帮你办好。

一、inert 到底关掉了什么

给一个元素加上 inert,它和它的所有后代会被:

  • 移出焦点顺序。Tab 跳不过去,脚本调 el.focus() 也不生效。
  • 屏蔽指针交互。点击、触摸、拖拽都不再触发到里面的元素。
  • 从无障碍树里摘掉。读屏软件会完全忽略这部分内容,连读都不读。
  • 禁止编辑。里面原本可编辑的区域(contenteditable、表单输入)无法获得光标。

它不做的也很明确:元素照常渲染、照常占位、照常参与布局。视觉上一点变化都没有——除非你自己写样式。

这个”不改变显示”的性质非常关键。它意味着 inert 可以用于那些必须留在页面上、但此刻不该被操作的区域,而不是像 display: none 或者 hidden 那样直接抹掉。

属性写法很简单,就是个布尔属性:

<main id="app">
  <!-- 这里是页面主体 -->
</main>

<aside id="drawer">
  <!-- 抽屉内容 -->
</aside>
const app = document.getElementById('app');

function openDrawer() {
  app.inert = true;
}

function closeDrawer() {
  app.inert = false;
}

用 JS 属性赋值和用 HTML 属性写 <main inert> 是一回事,前者会同步到 attribute 上,CSS 里也能用 [inert] 选中。

二、案例一:抽屉导航,焦点陷阱不用写了

先做最典型的。HTML 结构:

<header>
  <button id="openBtn" type="button" aria-expanded="false" aria-controls="drawer">菜单</button>
</header>

<main id="app">
  <h1>订单列表</h1>
  <!-- 页面主体,很长 -->
</main>

<aside id="drawer" hidden>
  <nav aria-label="主导航">
    <ul>
      <li><a href="/orders" rel="external nofollow" >订单</a></li>
      <li><a href="/customers" rel="external nofollow" >客户</a></li>
      <li><a href="/settings" rel="external nofollow" >设置</a></li>
    </ul>
  </nav>
  <button id="closeBtn" type="button">关闭</button>
</aside>

和 inert 配合的脚本,重点不在开关,在焦点怎么进出:

const app = document.getElementById('app');
const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('openBtn');
const closeBtn = document.getElementById('closeBtn');

let lastFocused = null;

function openDrawer() {
  lastFocused = document.activeElement;

  drawer.hidden = false;
  app.inert = true;                  // 主体冻结
  document.body.classList.add('no-scroll');

  openBtn.setAttribute('aria-expanded', 'true');
  closeBtn.focus();                  // 焦点送进抽屉
}

function closeDrawer() {
  drawer.hidden = true;
  app.inert = false;
  document.body.classList.remove('no-scroll');

  openBtn.setAttribute('aria-expanded', 'false');
  lastFocused?.focus();              // 焦点送回原处
}

三件事分开说。

焦点要主动送进去。 app.inert = true 只是把主体冻住,抽屉本身并不会自动获得焦点。用户按 Tab 之后焦点会从 body 开始找下一个可聚焦元素,落到哪儿取决于 DOM 顺序,很可能直接跳出去。所以在打开之后立刻 closeBtn.focus(),把这个起点定下来。这也是为什么抽屉里最好有个明确的关闭按钮——它是最自然的初始焦点位置。

焦点要还记得送回来。 lastFocused 存的是打开之前用户所在的元素。关闭之后还原,用户按 Tab 会从原来那个位置继续往下走,而不是回到页面最顶端重新找。这一步如果漏了,键盘用户的体验会非常糟——读屏场景下尤其明显,用户会以为自己被扔到了别的地方。

inert 不管滚动。 这是新手最常期待落空的地方:app.inert = true 之后,主体确实点不动了,但滚轮照样能把背后的页面滚下去。inert 只处理交互和焦点,滚动是另一回事,还是得自己加 overflow: hidden(顺便配上 scrollbar-gutter 或者补个 padding,避免布局跳一下)。

至于焦点陷阱——不用写。主体 inert 之后,整个页面里可 Tab 到的元素只剩抽屉里那几个,Tab 到头会自然回到浏览器地址栏(这是符合预期的标准行为,别去拦它)。这才是把焦点陷阱交给浏览器之后最舒服的地方:不用再跟 getFocusableElements() 那套工具函数纠缠了。

顺手补个细节:点遮罩关闭

抽屉外面那层遮罩通常是独立元素,它自己不能 inert(不然就点不到了):

overlay.addEventListener('click', closeDrawer);

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && !drawer.hidden) closeDrawer();
});

注意 Escape 的监听放在 document 上而不是抽屉上,这样焦点万一飘出去也还能拦住。

三、案例二:轮播里那些看不见的页

轮播的问题很隐蔽:屏幕外的那几页并没有被隐藏,只是被 translate 挪出了视口。用 Tab 键一路按过去,焦点会跑到你看不见的地方,然后页面似乎卡住了——实际上焦点在视口外的那张卡片上。

结构大概是这样:

<section class="carousel" aria-roledescription="轮播">
  <div class="track">
    <article class="slide">第 1 页 <a href="/a" rel="external nofollow" >详情</a></article>
    <article class="slide">第 2 页 <a href="/b" rel="external nofollow" >详情</a></article>
    <article class="slide">第 3 页 <a href="/c" rel="external nofollow" >详情</a></article>
  </div>
  <button class="prev" type="button">上一页</button>
  <button class="next" type="button">下一页</button>
</section>
let current = 0;
const slides = [...document.querySelectorAll('.slide')];

function goTo(index) {
  current = Math.max(0, Math.min(index, slides.length - 1));

  slides.forEach((slide, i) => {
    slide.inert = i !== current;      // 只有当前页可交互
    slide.setAttribute('aria-hidden', i !== current ? 'true' : 'false');
  });

  track.style.setProperty('--index', current);
}

为什么用 inert 而不是给非当前页加 hidden?因为轮播要滑动。hidden 会让元素退出布局,滑动动画直接没了,最后一帧的位置全乱。inert 保持布局、保持渲染,只把交互和焦点关掉,正好是这里需要的语义。

这一行 aria-hidden 是配合用的,不是重复劳动:inert 会把子树从无障碍树里摘掉,理论上已经够了;但部分读屏软件在处理”视觉上存在、但不在当前页”的场景时,aria-hidden 能给出更明确的意图信号。两个一起写成本极低,稳一点没坏处。

有一个边界要留意:如果当前页里有元素正在获得焦点,切换页的时候要先处理焦点。 用户点了第 1 页里的链接,然后点”下一页”,那个链接立刻变成 inert——焦点会掉回 body。一种处理方式是切换后把焦点放到新页的第一个可聚焦元素,或者放到轮播容器本身(给容器加 tabindex="-1")。不处理也不会报错,但键盘用户会突然”失联”。

四、案例三:保存中的编辑器

这个场景比较特别:用户正在一个富文本区里编辑,点了保存,请求发出去了,这时候应该禁止继续输入——否则用户改的内容和发出去的内容对不上。

常见做法是加个 pointer-events: none 加半透明遮罩,结果键盘能继续打字;或者整个换成 contenteditable="false",但这会丢掉光标位置和选区,恢复的时候体验很跳。

inert 的做法:

<div id="editorWrap">
  <div id="editor" contenteditable="true">...</div>
  <button id="saveBtn" type="button">保存</button>
</div>
async function save() {
  const wrap = document.getElementById('editorWrap');
  wrap.inert = true;

  try {
    await fetch('/api/save', { method: 'POST', body: getContent() });
  } finally {
    wrap.inert = false;
  }
}

核心就两行。编辑器里的 DOM 一点没动,光标位置、选区、滚动位置全都保留着,只是这段时间内它不接受任何输入。请求结束后解除,用户接着打,光标还在原地。

这里必须点明一个容易误解的地方:inert 不等于 disabled。 被 inert 冻住的输入框,value 仍然是可读的,脚本仍然能改,而且如果这个表单被提交了,它的值照样会被提交上去。如果你希望字段”像被禁用一样排除在提交之外”,那得用 disabled 或者 readonly,inert 不是干这个的。

反过来说,如果你希望数据还在、只是不让用户动,那 inert 恰好是对的——用 disabled 反而会丢值(取决于后端怎么处理)。

五、把边界划清楚:inert 和它的几个近亲

这几组概念经常被混着用,效果差很多:

inert vs aria-hidden

aria-hidden="true" 只管无障碍树,不管焦点也不管点击。用它藏内容,键盘用户照样能 Tab 进去、点击照样生效——这就是那个经典的”给装饰图标加 aria-hidden,结果里面的按钮还能点”的事故。inert 是”另一个方向的完整版”:管得全面,只管交互层面,不管是不是在无障碍树里有一席之地(它直接摘掉)。

需要纯视觉隐藏但保留可访问性的场景(比如只给读屏用的文字),用的是另一套裁剪技术,跟这两个都不是一回事。

inert vs disabled

disabled 只能用在表单控件上,作用范围就那一个元素,而且会让值不参与提交。inert 能用在任何元素上,作用于整个子树,不影响值。一个是控件状态,一个是交互冻结,别混。

inert vs dialog 的 showModal

这条容易出”白干一场”的事故:<dialog> 调 showModal() 之后,浏览器已经自动把背景内容置为 inert 了。这时候如果你再手动给别人加 inert、然后在关闭时手动移除,很容易漏掉一层,导致关闭之后页面整体卡死——所有按钮都点不动,看起来像 JS 挂了。

规则很简单:用模态 dialog 的地方不要再手动 inert。只有在你自己实现浮层(没用 dialog 也没用 popover)时,才需要手工上 inert。同样地,Popover API 在显示 popover 的”自动”模式下也会处理背景的交互,别叠着用。

六、五个绕不开的坑

1. 子树里没法”豁免”

inert 是从祖先往下整体生效的,子元素里写 inert = false 没有任何效果。也就是说,一旦某个容器被冻结,里面不可能单独放开某个按钮。

如果真有这种需求(比如”整体冻结,但保留一个’取消’按钮可点”),唯一办法是把这个按钮挪到容器外面去,而不是想办法让 inert 开口子。这不是限制,是提醒你把结构重新想一遍。

2. 设 inert 时焦点会掉到 body

如果当前焦点在要被冻结的子树里,el.inert = true 之后浏览器会把焦点移走,通常是 body。如果那一刻你在做一些依赖 document.activeElement 的事(比如记录”上次焦点”),必须在设 inert 之前读取,之后就晚了。

3. 框架里 inert="false" 仍然是 inert

这是布尔属性的经典陷阱。HTML 里 inert="false" 的值是字符串 "false",只要属性存在,元素就是 inert——它和 inert、inert=""、inert="任意文本" 效果完全一样。

React 19 之后对布尔属性做了正确处理,inert={false} 会真的移除属性;但旧版本以及一些模板引擎(包括某些服务端模板)会把这个值原样输出成 inert="false",于是”我明明传了 false”却依然冻住。碰到这种诡异现象,先去审查元素里看一眼属性到底长什么样。

最稳的写法是控制属性在不在,而不是控制它的值:

// 好
el.toggleAttribute('inert', shouldInert);

// 有风险(某些模板/框架生成 inert="false")
el.setAttribute('inert', String(shouldInert));

4. 别手工补 pointer-events

网上不少文章会在 [inert] 上再写一条 pointer-events: none,理由是”保险”。实际上浏览器已经处理了指针事件,重复加反而可能带来副作用:某些实现在 pointer-events: none 下会让滚动行为发生变化,触摸设备上尤其容易出现”滚不动”的怪异感。不要画蛇添足。

想要视觉反馈(变灰、变淡)是另一回事,那用 [inert] { opacity: ... } 或加个透明度过渡,属于纯装饰,随便写。

5. inert 不会让元素”消失”

inert 的内容仍然能被 Ctrl+F 搜索到,仍然占着布局,也仍然会被截图工具和导出工具抓到。如果你的诉求是”让这段内容彻底不可见”,那得用 hidden、display: none,或者在需要保留 DOM 但参与无渲染的场景用 content-visibility。

反过来说,这也正是 inert 在”浮层打开、背景要保持视觉连续”时的价值所在——你不能用 hidden,得让它留在那儿。

七、降级:现在还需要兜底吗

inert 的支持已经相当普遍,Chrome 102、Firefox 112、Safari 15.5 之后就都跟上了,现在再写 2024 年那种”必须 polyfill”的结论已经不合适。上线前查一下目标环境的实际支持矩阵就够了。

如果确实还得照顾非常老的设备,兜底不必做成完整的 inert 模拟(那套方案复杂度很高,还要处理 MutationObserver 监听动态插入的元素),更实用的做法是:至少把焦点这一层兜住。

if (!('inert' in HTMLElement.prototype)) {
  // 降级:只在浮层打开时拦截 Tab,把焦点限制在容器内
  document.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    const layer = document.querySelector('.overlay:not([hidden])');
    if (!layer) return;

    const focusables = layer.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
    );
    if (!focusables.length) return;

    const first = focusables[0];
    const last = focusables[focusables.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });
}

代码只在新浏览器里不执行——if 那层保证了这点。它不完整(管不了点击穿透),但保住了键盘可用性这条底线。至于点击,老设备上就靠遮罩层盖住来挡吧,那个做法一直有效。

八、什么时候还是别用 inert

inert 很省事,但它不是”隐藏”和”禁用”的通用替代品。以下场景我通常不上它:

  • 内容要根据状态长期消失。 那就用 hidden,语义更准,还能省掉渲染开销。inert 保留渲染是有代价的。
  • 只是想让一个输入框暂时不能改。 readonly 更轻,而且用户仍然能选中文本、复制、Tab 进去读一遍。inert 会连聚焦都不给,反而更糟。
  • 模态 dialog 场景。 浏览器已经帮你 inert 背景了,再加一层只会制造 bug。
  • 需要在子树里保留可交互的元素。 前面说过了,inert 没有豁免机制,结构不对就得改结构,别在 CSS 或 JS 里绕。

最后说个观察。inert 的价值不只在”少写代码”——它把一件原本由业务逻辑维护的事(哪些元素此刻能操作)变成了一个由 DOM 结构直接表达的事实。你打开 DevTools 看一眼,哪个子树被冻着,一目了然。手写的焦点陷阱做不到这点,它的状态藏在某个变量里,只有读代码的人知道。

这也算是近几年 HTML 的一个趋势:能交给标记表达的语义,就别让它散落在脚本里。inert 是个很好的例子,它足够小,小到很多人觉得”没必要专门学”,但真用起来,省掉的代码和绕开的事故都挺实在。

inert 属性实战:抽屉、轮播与保存中状态下的焦点管理
收藏 (0) 打赏

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

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

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

淘吗网 html inert 属性实战:抽屉、轮播与保存中状态下的焦点管理 https://www.taomawang.com/web/html/2972.html

常见问题

相关文章

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

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