让网页更包容 无障碍访问技术实践与优化

Des.冰可 前端 阅读 1,453
赞 17 收藏
二维码
手机扫码查看
反馈

先来个实际的,别整虚的

上周上线了个新功能,一个带搜索的下拉选择组件。UI 挺好看,交互也顺滑,但 QA 测完提了一堆 accessibility 的问题——键盘操作不完整、屏幕阅读器读不出来、焦点管理混乱……我心想这玩意儿谁用啊,结果产品甩给我一条 WCAG 准则和公司合规要求,好吧,打脸了。

让网页更包容 无障碍访问技术实践与优化

折腾了两天,从完全不懂到把所有 a11y 问题干掉,还顺手给团队写了份内部指南。今天就把我踩过的坑、亲测有效的写法分享出来,全是实战经验,不整理论那一套。

按钮?别光靠视觉判断

很多人做“可点击区域”直接上 div + onclick,比如:

<div class="custom-select-trigger" onclick="toggleOptions()">
  请选择选项
</div>

看着没问题,鼠标点也 OK,但一上键盘就崩了——Tab 键根本跳不到这个元素上,screen reader(比如 VoiceOver 或 NVDA)也识别不了这是个按钮。

解决方法很简单:该用语义化标签就用。改成 button:

<button type="button" class="custom-select-trigger" onclick="toggleOptions()" aria-haspopup="true" aria-expanded="false">
  请选择选项
</button>

关键点:

  • type=”button”:防止意外提交表单
  • aria-haspopup:告诉辅助技术这个按钮会弹出内容(比如菜单)
  • aria-expanded:动态切换 true/false 表示展开或收起状态

JavaScript 控制的时候记得同步更新 aria-expanded:

function toggleOptions() {
  const btn = document.querySelector('.custom-select-trigger');
  const expanded = btn.getAttribute('aria-expanded') === 'true';
  btn.setAttribute('aria-expanded', !expanded);
}

别小看这几个属性,screen reader 用户全靠它们理解界面逻辑。

下拉列表怎么搞才合规

接着上面的,下拉选项列表我也一开始用了 ul + li 套路,但没加角色和关系绑定,结果读屏软件完全不知道这些选项属于哪个控件。

正确姿势:

<ul role="listbox" aria-labelledby="select-label" id="option-list" tabindex="-1">
  <li role="option" aria-selected="false" data-value="1">选项一</li>
  <li role="option" aria-selected="true" data-value="2">选项二</li>
  <li role="option" aria-selected="false" data-value="3">选项三</li>
</ul>

解释几个重点:

  • role=”listbox”:声明这是一个可选列表容器
  • role=”option”:每个选项都要标注为 option
  • aria-labelledby:关联到触发按钮的文本,建立父子关系
  • tabindex=”-1″:允许通过 JS 聚焦,但不在自然 Tab 流中出现(不然用户得一个个按过去)

这里注意我踩过好几次坑:如果 listbox 初始是隐藏的(display: none),那你用 JS 显示后一定要手动 focus 到第一个选项,否则键盘用户会“失焦”。

function showOptions() {
  const list = document.getElementById('option-list');
  list.style.display = 'block';
  // 等 DOM 更新后再 focus
  setTimeout(() => {
    const firstOption = list.querySelector('[role="option"]');
    if (firstOption) firstOption.focus();
  }, 10);
}

键盘支持不是 optional

很多开发者只测试鼠标操作,忽略了键盘导航。但很多用户(比如视力障碍者、手部不便者)完全依赖键盘。

你得支持以下行为:

  • 打开下拉:触发按钮获得焦点后按 Space 或 Enter
  • 上下切换选项:↑ ↓ 方向键
  • 确认选择:Enter 或 Tab
  • 关闭面板:Esc

监听 keydown 就完事了:

document.addEventListener('keydown', function(e) {
  const list = document.getElementById('option-list');
  const isVisible = list.style.display === 'block';
  const options = Array.from(list.querySelectorAll('[role="option"]'));
  const current = document.activeElement;

  if (!isVisible) return;

  switch(e.key) {
    case 'ArrowUp':
      e.preventDefault();
      const prevIndex = Math.max(0, options.indexOf(current) - 1);
      options[prevIndex].focus();
      break;
    case 'ArrowDown':
      e.preventDefault();
      const nextIndex = Math.min(options.length - 1, options.indexOf(current) + 1);
      options[nextIndex].focus();
      break;
    case 'Enter':
      e.preventDefault();
      selectOption(current);
      closeList();
      break;
    case 'Escape':
      e.preventDefault();
      closeList();
      document.querySelector('.custom-select-trigger').focus();
      break;
  }
});

这里有个细节:必须 preventDefault(),否则 ↑↓ 会滚动页面,Enter 会触发多次 click,体验极差。

隐藏内容 ≠ 删除 DOM

曾经我以为 display: none 就万事大吉,结果发现 screen reader 居然“看不见”这些元素了!对,它们真看不见。

如果你用 aria-live 区域、tooltip 或 modal,不能简单 hide 掉,要用视觉隐藏但保留可访问性的方式。

推荐 CSS 写法:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

这样元素在视觉上不可见,但仍能被 screen reader 读取。常用于动态提示:

<div aria-live="polite" class="sr-only" id="live-announcement"></div>
function announce(message) {
  const el = document.getElementById('live-announcement');
  el.textContent = message;
  // 再次赋值确保触发更新(有些浏览器需要)
  setTimeout(() => { el.textContent = ''; }, 1000);
}

表单控件别乱搞 label

最常见的错误就是写了 input 却没绑 label。你以为 placeholder 就够了?错,screen reader 不一定读 placeholder,而且键盘用户也不知道这输入框是干啥的。

正确做法:

<label for="search-input">搜索关键词</label>
<input type="text" id="search-input" name="q" aria-describedby="search-help">
<span id="search-help" class="sr-only">按回车开始搜索</span>

或者嵌套写法也行:

<label>
  搜索关键词
  <input type="text" name="q">
</label>

两种都可以,但我建议用 for + id 显式绑定,更可控。

另外,如果有错误提示,记得关联:

<input type="text" id="email" aria-invalid="true" aria-describedby="email-error">
<div id="email-error" role="alert">请输入有效邮箱地址</div>

API 请求状态要通知到所有人

加载中、成功、失败……这些状态变化不仅要视觉反馈,还得让 screen reader 知道。

我现在的做法是封装一个 notify 函数:

async function fetchData() {
  announce('开始加载数据');
  try {
    const res = await fetch('https://jztheme.com/api/data');
    const data = await res.json();
    renderData(data);
    announce('数据加载成功,共' + data.length + '条记录');
  } catch (err) {
    announce('数据加载失败,请稍后重试');
  }
}

结合前面的 aria-live 区域,信息就能及时传达。

踩坑提醒:这三点一定注意

这都是我拿 bug 换来的经验:

  1. 不要用 visibility: hidden 或 display: none 包裹需要被读取的内容,screen reader 会跳过。要用 .sr-only 这类技巧。
  2. 动态生成的 DOM 记得补全 ARIA 属性,尤其是单页应用(SPA),router 切换后容易漏掉 title 和 live region 重置。
  3. 避免使用 role=”presentation” 或 aria-hidden=”true” 过度,删错了可能把整个可交互区域屏蔽掉,我自己就误删过一个菜单导致无法操作。

还有一个隐蔽问题:移动端 iOS VoiceOver 在 Safari 中对某些 ARIA 属性支持不一致,建议上线前用真机测试基本流程。

Chrome DevTools 其实挺能打

别以为 a11y 测试只能靠盲人用户反馈。Chrome 的 Accessibility 面板就能查不少问题。

打开方式:Elements 面板 → 右侧 Accessibility 树,你能看到浏览器“认为”的语义结构。

还可以用 Lighthouse 跑一次 audit,分数低的基本都有明显问题。不过别迷信分数,60 分也能满足基础合规,关键是核心功能路径通顺。

这个技术的拓展用法还有很多

以上是我个人对无障碍访问的阶段性总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如高对比度模式适配、prefers-reduced-motion 动画控制、iframe 的 title 管理等,后续会继续分享这类博客。

最后说句实在话:做 a11y 不是为了加分,而是为了不让任何人被排除在外。改完之后我们收到一封来自视障用户的邮件,说终于能独立完成下单了——那一刻我觉得这两天加班值了。

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

暂无评论