前端可访问性

深入理解 Web 可访问性(a11y):WCAG 规范、ARIA 属性、键盘导航、屏幕阅读器支持,以及构建包容性 Web 应用。


可访问性概述

为什么需要可访问性

┌──────────────────────────────────────────────────────────────┐
│                    可访问性的价值                            │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│   残障人士                                                   │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  • 全球约 15% 人口有某种形式的残障                    │   │
│   │  • 4% 的人口有视力障碍                               │   │
│   │  • 1% 的人口有红绿色盲                               │   │
│   │  • 0.6% 的人口有运动障碍                             │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                              │
│   临时性障碍                                                 │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  • 手臂受伤无法使用鼠标                              │   │
│   │  • 眼睛手术后的临时视力下降                          │   │
│   │  • 手机在阳光下看不清屏幕                            │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                              │
│   情境性限制                                                 │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  • 嘈杂环境中无法听音频                              │   │
│   │  • 嘈杂环境中无法通话                                │   │
│   │  • 开车时只能用手操作                                │   │
│   │  • 网速慢时无法加载图片                              │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

WCAG 规范

Web Content Accessibility Guidelines(网页内容可访问性指南):

版本 发布时间 核心内容
WCAG 1.0 1999 最初的指南
WCAG 2.0 2008 12 条指南,4 项原则
WCAG 2.1 2018 新增移动端、低视力、认知障碍支持
WCAG 2.2 2023 新增焦点指示器、拖拽、认证等

四项核心原则(POUR)

┌──────────────────────────────────────────────────────────────┐
│                   WCAG 四项核心原则                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│   P - Perceivable (可感知)                                  │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  信息和 UI 组件必须以用户能感知的方式呈现              │   │
│   │  • 提供替代文本描述图片                               │   │
│   │  • 提供字幕给视频                                    │   │
│   │  • 内容可以用不同方式呈现而不丢失信息                 │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                              │
│   O - Operable (可操作)                                     │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  UI 组件和导航必须是可操作的                        │   │
│   │  • 所有功能都可以用键盘操作                         │   │
│   │  • 给用户足够的时间阅读内容                         │   │
│   │  • 不以引起癫痫的方式呈现内容                       │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                              │
│   U - Understandable (可理解)                               │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  信息和 UI 操作必须是可以理解的                      │   │
│   │  • 文本可读可理解                                   │   │
│   │  • 操作可预测                                     │   │
│   │  • 帮助用户避免和纠正错误                          │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                              │
│   R - Robust (健壮)                                         │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  内容必须能被各种用户代理可靠地解释                  │   │
│   │  • 符合规范                                        │   │
│   │  • 组件有可访问性名称和角色                        │   │
│   │  • 状态有可访问性状态信息                          │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

合规级别

级别 说明 目标
A 基础可访问性 必须满足
AA 实际应用标准 法规要求通常是 AA
AAA 最高标准 根据情况追求

语义化 HTML

正确的语义标签

<!-- 正确的语义化结构 -->
<header>
  <nav aria-label="主导航">
    <ul>
      <li><a href="/">首页</a></li>
      <li><a href="/about">关于</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <header>
      <h1>文章标题</h1>
      <p>发布时间:2024-01-15</p>
    </header>

    <section>
      <h2>章节标题</h2>
      <p>章节内容...</p>
    </section>
  </article>

  <aside>
    <h2>相关推荐</h2>
    <ul>
      <li><a href="/article/1">相关文章1</a></li>
      <li><a href="/article/2">相关文章2</a></li>
    </ul>
  </aside>
</main>

<footer>
  <p>&copy; 2024 公司名称</p>
</footer>

表单语义

<!-- 使用 label 关联表单控件 -->
<label for="email">邮箱地址</label>
<input
  type="email"
  id="email"
  name="email"
  aria-describedby="email-hint"
  required
>
<span id="email-hint">我们将发送确认邮件到这个地址</span>

<!-- 分组相关字段 -->
<fieldset>
  <legend>配送地址</legend>

  <label for="street">街道地址</label>
  <input type="text" id="street" name="street" required>

  <label for="city">城市</label>
  <input type="text" id="city" name="city" required>
</fieldset>

列表语义

<!-- 有序列表 -->
<ol>
  <li>第一步:注册账号</li>
  <li>第二步:完成实名认证</li>
  <li>第三步:开始使用</li>
</ol>

<!-- 无序列表 -->
<ul>
  <li>功能特点一</li>
  <li>功能特点二</li>
  <li>功能特点三</li>
</ul>

<!-- 导航列表 -->
<nav aria-label="面包屑">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/products">产品</a></li>
    <li>当前页面</li>
  </ol>
</nav>

ARIA 属性

ARIA 角色

<!-- landmark roles -->
<header role="banner">头部</header>
<nav role="navigation">导航</nav>
<main role="main">主要内容</main>
<aside role="complementary">侧边栏</aside>
<footer role="contentinfo">底部信息</footer>

<!-- 部件角色 -->
<button role="button">点击我</button>
<a href="#" role="link">链接</a>
<div role="menu">菜单</div>
<div role="menuitem">菜单项</div>
<div role="tablist">标签页列表</div>
<div role="tab">标签</div>
<div role="tabpanel">标签面板</div>

ARIA 属性

<!-- aria-label:提供可访问名称 -->
<button aria-label="关闭对话框">
  <svg aria-hidden="true"><!-- 关闭图标 --></svg>
</button>

<!-- aria-labelledby:引用其他元素作为标签 -->
<div role="dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">对话框标题</h2>
  <p>对话框内容...</p>
</div>

<!-- aria-describedby:提供额外描述 -->
<input
  type="text"
  id="password"
  aria-describedby="password-hint"
>
<span id="password-hint">密码至少8个字符,包含大小写字母和数字</span>

<!-- aria-hidden:隐藏非交互内容 -->
<div aria-hidden="true">
  <span>装饰性图标</span>
</div>

<!-- aria-expanded:指示展开/折叠状态 -->
<button aria-expanded="false" aria-controls="menu">
  菜单
</button>
<ul id="menu" hidden>
  <li>选项1</li>
  <li>选项2</li>
</ul>

<!-- aria-live:通知动态内容变化 -->
<div aria-live="polite" aria-atomic="true">
  <!-- 内容更新时会被屏幕阅读器朗读 -->
</div>

状态属性

<!-- aria-checked -->
<div role="checkbox" aria-checked="true">已选</div>

<!-- aria-selected -->
<div role="tab" aria-selected="true">当前标签</div>

<!-- aria-disabled -->
<button aria-disabled="true" disabled>禁用按钮</button>

<!-- aria-invalid -->
<input type="text" aria-invalid="true" aria-describedby="error-msg">
<span id="error-msg">请输入有效值</span>

<!-- aria-required -->
<input type="text" aria-required="true">

键盘导航

可聚焦元素

/* 确保可聚焦元素有可见焦点指示器 */
:focus {
  outline: 2px solid blue;
  outline-offset: 2px;
}

/* 只在键盘导航时显示焦点 */
:focus:not(:focus-visible) {
  outline: none;
}

:focus-visible {
  outline: 2px solid blue;
  outline-offset: 2px;
}

Tab 顺序

<!-- 使用 tabindex 管理 Tab 顺序 -->
<!-- tabindex="0" - 按自然顺序 -->
<button>第一个</button>
<button tabindex="0">第二个</button>
<button>第三个</button>

<!-- tabindex="-1" - 可编程聚焦,但不参与 Tab 顺序 -->
<button id="skip-link" tabindex="-1">跳转到内容</button>

<!-- tabindex="正数" - 指定 Tab 顺序(避免使用) -->
<button tabindex="3">最后</button>
<button tabindex="1">第一个</button>
<button tabindex="2">第二个</button>

键盘交互模式

// 按钮交互
<button
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
>
  点击我
</button>

// 下拉菜单
<div
  role="menu"
  onKeyDown={(e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        moveFocusToNextItem();
        break;
      case 'ArrowUp':
        e.preventDefault();
        moveFocusToPrevItem();
        break;
      case 'Escape':
        e.preventDefault();
        closeMenu();
        break;
      case 'Home':
        e.preventDefault();
        moveFocusToFirstItem();
        break;
      case 'End':
        e.preventDefault();
        moveFocusToLastItem();
        break;
    }
  }}
>
  <!-- menu items -->
</div>

跳过链接

<!-- 跳过链接 - 让键盘用户快速跳过重复内容 -->
<head>
  <style>
    .skip-link {
      position: absolute;
      top: -100px;
      left: 0;
      background: #000;
      color: #fff;
      padding: 8px 16px;
      z-index: 100;
    }

    .skip-link:focus {
      top: 0;
    }
  </style>
</head>

<body>
  <a href="#main-content" class="skip-link">
    跳转到主要内容
  </a>

  <nav><!-- 导航内容 --></nav>

  <main id="main-content" tabindex="-1">
    <!-- 主要内容 -->
  </main>
</body>

颜色与对比度

对比度要求

文本类型 AA 级要求 AAA 级要求
普通文本 4.5:1 7:1
大文本 (≥18px 或 14px 粗体) 3:1 4.5:1
UI 组件和图形对象 3:1 不适用
/* 使用工具验证对比度 */
/* https://webaim.org/resources/contrastchecker/ */

/* 推荐:深色背景配浅色文字 */
.good-contrast {
  background: #1a1a2e;
  color: #ffffff;
  /* 对比度:16:1 */
}

/* 不足:对比较低 */
.poor-contrast {
  background: #666666;
  color: #888888;
  /* 对比度:2.5:1 - 不合格 */
}

不要仅依赖颜色传达信息

<!-- 错误:只用颜色表示状态 -->
<span style="color: red;">错误</span>
<span style="color: green;">成功</span>

<!-- 正确:颜色 + 图标 + 文本 -->
<span class="status status--error">
  <svg aria-hidden="true"><!-- 错误图标 --></svg>
  <span>错误:用户名不能为空</span>
</span>

<span class="status status--success">
  <svg aria-hidden="true"><!-- 成功图标 --></svg>
  <span>成功:表单已提交</span>
</span>

高对比度模式支持

/* 使用系统颜色,不覆盖用户设置 */
@media (prefers-contrast: high) {
  :root {
    --text-color: #000000;
    --background-color: #ffffff;
    --link-color: #0000EE;
    --focus-outline: 3px solid #000000;
  }
}

/* 减少动画(敏感用户) */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

屏幕阅读器支持

测试工具

工具 平台 说明
NVDA Windows 免费开源
JAWS Windows 商业软件
VoiceOver macOS/iOS Apple 内置
TalkBack Android Google 内置
axe DevTools 浏览器扩展 自动检测问题

朗读顺序测试

<!-- 确保视觉顺序与 DOM 顺序一致 -->
<!-- DOM 顺序正确 -->
<article>
  <h2>标题</h2>
  <p>第一段</p>
  <p>第二段</p>
</article>

<!-- 问题:CSS 改变了视觉顺序但 DOM 顺序未变 -->
<style>
  .sidebar {
    float: right;
  }
</style>

<article>
  <div class="sidebar">侧边栏</div>
  <p>主要内容 - 在屏幕上显示在侧边栏左边</p>
  <!-- 但 DOM 中侧边栏在前,朗读时会先读侧边栏 -->
</style>

动态内容通知

// 使用 aria-live 通知屏幕阅读器
function showNotification(message) {
  const notification = document.getElementById('notification');

  // 清空并设置新内容(触发通知)
  notification.textContent = '';
  setTimeout(() => {
    notification.textContent = message;
  }, 100);
}

// 模板
<div
  id="notification"
  role="status"
  aria-live="polite"
  aria-atomic="true"
></div>

// 不同场景使用不同级别
// aria-live="off" - 不通知
// aria-live="polite" - 空闲时通知
// aria-live="assertive" - 立即通知(尽量少用)

表单可访问性

完整的表单示例

<form action="/submit" method="post">
  <fieldset>
    <legend>登录信息</legend>

    <div class="form-group">
      <label for="username">
        用户名
        <span aria-hidden="true">*</span>
        <span class="sr-only">(必填)</span>
      </label>
      <input
        type="text"
        id="username"
        name="username"
        autocomplete="username"
        required
        aria-required="true"
        aria-describedby="username-hint username-error"
      >
      <p id="username-hint" class="hint">
        3-20 个字符,支持字母、数字、下划线
      </p>
      <p id="username-error" class="error" role="alert" hidden>
        <!-- 错误信息动态填充 -->
      </p>
    </div>

    <div class="form-group">
      <label for="password">
        密码
        <span aria-hidden="true">*</span>
        <span class="sr-only">(必填)</span>
      </label>
      <input
        type="password"
        id="password"
        name="password"
        autocomplete="current-password"
        required
        aria-required="true"
        aria-describedby="password-requirements"
      >
      <ul id="password-requirements" class="requirements">
        <li id="req-length">至少 8 个字符</li>
        <li id="req-upper">包含大写字母</li>
        <li id="req-lower">包含小写字母</li>
        <li id="req-number">包含数字</li>
      </ul>
    </div>
  </fieldset>

  <button type="submit">登录</button>
</form>
/* 屏幕阅读器专用文本 */
.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;
}

错误处理

// 显示错误并关联到输入框
function showError(inputId, message) {
  const input = document.getElementById(inputId);
  const errorEl = document.getElementById(`${inputId}-error`);

  input.setAttribute('aria-invalid', 'true');
  input.setAttribute('aria-describedby', `${inputId}-error`);

  errorEl.textContent = message;
  errorEl.hidden = false;

  // 聚焦到错误字段
  input.focus();
}

function clearError(inputId) {
  const input = document.getElementById(inputId);
  const errorEl = document.getElementById(`${inputId}-error`);

  input.removeAttribute('aria-invalid');
  errorEl.textContent = '';
  errorEl.hidden = true;
}

多媒体可访问性

图片替代文本

<!-- 信息图片 - 提供描述 -->
<img
  src="chart.png"
  alt="2024 年销售额增长图表:Q1 100万,Q2 150万,Q3 200万,Q4 280万"
>

<!-- 功能图片 - 说明功能 -->
<a href="/home">
  <img src="logo.png" alt="公司 Logo,点击返回首页">
</a>

<!-- 装饰图片 - 使用空 alt -->
<img src="divider.png" alt="">

<!-- 复杂图片 - 使用 longdesc 或 figure/figcaption -->
<figure>
  <img src="complex-diagram.png" alt="系统架构图">
  <figcaption>
    <h3>图 1:系统架构</h3>
    <p>描述:用户通过负载均衡器连接到应用服务器,...</p>
    <p>详细说明请参见<a href="/docs/architecture">架构文档</a></p>
  </figcaption>
</figure>

视频字幕

<video controls>
  <source src="video.mp4" type="video/mp4">
  <track
    kind="captions"
    label="中文字幕"
    srclang="zh"
    src="captions.vtt"
    default
  >
  <track
    kind="descriptions"
    label="音频描述"
    srclang="zh"
    src="descriptions.vtt"
  >
</video>
<!-- captions.vtt 示例 -->
WEBVTT

00:00:00.000 --> 00:00:04.000
欢迎观看本教程。

00:00:04.500 --> 00:00:08.000
首先,打开设置菜单。

00:00:08.500 --> 00:00:12.000
点击"新建项目"按钮。

React 可访问性

常用库

说明
@react-aria Headless 组件和 Hooks
Radix UI 无样式、可访问的组件
Headless UI Tailwind 官方
Chakra UI 可访问的组件库

Headless UI 示例

import { Listbox } from '@headlessui/react';

function Select({ options, value, onChange }) {
  return (
    <Listbox value={value} onChange={onChange}>
      <Listbox.Label className="sr-only">选择颜色</Listbox.Label>
      <Listbox.Button
        className="flex items-center justify-between w-full px-4 py-2 border rounded"
        aria-describedby="color-hint"
      >
        {value.label}
      </Listbox.Button>
      <span id="color-hint" className="sr-only">
        当前选择:{value.label}
      </span>

      <Listbox.Options className="mt-1 border rounded shadow">
        {options.map((option) => (
          <Listbox.Option
            key={option.id}
            value={option}
            className={({ active, selected }) =>
              `px-4 py-2 cursor-pointer ${
                active ? 'bg-blue-100' : ''
              } ${selected ? 'font-bold' : ''}`
            }
          >
            {({ selected }) => (
              <>
                {option.label}
                {selected && (
                  <span className="sr-only">(当前选中)</span>
                )}
              </>
            )}
          </Listbox.Option>
        ))}
      </Listbox.Options>
    </Listbox>
  );
}

可访问性测试

axe-core 集成

import { axe, toHaveNoViolations } from 'jest-axe';
import { render, screen } from '@testing-library/react';
import { Button } from './Button';

expect.extend(toHaveNoViolations);

test('Button has no accessibility violations', async () => {
  const { container } = render(<Button>Click me</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Playwright 自动化测试

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('page has no accessibility violations', async ({ page }) => {
  await page.goto('/');

  const accessibilityScanResults = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

这一章想说的

可访问性让 Web 对所有人都可用:

  1. WCAG 规范:四项核心原则(POUR)指导所有实践
  2. 语义化 HTML:正确的标签是最基础的可访问性
  3. ARIA 属性:增强原生语义,覆盖边缘情况
  4. 键盘导航:确保所有功能可用键盘操作
  5. 对比度:足够的颜色对比度,不单靠颜色传达信息
  6. 屏幕阅读器:测试朗读顺序,使用 aria-live 通知变化

可访问性不是负担,而是质量的一部分。


延展阅读