Accessibility实战:提升网页可访问性的关键技巧与避坑指南

IT人子璐 工具 阅读 1,404
赞 16 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

项目上线前做了一轮无障碍(Accessibility)合规改造,加了各种 aria-* 属性、role、focus management,本来以为没啥大影响。结果一测性能,直接傻眼——页面滚动开始掉帧,按钮点击响应延迟明显,首屏交互时间从 1.2s 涨到了接近 5s。最离谱的是,在低端安卓机上,开启 TalkBack 后滑动列表几乎卡成幻灯片。

Accessibility实战:提升网页可访问性的关键技巧与避坑指南

我第一反应是:不至于吧?就几个属性多了这么多开销?但用户反馈很明确:开了读屏功能后体验崩了。这不能忍,必须优化。

找到瘼颈了!

先上 Chrome DevTools 的 Performance 面板跑了一遍记录,发现有两个高频出现的重排(Layout)和大量强制同步布局(Forced Synchronous Layouts)。顺着 call stack 往上看,罪魁祸首居然是频繁操作 DOM 节点的 aria-label 和 innerText。

我们有个动态通知组件,每次有新消息就通过 JS 修改一个 hidden 元素的文本内容,让读屏软件能播报出来。代码长这样:

const announce = (message) => {
  const el = document.getElementById('a11y-live-region');
  el.innerText = message;
  // 触发重新播报
  el.setAttribute('aria-live', 'assertive');
  setTimeout(() => el.setAttribute('aria-live', 'off'), 100);
};

问题来了:每调一次这个函数,浏览器就要触发一次重排,尤其是当 message 内容变化时,innerText 改变会引发文本重渲染。而且在 TalkBack 或 VoiceOver 开启时,系统会对 aria-live 区域做额外监听,导致主线程被持续占用。

我还用了 Axe 和 Lighthouse 做辅助分析,发现除了 live region 外,还有一些重复绑定的事件监听器也在拖慢初始化速度。比如每个可点击元素都绑了个 keydown 来模拟 click,但实际上很多根本用不上。

核心优化:减少 DOM 操作 + 节流播报

第一个下手的就是那个 announce 函数。既然不能频繁改 innerText,那就缓存一下当前值,避免无意义更新。

let currentAnnounce = '';
const announce = (message) => {
  if (message === currentAnnounce) return;

  const el = document.getElementById('a11y-live-region');
  el.innerText = message;
  currentAnnounce = message;

  el.setAttribute('aria-live', 'assertive');
  requestAnimationFrame(() => {
    setTimeout(() => el.setAttribute('aria-live', 'off'), 80);
  });
};

改动不大,但效果显著:

  • 加了去重判断,相同内容不再触发 DOM 更新
  • 把 aria-live 的关闭时机放到 requestAnimationFrame 里,避免强制同步布局
  • 延时从 100ms 缩到 80ms,实测够用且更轻快

这里注意我踩过好几次坑:一开始用 MutationObserver 监听变化再设置 aria-live,结果反而更卡——因为 observer 回调本身就有延迟,读屏器经常漏报。最后还是回到主动控制的老路子,稳定得多。

结构优化:合并 ARIA 控制区

原代码里分散着三个 aria-live=”polite” 的区域,分别用于提示加载状态、表单错误、网络异常。问题是,浏览器要为每一个维护独立的可访问性树节点,内存占用翻倍不说,切换时还会造成播报混乱。

解决办法很简单:统一收归到一个容器里。

<div id="a11y-live-region"
     aria-live="polite"
     aria-atomic="true"
     style="position: absolute; top: -9999px; left: -9999px;">
</div>

关键在 aria-atomic="true",意思是整个区域内容变更时一次性全部播报,不会只读一部分。JS 层也不用手动管理多个元素,干净利落。

事件监听瘦身

另一个隐藏性能杀手是事件代理滥用。为了满足 WCAG 的键盘导航要求,我们给所有带 role=”button” 的 div 都加上了 keydown 监听:

document.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    const target = e.target;
    if (target.getAttribute('role') === 'button') {
      e.preventDefault();
      target.click();
    }
  }
});

逻辑没错,但问题是这个监听挂在 document 上,每次按键都会遍历判断,哪怕是在 input 里打字也会进这个分支。低端机上连续输入时明显卡顿。

优化方案是加个白名单过滤:

const interactiveTags = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'];
document.addEventListener('keydown', (e) => {
  if (interactiveTags.includes(e.target.tagName)) return;

  if (e.key === 'Enter' || e.key === ' ') {
    const target = e.target;
    if (target.getAttribute('role') === 'button') {
      e.preventDefault();
      target.click();
    }
  }
});

虽然只是加了个 early return,但在表单页面这种高频输入场景下,FPS 提升了 8~12 帧。别小看这点改动。

懒加载非关键 ARIA 组件

有些弹窗和提示框其实不需要一上来就挂 accessibility 属性。比如一个帮助浮层,默认隐藏,只有用户点问号图标才出现。但它在 DOM 里一直存在,还带着一堆 aria-describedby 引用。

后来改成首次打开时才初始化相关属性:

let helpInitialized = false;
const showHelpModal = () => {
  if (!helpInitialized) {
    const modal = document.getElementById('help-modal');
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-labelledby', 'help-title');
    modal.setAttribute('aria-describedby', 'help-content');
    helpInitialized = true;
  }
  modal.style.display = 'block';
};

不仅减少了初始可访问性树的复杂度,还顺便降低了 SSR 输出的 HTML 体积。Lighthouse 的“Accessibility”评分从 78 直接涨到 93。

优化后:流畅多了

改完之后重新压测:

  • 首屏可交互时间从平均 4.8s 降到 800ms
  • 滚动帧率从 30fps+ 波动提升至稳定 50~60fps(TalkBack 开启状态下)
  • Lighthouse Accessibility 分数:78 → 93
  • 主线程繁忙时间减少约 70%

最关键的是,QA 同事反馈:“终于不用关读屏器测试了。” 这话比啥数据都实在。

性能数据对比

这是优化前后两次 Lighthouse 报告的关键指标对比(设备:Moto G4,Chrome 122,开启远程调试):

指标 优化前 优化后
First Contentful Paint 2.1s 1.3s
Largest Contentful Paint 4.6s 780ms
Total Blocking Time 620ms 90ms
Accessibility Score 78 93

有意思的是,性能提升了,无障碍得分也没丢,反而是更高了——说明之前那些冗余操作对读屏器也不友好。

还有哪些可以改进?

目前仍有小问题:某些快速连发的 announce 消息还是会合并成一条播报,暂时靠延长间隔缓解。理论上可以用 SpeechSynthesis API 替代 aria-live,但兼容性和语音自然度不够理想,没敢上生产。

另外,动态插入带 aria 标记的 DOM 片段时,如果父节点已经渲染完成,仍可能引发异步 reflow。这块考虑后续引入虚拟滚动或容器锚定(container queries)来进一步隔离。

以上是我的优化经验,有更优的实现方式欢迎评论区交流

这个项目让我意识到:无障碍不是加几个属性就完事,它也是性能战场的一部分。尤其在移动设备上,每个多余的 DOM 操作都可能成为卡顿导火索。

改完后整体还算满意,虽然方案不算完美,但至少用户能流畅使用了。有时候最优解不如“够用且稳定”的方案来得实际。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论