Skip to content

Web 无障碍(a11y):让产品服务每一个人

目标:建立无障碍工程化思维,掌握从代码到流程的可访问性实践方法。


核心要点(TL;DR)

  • Web 无障碍(a11y)不是"加分项",而是产品可用性、合规性和品牌责任的组成部分。
  • 语义化 HTML 是无障碍的基础,ARIA 是补充而非替代。
  • 键盘可访问性是可访问性的底线:所有交互都应可通过键盘完成。
  • 屏幕阅读器用户依赖标题层级、地标(landmark)、表单标签和状态提示来理解页面。
  • 颜色不应是传递信息的唯一方式,需保证足够的对比度。
  • 无障碍需要融入开发流程:设计评审、代码审查、自动化测试、用户测试。

学习时长与前置知识

  • 建议学习时长:2-3 周(每周投入 4-6 小时)
  • 前置知识:HTML/CSS 基础(F06)、浏览器原理(F03)、JavaScript 基础(F01)

一、什么是 Web 无障碍?

Web 无障碍(Web Accessibility,简称 a11y)是指让网站和应用能够被尽可能多的人使用,包括:

  • 视觉障碍者(失明、低视力、色盲)
  • 听觉障碍者
  • 运动障碍者(无法使用鼠标、肢体震颤)
  • 认知障碍者(阅读困难、注意力缺陷)
  • 临时性障碍者(手臂受伤、强光环境、嘈杂环境)
  • 老年用户

1.1 无障碍不仅是道德义务

维度说明
法律合规中国《无障碍环境建设法》、欧盟《欧洲无障碍法》、美国 ADA/WCAG 诉讼
商业收益扩大用户群体,提升 SEO 和可用性
品牌责任体现企业的社会责任感
技术质量无障碍代码通常也是语义化、可维护性更好的代码

1.2 前端在无障碍中的核心位置

前端是用户与系统交互的最后一公里,直接决定:

  • 键盘能否操作
  • 屏幕阅读器能否理解
  • 焦点是否可见且合理
  • 颜色是否可辨识

二、WCAG:无障碍的通用标准

2.1 WCAG 2.1 四项原则(POUR)

原则含义示例
Perceivable(可感知)信息和界面组件必须可被用户感知图片有 alt 文本,视频有字幕
Operable(可操作)界面组件必须可操作所有功能可通过键盘访问
Understandable(可理解)信息和操作必须可理解错误提示清晰,语言简洁
Robust(健壮)内容可被各种辅助技术解析使用标准 HTML 和 ARIA

2.2 conformance 等级

  • A:最低要求,必须满足。
  • AA:推荐标准,大多数合规要求的目标。
  • AAA:最高标准,适用于特定场景。

国内法规通常要求达到 AA 级

2.3 关键成功标准

标准要求前端实践
1.1.1 非文本内容图片必须有替代文本alt="描述"
1.4.3 对比度文本与背景对比度至少 4.5:1检查颜色组合
2.1.1 键盘所有功能可通过键盘操作避免只依赖鼠标事件
2.4.3 焦点顺序焦点顺序符合逻辑DOM 顺序即焦点顺序
2.4.7 焦点可见当前焦点必须可见:focus-visible 样式
3.3.1 错误识别输入错误必须清晰标识关联错误信息与输入框
4.1.2 名称、角色、值组件必须有可访问名称和状态正确使用 label、aria-*

2.4 WCAG 2.2 新增成功标准

WCAG 2.2 于 2023 年 10 月成为正式推荐标准,在 WCAG 2.1 的 50 项成功标准基础上新增了 9 项。以下是对前端影响最大的三项:

2.4.11 Focus Not Obscured(AA 级)

聚焦的元素不能被其他元素完全遮挡。当一个元素获得键盘焦点时,用户必须能够看到焦点的所在位置。常见违规场景包括固定导航栏遮挡、弹窗遮罩层未正确管理、粘性页脚或 Cookie 横幅遮挡页面底部可聚焦元素。

css
/* ✅ 使用 scroll-margin 确保标题不被固定导航栏遮挡 */
h2, h3 {
  scroll-margin-top: 80px;
}

/* ✅ 确保弹窗开启时焦点元素在最顶层 */
[role="dialog"] {
  z-index: 1000;
}

2.5.7 Dragging Movements(AA 级)

对于需要拖拽完成的操作,必须提供基于单次点击或轻触的替代方式。例如拖拽排序的列表应提供"上移"/"下移"按钮作为替代方案。

html
<!-- ✅ 拖拽排序同时提供按钮替代 -->
<ul role="listbox" aria-label="排序列表">
  <li role="option" draggable="true">
    项目一
    <button aria-label="上移项目一">↑</button>
    <button aria-label="下移项目一">↓</button>
  </li>
</ul>

2.5.8 Target Size(AA 级)

交互目标的最小尺寸为 24×24 CSS 像素。AAA 级别要求 44×44 像素。例外情况包括:目标位于同一行或句子内(如内联链接)、目标大小由用户代理控制、或者目标是法律或财务功能中的必要元素。

css
/* ✅ 确保触摸目标至少 24x24 */
.icon-button {
  min-width: 24px;
  min-height: 24px;
  /* 即使图标本身小,点击区域也要足够 */
}

三、语义化 HTML:无障碍的基石

3.1 语义标签自带无障碍信息

html
<!-- ✅ 屏幕阅读器知道这是按钮 -->
<button type="submit">提交</button>

<!-- ❌ 屏幕阅读器只读到"提交"文本,不知道这是可点击按钮 -->
<div class="btn" onclick="submit()">提交</div>

3.2 标题层级构成文档地图

屏幕阅读器用户常通过标题快速跳转:

html
<h1>商品详情</h1>
  <h2>商品参数</h2>
    <h3>尺寸信息</h3>
  <h2>用户评价</h2>

禁忌:

  • 跳级(h1 后直接 h3)
  • 用标题控制字体大小
  • 一个页面多个 h1

3.3 地标元素(Landmark)

html
<header>
  <nav aria-label="主导航">...</nav>
</header>
<main>
  <article>...</article>
</main>
<aside>...</aside>
<footer>...</footer>

屏幕阅读器用户可以通过 landmark 快速跳转到页面不同区域。


四、ARIA:补充语义信息

ARIA(Accessible Rich Internet Applications)用于补充 HTML 无法表达的语义。

4.1 ARIA 三件套

类型作用示例
角色(Role)定义组件类型role="dialog"
状态(State)描述当前状态aria-expanded="true"
属性(Property)提供额外信息aria-label="关闭"

4.2 常见 ARIA 使用场景

html
<!-- 自定义按钮 -->
<div role="button" tabindex="0" aria-pressed="false">
  收藏
</div>

<!-- 开关 -->
<button role="switch" aria-checked="false" aria-label="夜间模式">
  <span aria-hidden="true">🌞</span>
</button>

<!-- 提示信息 -->
<div role="alert" aria-live="assertive">
  保存失败,请重试
</div>

4.3 ARIA 使用原则

  1. 优先使用原生 HTML:能用 <button> 就不用 role="button"
  2. 不要过度使用 ARIA:错误的 ARIA 比没有 ARIA 更糟糕。
  3. 确保可访问名称:每个交互元素都应有可访问名称(文本内容、aria-label 或 aria-labelledby)。
  4. 状态同步:ARIA 状态必须随组件状态实时更新。

4.4 ARIA 深度解析

ARIA 角色分类体系

ARIA 1.2 规范定义了六类角色,每个角色有不同的语义要求和使用场景:

角色分类描述示例
Widget Roles(控件角色)代表用户可交互的 UI 组件button, slider, tab, switch
Document Structure Roles(文档结构角色)描述页面内容的结构关系heading, list, table, toolbar
Landmark Roles(地标角色)标识页面主要功能区域navigation, main, banner, contentinfo
Live Region Roles(动态区域角色)内容动态更新的区域alert, log, status, timer
Window Roles(窗口角色)独立于主窗口的浮动层dialog, alertdialog
Abstract Roles(抽象角色)仅用于继承,不直接使用widget, input, range

控件角色详解

控件角色用于构建自定义交互组件:

html
<!-- 进度条 -->
<div role="progressbar" aria-valuenow="65" aria-valuemin="0" aria-valuemax="100">
  65%
</div>

<!-- 选项卡 -->
<div role="tablist" aria-label="设置选项">
  <button role="tab" aria-selected="true" aria-controls="panel-general">通用</button>
  <button role="tab" aria-selected="false" aria-controls="panel-security">安全</button>
</div>
<div role="tabpanel" id="panel-general"><!-- 内容 --></div>
<div role="tabpanel" id="panel-security"><!-- 内容 --></div>

地标角色最佳实践

每个页面应至少包含 <main> 地标。地标可以嵌套,但同一类型地标应使用 aria-label 区分:

html
<nav aria-label="主导航">...</nav>
<nav aria-label="页脚导航">...</nav>

第一条 ARIA 法则

If you can use a native HTML element with the semantics and behavior you require, don't use ARIA.

这是 WAI-ARIA 的第一条使用法则。原生 HTML 元素内置了语义、键盘交互和焦点管理,无需额外工作。ARIA 只在以下情况使用:

  • 使用原生 HTML 无法表达所需语义时(如 role="progressbar"
  • 构建需要特定角色关系的高级组件时(如 role="tablist" + role="tab"
  • 因设计约束无法使用语义元素时

ARIA Authoring Practices Guide (APG)

W3C 的 ARIA Authoring Practices Guide 提供了常见 UI 组件的完整无障碍实现方案,包括:

  • 推荐的键盘交互
  • 焦点管理模式
  • 角色、状态和属性的完整设置
  • 可运行的示例代码

APG 涵盖的组件模式包括:手风琴(Accordion)、自动补全(Autocomplete)、弹窗(Dialog)、网格(Grid)、列表盒(Listbox)、菜单(Menu)、滑块(Slider)、选项卡(Tabs)、工具栏(Toolbar)等。

js
// APG 推荐的手风琴键盘交互
function onAccordionKeyDown(event) {
  const { key } = event;
  switch (key) {
    case 'ArrowDown':
      moveFocusToNextItem();
      break;
    case 'ArrowUp':
      moveFocusToPreviousItem();
      break;
    case 'Home':
      moveFocusToFirstItem();
      break;
    case 'End':
      moveFocusToLastItem();
      break;
  }
}

五、键盘可访问性

5.1 键盘操作基础

按键行为
Tab在可聚焦元素间正向移动
Shift + Tab反向移动
Enter / Space激活按钮、链接
方向键在列表、菜单、单选组中导航
Esc关闭弹窗、菜单
Home / End跳到列表首尾

5.2 焦点管理

css
/* ✅ 可见焦点样式 */
:focus-visible {
  outline: 3px solid #3b82f6;
  outline-offset: 2px;
  border-radius: 2px;
}

/* ❌ 不要完全移除焦点样式 */
:focus { outline: none; }

5.3 焦点顺序

焦点顺序应与视觉顺序一致。避免使用 tabindex > 0,它会破坏自然顺序。

html
<!-- ❌ tabindex 大于 0 会改变焦点顺序 -->
<div tabindex="2">第二步</div>
<div tabindex="1">第一步</div>

<!-- ✅ 使用 DOM 顺序控制焦点 -->
<div tabindex="0">第一步</div>
<div tabindex="0">第二步</div>

5.4 弹窗与焦点陷阱

打开弹窗时:

  • 焦点应移动到弹窗内第一个可聚焦元素。
  • Tab 键应在弹窗内循环(焦点陷阱)。
  • 关闭弹窗时,焦点应回到触发按钮。
js
// 打开弹窗
modal.showModal();
modal.querySelector('button').focus();

// 关闭弹窗
triggerButton.focus();
modal.close();

5.5 焦点陷阱的高级实现

对于自定义模态弹窗,需要程序化实现焦点循环:

js
// 焦点陷阱函数
function trapFocus(container) {
  const focusable = container.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  container.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

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

  first.focus();
}

5.6 Roving Tabindex 模式

用于列表、菜单、选项卡等需要方向键导航的组件。核心思想是:整个组件仅有一个元素在 Tab 顺序中(tabindex="0"),其余元素 tabindex="-1",通过方向键动态切换哪个元素为 tabindex="0"。

js
// Roving Tabindex 实现模式
function rovingTabindex(listContainer, items) {
  items.forEach((item, index) => {
    item.setAttribute('tabindex', index === 0 ? '0' : '-1');

    item.addEventListener('keydown', (e) => {
      let nextIndex;
      switch (e.key) {
        case 'ArrowDown':
          nextIndex = (index + 1) % items.length;
          break;
        case 'ArrowUp':
          nextIndex = (index - 1 + items.length) % items.length;
          break;
        case 'Home':
          nextIndex = 0;
          break;
        case 'End':
          nextIndex = items.length - 1;
          break;
        default:
          return;
      }

      e.preventDefault();
      items[index].setAttribute('tabindex', '-1');
      items[nextIndex].setAttribute('tabindex', '0');
      items[nextIndex].focus();
    });
  });
}

跳转链接是页面加载后第一个可聚焦元素,允许键盘用户跳过重复导航直接进入主要内容区域:

html
<!-- ✅ 标准跳转链接实现 -->
<a href="#main-content" class="skip-link">
  跳到主要内容
</a>

<nav><!-- 重复的导航内容 --></nav>
<main id="main-content">
  <h1>页面标题</h1>
  <!-- 主要内容 -->
</main>
css
/* ✅ 跳转链接样式:只在聚焦时可见 */
.skip-link {
  position: absolute;
  top: -100%;
  left: 8px;
  padding: 8px 16px;
  background: #3b82f6;
  color: white;
  z-index: 10000;
}

.skip-link:focus {
  top: 8px;
}

六、屏幕阅读器测试工作流

屏幕阅读器是测试无障碍最关键的辅助技术工具。每种屏幕阅读器有不同的快捷键和交互模式,掌握它们是有效测试的前提。

6.1 NVDA(Windows,开源)

NVDA 是最广泛使用的 Windows 屏幕阅读器。关键快捷键:

快捷键功能
NVDA + 方向键浏览模式下逐字/逐行导航
NVDA + Tab获取当前聚焦元素的描述
NVDA + F7显示页面元素列表(标题、链接、地标)
NVDA + Space在浏览模式和焦点模式之间切换
方向键(浏览模式)在页面内容中逐行阅读
Tab(浏览模式)在可聚焦元素间跳转

浏览模式(Browse Mode):这是 NVDA 阅读网页的默认模式。用户可以用方向键按文档流阅读,屏幕阅读器会朗读语义信息(标题层级、地标、列表项等)。

焦点模式(Focus Mode):当用户聚焦到表单输入框或具有 role 的自定义交互组件时,NVDA 自动切换到此模式。按键直接传递给页面而非屏幕阅读器。

6.2 VoiceOver(macOS / iOS)

macOS 内置屏幕阅读器,快捷键:

快捷键功能
VO + 方向键导航到上一个/下一个元素
VO + Shift + 方向键与元素交互(点击、展开)
VO + Command + H按标题跳转
VO + Command + J跳转到下一个地标
VO + U打开转子(Rotor)菜单

转子(Rotor):VoiceOver 的核心导航功能,允许用户按标题、链接、表单控件、地标等维度快速跳转。

iOS 上的 VoiceOver 快捷键:

  • 单指左右滑动:浏览元素
  • 单指双击:激活元素
  • 双指轻扫:翻页
  • 三指上下滑动:快速滚动

6.3 常见屏幕阅读器测试流程

  1. 结构验证:使用元素列表(NVDA+F7 或 VO+U)检查标题层级是否合理、地标是否完整。
  2. 键盘导航:全程仅用 Tab、Shift+Tab 和方向键操作,验证所有功能可达。
  3. 表单测试:使用 Tab 进入表单字段,验证标签是否正确朗读,错误提示是否关联。
  4. 动态内容测试:验证 live region 更新是否被朗读,弹窗打开时焦点是否移动到弹窗内。
  5. 交互组件测试:验证自定义组件(下拉菜单、选项卡、树形控件)的键盘交互是否符合预期。

6.4 不同 ARIA 模式在屏幕阅读器中的表现

ARIA 模式屏幕阅读器行为
aria-live="polite"当前朗读完成后通知用户更新(适合非关键更新)
aria-live="assertive"立即中断当前朗读通知用户(适合错误提示)
role="alert"自动触发 assertive live region,无需手动设置 aria-live
aria-hidden="true"元素完全对屏幕阅读器不可见(用于装饰元素)
aria-expanded="true/false"朗读时告知用户当前折叠/展开状态
aria-describedby在朗读 aria-labelledby 或标签后,附加朗读描述内容

七、表单可访问性

7.1 标签关联

html
<!-- ✅ 显式关联 -->
<label for="email">邮箱</label>
<input id="email" type="email" name="email" />

<!-- ✅ 隐式关联 -->
<label>
  邮箱
  <input type="email" name="email" />
</label>

<!-- ❌ 无关联 -->
<span>邮箱</span>
<input type="email" name="email" />

7.2 错误提示

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

7.3 占位符不是标签

html
<!-- ❌ 占位符消失后用户会忘记要填什么 -->
<input type="text" placeholder="请输入用户名" />

<!-- ✅ 占位符仅作提示 -->
<label for="username">用户名</label>
<input id="username" type="text" placeholder="如:zhangsan" />

7.4 Live Regions 与动态验证反馈

对于异步表单验证(如用户名是否可用),使用 live region 通知屏幕阅读器用户验证结果:

html
<label for="username">用户名</label>
<input
  id="username"
  type="text"
  aria-describedby="username-status"
  aria-invalid="false"
/>
<div id="username-status" role="status" aria-live="polite">
  <!-- 动态验证结果将在此处更新 -->
</div>
js
// 动态验证反馈
usernameInput.addEventListener('blur', async () => {
  const status = document.getElementById('username-status');
  try {
    const available = await checkUsername(usernameInput.value);
    if (available) {
      status.textContent = '用户名可用';
      usernameInput.setAttribute('aria-invalid', 'false');
    } else {
      status.textContent = '用户名已被使用';
      usernameInput.setAttribute('aria-invalid', 'true');
    }
  } catch {
    status.textContent = '验证服务暂不可用';
  }
});

7.5 aria-describedby 多错误关联

复杂表单中一个输入框可能需要关联多个错误描述:

html
<label for="password">设置密码</label>
<input
  id="password"
  type="password"
  aria-describedby="password-rules password-strength password-error"
  aria-invalid="true"
/>
<ul id="password-rules">
  <li>至少 8 个字符</li>
  <li>包含大小写字母</li>
  <li>包含数字</li>
</ul>
<div id="password-strength">密码强度:中</div>
<div id="password-error">密码不符合安全要求</div>

7.6 复杂表单模式:分步表单

多步骤表单需要管理每一步的焦点和状态:

html
<!-- ✅ 分步表单的进度指示 -->
<nav aria-label="表单进度">
  <ol>
    <li aria-current="step">个人信息</li>
    <li>联系方式</li>
    <li>确认提交</li>
  </ol>
</nav>

<!-- ✅ 每一步切换时焦点管理 -->
<form id="multi-step-form">
  <fieldset id="step-1">
    <legend>个人信息</legend>
    <!-- 表单项 -->
  </fieldset>
  <fieldset id="step-2" hidden>
    <legend>联系方式</legend>
    <!-- 表单项 -->
  </fieldset>
</form>
js
// 分步表单焦点管理
function goToStep(stepIndex) {
  // 隐藏所有步骤
  document.querySelectorAll('[id^="step-"]').forEach(s => s.hidden = true);
  // 显示目标步骤
  const currentStep = document.getElementById(`step-${stepIndex}`);
  currentStep.hidden = false;
  // 焦点移动到步骤标题
  currentStep.querySelector('legend').focus();
  // 更新 aria-current
  document.querySelectorAll('[aria-current]').forEach(el => el.removeAttribute('aria-current'));
  document.querySelector(`li:nth-child(${stepIndex})`).setAttribute('aria-current', 'step');
}

八、图像、媒体与颜色

8.1 图片替代文本

html
<!-- ✅ 信息性图片 -->
<img src="chart.png" alt="2024 年 Q1 销售额同比增长 25%" />

<!-- ✅ 装饰性图片 -->
<img src="decoration.png" alt="" />

<!-- ❌ 无意义的 alt -->
<img src="chart.png" alt="图片" />

8.2 视频与音频

  • 提供字幕(captions)
  • 提供音频描述(audio descriptions)
  • 提供文字稿(transcript)

8.3 颜色对比

WCAG AA 要求:

  • 普通文本:4.5:1
  • 大文本(18pt+ 或 14pt+ 粗体):3:1

工具:WebAIM Contrast Checker、Lighthouse、axe DevTools。

8.4 不要只用颜色传达信息

html
<!-- ❌ 色盲用户无法区分 -->
<span class="status-red">失败</span>
<span class="status-green">成功</span>

<!-- ✅ 配合图标和文字 -->
<span class="status-error"><span aria-hidden="true">✕</span> 失败</span>
<span class="status-success"><span aria-hidden="true">✓</span> 成功</span>

8.5 APCA:新一代对比度算法

APCA(Accessible Perceptual Contrast Algorithm)是 WCAG 3.0 草案中提出的对比度评估方法,相比 WCAG 2.x 的简单比率有根本性改进:

对比WCAG 2.xAPCA
计算方式简单亮度比率感知亮度差(考虑空间频率、字体粗细)
文本大小仅区分普通和大文本连续分级的滚动比例
字体粗细仅考虑粗体精细区分不同字重
暗色模式无特殊处理考虑了暗色背景的对比感知差异
目标用户对比度损失 1.5 stops对比度损失 3 stops(覆盖更广泛的视觉障碍)

WCAG 3.0 APCA 建议值(青铜级):

  • 正文文本(>= 12px < 18px):APCA 值 >= 75
  • 大文本(>= 18px < 36px):APCA 值 >= 60
  • 超大文本(>= 36px):APCA 值 >= 45
  • 非文本组件:APCA 值 >= 45

8.6 深色模式的无障碍考量

深色模式不仅仅是反转颜色,需要特别注意:

css
/* ✅ 深色模式下的颜色对比度 */
[data-theme="dark"] {
  --text-primary: #e4e4e7;    /* 浅色文本 */
  --text-secondary: #a1a1aa;  /* 次级文本 */
  --bg-primary: #18181b;      /* 深色背景 */
  --bg-secondary: #27272a;    /* 次级背景 */
  /* 检查每一项对比度是否达标 */
}

/* ❌ 深色模式下饱和度变化 */
[data-theme="dark"] .status-error {
  color: #fca5a5;  /* 降低红色饱和度,避免刺眼 */
}

深色模式关键考量:

  • 对比度维持:深色背景上的浅色文本同样需要满足 4.5:1 对比度
  • 色相偏移:纯色在深色背景上看起来更亮,需要降低饱和度
  • 焦点样式可见:深色背景上的焦点环需要足够的亮度差
  • 图片滤镜:深色模式下可对图片添加 filter: brightness(0.8) 减少眩光
  • 用户偏好检测:使用 prefers-color-scheme 媒体查询检测用户系统设置

九、可访问性测试

9.1 自动化测试

工具用途
axe DevTools浏览器插件,检测 WCAG 违规
Lighthouse性能 + 可访问性评分
eslint-plugin-jsx-a11yReact 项目静态检查
@vue/a11yVue 项目静态检查
Pa11yCI 集成可访问性测试
Storybook a11y addon组件级可访问性检查

9.2 手动测试

  1. 拔掉鼠标,全程用键盘操作。
  2. 打开屏幕阅读器(NVDA、JAWS、VoiceOver)。
  3. 使用高对比度模式或放大页面。
  4. 用色盲模拟器检查颜色依赖。

9.3 用户测试

邀请真实的障碍用户参与测试,这是发现自动化工具无法捕捉问题的最佳方式。

9.4 axe-core 编程式 API

axe-core 是可访问性测试引擎,可在 Node.js 环境或浏览器中编程调用:

js
// Node.js 中使用 axe-core API
const axe = require('axe-core');
const { JSDOM } = require('jsdom');

const html = `
  <html>
    <body>
      <button>提交</button>
      <img src="photo.jpg" />
    </body>
  </html>
`;

const { window } = new JSDOM(html);
axe.run(window.document, (err, results) => {
  if (err) throw err;
  console.log(`违反数量:${results.violations.length}`);
  results.violations.forEach(v => {
    console.log(`- ${v.id}: ${v.description}`);
  });
});

9.5 Cypress-axe 集成

在 Cypress 端到端测试中加入可访问性断言:

js
// cypress/support/commands.js
import 'cypress-axe';

// 测试用例示例
describe('登录页面可访问性', () => {
  beforeEach(() => {
    cy.visit('/login');
    cy.injectAxe();
  });

  it('不应有严重可访问性违规', () => {
    cy.checkA11y(null, {
      runOnly: {
        type: 'tag',
        values: ['wcag2a', 'wcag2aa'],
      },
    });
  });

  it('表单错误后应检查可访问性', () => {
    cy.get('button[type="submit"]').click();
    cy.checkA11y('[role="alert"]');
  });
});

9.6 Playwright 可访问性断言

Playwright 集成了 axe-core,通过 expect.toPassA11y 进行断言:

js
// Playwright 测试示例
import { test, expect } from '@playwright/test';

test('首页可访问性检查', async ({ page }) => {
  await page.goto('/');

  // 执行可访问性扫描
  const violations = await page.evaluate(async () => {
    const axe = await import('axe-core');
    return axe.run(document).then(r => r.violations);
  });

  // 检查严重违规
  const criticalViolations = violations.filter(v => v.impact === 'critical');
  expect(criticalViolations.length).toBe(0);
});

使用 Playwright 的自带定位器可结合可访问性属性选择元素:

js
// Playwright 可访问性定位器
await page.getByRole('button', { name: '提交' }).click();
await page.getByLabel('邮箱').fill('user@example.com');
await page.getByPlaceholder('输入搜索关键词').fill('a11y');

9.7 CI 集成模式

将可访问性测试融入持续集成流水线:

yaml
# GitHub Actions 示例
name: Accessibility Check
on: [pull_request]
jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci

      # 静态检查
      - run: npm run lint:a11y

      # 构建并启动预览
      - run: npm run build
      - run: npm run preview &

      # Pa11y CI 扫描
      - run: npx pa11y-ci --sitemap http://localhost:3000/sitemap.xml

      # 如果使用 Cypress
      - run: npx cypress run --spec "cypress/e2e/a11y/**"

十、无障碍架构与流程

10.1 融入研发流程

设计评审 → 检查颜色、字体、交互是否可访问

开发 → 使用语义 HTML、ARIA、键盘事件

Code Review → 检查焦点、标签、对比度

自动化测试 → axe、Lighthouse、Pa11y

发布 → 监控真实用户反馈

10.2 建立无障碍规范

  • 组件库必须提供可访问的默认实现。
  • 设计 Token 中包含对比度要求。
  • 每个新功能必须通过键盘测试。
  • 定期进行可访问性审计。

十一、常见误区与反模式

误区说明正确做法
"无障碍只服务少数人"障碍是普遍体验,每个人都会在某些场景下遇到将无障碍作为默认要求
"ARIA 越多越好"错误的 ARIA 会误导辅助技术优先原生 HTML,ARIA 作补充
"只要颜色好看就行"颜色对比不足影响可辨识使用对比度检查工具
"测试阶段再补无障碍"后期改造成本高从设计和开发阶段融入
"移除 outline 更美观"键盘用户无法看到焦点使用 :focus-visible 自定义样式

十二、相关领域


十三、延伸阅读


十四、React 中的无障碍实践

React 生态系统为无障碍提供了多种成熟的解决方案。合理使用这些工具和库,可以大幅提升产品的可访问性标准。

14.1 @react-aria 与 React Aria Components

React Aria 是 Adobe 的 React Spectrum 项目中的可访问性 hooks 库,提供无样式的可访问行为:

jsx
import { useButton } from '@react-aria/button';
import { useRef } from 'react';

function AccessibleButton(props) {
  const ref = useRef(null);
  const { buttonProps } = useButton(props, ref);

  return (
    <button {...buttonProps} ref={ref} style={/* 样式由开发者控制 */}>
      {props.children}
    </button>
  );
}

React Aria 提供的关键 hooks:

  • useButton — 按钮的完整键盘交互和 ARIA 属性
  • useSwitch — 开关组件的角色、状态和键盘交互
  • useComboBox — 自动补全的全部可访问性逻辑
  • useDialog — 弹窗的焦点管理和角色绑定
  • useMenu / useMenuItem — 菜单的 roving tabindex 和键盘导航
  • useSlider — 滑块的方向键控制和 ARIA 属性
jsx
// React Aria 的 Switch 组件
function ThemeSwitch() {
  const state = useToggleState({ defaultSelected: false });
  const ref = useRef(null);
  const { inputProps } = useSwitch(
    { 'aria-label': '深色模式' },
    state,
    ref
  );

  return (
    <label style=&#123;&#123; display: 'flex', alignItems: 'center', gap: 8 &#125;&#125;>
      <input {...inputProps} ref={ref} />
      <span>深色模式</span>
    </label>
  );
}

14.2 Radix UI

Radix UI 提供无样式、可访问的 React 组件原语,每个组件都内置了完整的键盘交互和 ARIA 属性:

jsx
import * as Dialog from '@radix-ui/react-dialog';

function ModalExample() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button>编辑资料</button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>编辑个人资料</Dialog.Title>
          <Dialog.Description>更新您的个人信息</Dialog.Description>
          {/* 表单内容 */}
          <Dialog.Close asChild>
            <button>取消</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Radix UI 自动处理的访问性特性:

  • 弹窗打开/关闭时的焦点管理(焦点陷阱 + 返回触发元素)
  • 模态遮罩层的 aria-hidden 管理
  • 正确绑定 role="dialog"aria-modal="true"aria-labelledbyaria-describedby
  • Esc 键关闭弹窗
  • 方向键导航(Tabs、Select、Menu 等组件)

14.3 Headless UI

Tailwind Labs 开发的 Headless UI 同样提供无样式、完全可访问的组件:

jsx
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';

function SelectExample() {
  return (
    <Listbox>
      <ListboxButton>请选择角色</ListboxButton>
      <ListboxOptions>
        <ListboxOption value="admin">管理员</ListboxOption>
        <ListboxOption value="editor">编辑</ListboxOption>
        <ListboxOption value="viewer">只读</ListboxOption>
      </ListboxOptions>
    </Listbox>
  );
}

Headless UI 自动处理:aria-expanded、aria-selected、listbox 角色、roving tabindex、方向键导航等。

14.4 React 组件设计中的无障碍原则

在开发自定义 React 组件时,应遵循以下原则:

jsx
// 原则一:始终关联标签
function FormField({ label, id, error, children }) {
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      {children}
      {error && (
        <p id={`${id}-error`} role="alert" style=&#123;&#123; color: 'red' &#125;&#125;>
          {error}
        </p>
      )}
    </div>
  );
}

// 原则二:状态变化同步 ARIA 属性
function AccordionItem({ title, expanded, onToggle, children }) {
  return (
    <div>
      <button
        aria-expanded={expanded}
        aria-controls={`panel-${title}`}
        onClick={onToggle}
      >
        {title}
      </button>
      <div
        id={`panel-${title}`}
        role="region"
        aria-labelledby={`btn-${title}`}
        hidden={!expanded}
      >
        {children}
      </div>
    </div>
  );
}

// 原则三:焦点管理
function useFocusTrap(containerRef) {
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const focusable = container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    if (focusable.length === 0) return;

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

    function handleKeyDown(e) {
      if (e.key !== 'Tab') return;
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }

    container.addEventListener('keydown', handleKeyDown);
    first.focus();
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [containerRef]);
}

React 无障碍检查清单:

  • 每个交互元素都有可访问名称
  • 状态变化同步更新 ARIA 属性
  • 弹窗/侧边栏等临时 UI 有正确的焦点管理
  • 异步区域更新使用 live region 通知
  • 组件支持受控和非受控模式(状态同步)
  • 样式完全使用 :focus-visible 而非 :focus
  • 条件渲染的内容不会破坏焦点顺序

标签#a11y #wcag #aria #键盘导航 #屏幕阅读器 #无障碍测试

最后更新:2026-06-25


本领域学习进度

学习进度0 / 43 (0%)

基于 MIT 协议发布