Skip to content

无障碍访问(A11y)

什么是 Web 无障碍?

官方定义

Web 无障碍(Web Accessibility,简称 A11y)是指让网站和应用程序对所有人可用,包括残障人士。这涉及到视觉、听觉、运动和认知障碍的用户。

通俗理解

想象你在一个完全黑暗的房间里使用手机 —— 你只能依靠声音来操作。无障碍设计就是确保即使在这种情况下,用户也能正常使用你的网站。

常见的无障碍场景

  • 盲人使用屏幕阅读器浏览网页
  • 色盲用户无法区分某些颜色
  • 手部残疾用户只能使用键盘
  • 老年人视力下降,需要放大字体

WCAG 标准

Web Content Accessibility Guidelines(WCAG)是 W3C 制定的无障碍指南。

四大原则(POUR)

原则说明示例
可感知 (Perceivable)信息必须以用户能感知的方式呈现图片提供替代文本
可操作 (Operable)界面组件必须可操作所有功能可通过键盘访问
可理解 (Understandable)信息和操作必须可理解清晰的错误提示
健壮性 (Robust)内容必须能被各种用户代理解析使用语义化 HTML

合规级别

  • A 级:最低要求,解决最严重的障碍
  • AA 级:中等要求,大多数网站的目标(推荐)
  • AAA 级:最高要求,适用于特定场景

语义化 HTML

使用正确的 HTML 元素是无障碍的基础。

正确使用标题层级

html
<!-- ✅ 正确:标题层级清晰 -->
<h1>网站标题</h1>
<main>
  <article>
    <h2>文章标题</h2>
    <section>
      <h3>章节标题</h3>
      <p>内容...</p>
    </section>
  </article>
</main>

<!-- ❌ 错误:跳过标题层级 -->
<h1>网站标题</h1>
<h3>直接使用 h3</h3>  <!-- 跳过了 h2 -->

<!-- ❌ 错误:用样式代替语义 -->
<div class="title">这不是真正的标题</div>
<span style="font-size: 24px; font-weight: bold;">这也不是</span>

使用语义化标签

html
<!-- ✅ 使用语义化标签 -->
<header>
  <nav aria-label="主导航">
    <ul>
      <li><a href="/">首页</a></li>
      <li><a href="/about">关于</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <h1>文章标题</h1>
    <p>文章内容...</p>
  </article>

  <aside aria-label="相关文章">
    <h2>相关推荐</h2>
    <ul>
      <li><a href="#">推荐文章1</a></li>
    </ul>
  </aside>
</main>

<footer>
  <p>&copy; 2024 版权所有</p>
</footer>

<!-- ❌ 全部使用 div -->
<div class="header">
  <div class="nav">...</div>
</div>
<div class="main">
  <div class="article">...</div>
</div>

按钮和链接

html
<!-- ✅ 正确使用按钮 -->
<button type="button" onclick="handleClick()">点击我</button>

<!-- ❌ 错误:用 div 模拟按钮 -->
<div class="button" onclick="handleClick()">点击我</div>

<!-- ✅ 正确使用链接 -->
<a href="/about">关于我们</a>

<!-- ❌ 错误:用按钮做导航 -->
<button onclick="location.href='/about'">关于我们</button>

<!-- ✅ 链接在新窗口打开时提示用户 -->
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
  外部链接
  <span class="sr-only">(在新窗口打开)</span>
</a>

ARIA 属性

ARIA(Accessible Rich Internet Applications)提供额外的语义信息。

常用 ARIA 角色

html
<!-- 地标角色 -->
<div role="banner">网站头部</div>
<div role="navigation">导航</div>
<div role="main">主要内容</div>
<div role="complementary">侧边栏</div>
<div role="contentinfo">页脚</div>

<!-- 组件角色 -->
<div role="button">自定义按钮</div>
<div role="dialog">对话框</div>
<div role="alert">警告信息</div>
<div role="tab">标签页</div>
<div role="tabpanel">标签面板</div>
<div role="menu">菜单</div>
<div role="menuitem">菜单项</div>

常用 ARIA 属性

html
<!-- aria-label:为元素提供标签 -->
<button aria-label="关闭弹窗">×</button>
<nav aria-label="主导航">...</nav>

<!-- aria-labelledby:引用其他元素作为标签 -->
<h2 id="section-title">用户设置</h2>
<section aria-labelledby="section-title">
  <!-- 内容 -->
</section>

<!-- aria-describedby:提供额外描述 -->
<input type="password" aria-describedby="password-hint">
<p id="password-hint">密码至少包含 8 个字符</p>

<!-- aria-hidden:对屏幕阅读器隐藏 -->
<span aria-hidden="true">🔍</span>  <!-- 装饰性图标 -->
<span class="sr-only">搜索</span>   <!-- 仅屏幕阅读器可见 -->

<!-- aria-live:动态内容通知 -->
<div aria-live="polite">
  <!-- 内容变化时会被朗读 -->
</div>
<div aria-live="assertive">
  <!-- 立即打断并朗读 -->
</div>

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

<!-- aria-current:当前项 -->
<nav>
  <a href="/" aria-current="page">首页</a>
  <a href="/about">关于</a>
</nav>

<!-- aria-disabled:禁用状态 -->
<button aria-disabled="true">提交中...</button>

<!-- aria-invalid:表单验证错误 -->
<input type="email" aria-invalid="true" aria-describedby="email-error">
<span id="email-error">请输入有效的邮箱地址</span>

实际组件示例

html
<!-- 模态框 -->
<div role="dialog"
     aria-modal="true"
     aria-labelledby="dialog-title"
     aria-describedby="dialog-desc">
  <h2 id="dialog-title">确认删除</h2>
  <p id="dialog-desc">确定要删除这条记录吗?此操作不可恢复。</p>
  <button>确认</button>
  <button>取消</button>
</div>

<!-- 标签页 -->
<div role="tablist" aria-label="产品信息">
  <button role="tab"
          id="tab-1"
          aria-selected="true"
          aria-controls="panel-1">
    详情
  </button>
  <button role="tab"
          id="tab-2"
          aria-selected="false"
          aria-controls="panel-2">
    评价
  </button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  产品详情内容...
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
  用户评价内容...
</div>

<!-- 下拉菜单 -->
<div class="dropdown">
  <button aria-haspopup="true"
          aria-expanded="false"
          aria-controls="dropdown-menu">
    选择选项
  </button>
  <ul id="dropdown-menu" role="menu" hidden>
    <li role="menuitem"><a href="#">选项 1</a></li>
    <li role="menuitem"><a href="#">选项 2</a></li>
    <li role="menuitem"><a href="#">选项 3</a></li>
  </ul>
</div>

<!-- 进度条 -->
<div role="progressbar"
     aria-valuenow="75"
     aria-valuemin="0"
     aria-valuemax="100"
     aria-label="下载进度">
  <span style="width: 75%"></span>
</div>

<!-- 警告信息 -->
<div role="alert" aria-live="assertive">
  操作失败,请重试!
</div>

图片无障碍

alt 属性

html
<!-- ✅ 有意义的图片:描述内容 -->
<img src="chart.png" alt="2024年销售趋势图,显示销售额从1月的100万增长到12月的500万">

<!-- ✅ 装饰性图片:空 alt -->
<img src="decorative-line.png" alt="">

<!-- ✅ 图片作为链接:描述链接目的 -->
<a href="/products">
  <img src="product.jpg" alt="查看所有产品">
</a>

<!-- ✅ 复杂图片:使用 figure 和 figcaption -->
<figure>
  <img src="complex-diagram.png" alt="系统架构图">
  <figcaption>
    图1:微服务架构图,展示了用户服务、订单服务和支付服务之间的交互关系。
  </figcaption>
</figure>

<!-- ❌ 错误:无意义的 alt -->
<img src="photo.jpg" alt="图片">
<img src="photo.jpg" alt="photo.jpg">
<img src="photo.jpg" alt="点击这里">

SVG 无障碍

html
<!-- 装饰性 SVG -->
<svg aria-hidden="true" focusable="false">
  <!-- 图标内容 -->
</svg>

<!-- 有意义的 SVG -->
<svg role="img" aria-labelledby="svg-title svg-desc">
  <title id="svg-title">饼图</title>
  <desc id="svg-desc">显示市场份额:A公司40%,B公司35%,其他25%</desc>
  <!-- 图形内容 -->
</svg>

<!-- 交互式 SVG -->
<button aria-label="播放视频">
  <svg aria-hidden="true" focusable="false">
    <polygon points="0,0 0,20 15,10" />
  </svg>
</button>

表单无障碍

标签关联

html
<!-- ✅ 使用 for 属性关联 -->
<label for="username">用户名</label>
<input type="text" id="username" name="username">

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

<!-- ✅ 使用 aria-label -->
<input type="search" aria-label="搜索产品" placeholder="搜索...">

<!-- ❌ 错误:没有标签 -->
<input type="text" placeholder="用户名">

表单验证

html
<form novalidate>
  <div class="form-group">
    <label for="email">邮箱 <span aria-hidden="true">*</span></label>
    <input
      type="email"
      id="email"
      name="email"
      required
      aria-required="true"
      aria-invalid="true"
      aria-describedby="email-error email-hint"
    >
    <p id="email-hint" class="hint">我们不会分享您的邮箱</p>
    <p id="email-error" class="error" role="alert">
      请输入有效的邮箱地址
    </p>
  </div>

  <div class="form-group">
    <label for="password">密码</label>
    <input
      type="password"
      id="password"
      name="password"
      minlength="8"
      aria-describedby="password-requirements"
    >
    <ul id="password-requirements">
      <li>至少 8 个字符</li>
      <li>包含大小写字母</li>
      <li>包含数字</li>
    </ul>
  </div>

  <button type="submit">注册</button>
</form>

必填字段

html
<!-- ✅ 正确标记必填字段 -->
<label for="name">
  姓名
  <span class="required" aria-hidden="true">*</span>
  <span class="sr-only">(必填)</span>
</label>
<input type="text" id="name" required aria-required="true">

<!-- 表单开头说明 -->
<p>标有 <span aria-hidden="true">*</span> 的字段为必填项。</p>

分组控件

html
<!-- 使用 fieldset 和 legend 分组 -->
<fieldset>
  <legend>收货地址</legend>

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

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

<!-- 单选按钮组 -->
<fieldset>
  <legend>支付方式</legend>

  <label>
    <input type="radio" name="payment" value="card"> 银行卡
  </label>
  <label>
    <input type="radio" name="payment" value="alipay"> 支付宝
  </label>
  <label>
    <input type="radio" name="payment" value="wechat"> 微信支付
  </label>
</fieldset>

键盘导航

焦点管理

html
<!-- tabindex 使用 -->
<!-- 0:正常焦点顺序 -->
<div tabindex="0">可以获得焦点</div>

<!-- -1:可通过 JS 聚焦,但不在 tab 顺序中 -->
<div tabindex="-1" id="modal">模态框内容</div>

<!-- 正数(避免使用):指定 tab 顺序 -->
<input tabindex="1">  <!-- 不推荐 -->
javascript
// 焦点管理示例
function openModal() {
  const modal = document.getElementById('modal');
  const firstFocusable = modal.querySelector('button, input, a');

  modal.hidden = false;
  firstFocusable.focus();

  // 焦点陷阱:保持焦点在模态框内
  modal.addEventListener('keydown', trapFocus);
}

function trapFocus(e) {
  const modal = e.currentTarget;
  const focusableElements = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];

  if (e.key === 'Tab') {
    if (e.shiftKey && document.activeElement === firstElement) {
      e.preventDefault();
      lastElement.focus();
    } else if (!e.shiftKey && document.activeElement === lastElement) {
      e.preventDefault();
      firstElement.focus();
    }
  }

  if (e.key === 'Escape') {
    closeModal();
  }
}

跳过链接

html
<!-- 跳过导航链接 -->
<body>
  <a href="#main-content" class="skip-link">
    跳到主要内容
  </a>

  <header>
    <nav>
      <!-- 很长的导航菜单 -->
    </nav>
  </header>

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

<style>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px 16px;
  z-index: 100;
  transition: top 0.3s;
}

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

键盘快捷键

javascript
// 自定义键盘快捷键
document.addEventListener('keydown', (e) => {
  // Alt + S 搜索
  if (e.altKey && e.key === 's') {
    e.preventDefault();
    document.getElementById('search-input').focus();
  }

  // Alt + H 返回首页
  if (e.altKey && e.key === 'h') {
    e.preventDefault();
    window.location.href = '/';
  }
});

// 提供快捷键提示
<button aria-keyshortcuts="Alt+S" onclick="focusSearch()">
  搜索 (Alt+S)
</button>

颜色和对比度

对比度要求

级别正常文本大文本
AA4.5:13:1
AAA7:14.5:1

大文本定义:18px 以上,或 14px 加粗

css
/* ✅ 高对比度 */
.text-good {
  color: #333333;           /* 深灰色文字 */
  background-color: #ffffff; /* 白色背景 */
  /* 对比度约 12.6:1 */
}

/* ❌ 低对比度 */
.text-bad {
  color: #999999;            /* 浅灰色文字 */
  background-color: #ffffff; /* 白色背景 */
  /* 对比度约 2.8:1,不满足 AA 级 */
}

/* ✅ 链接颜色 */
a {
  color: #0066cc;  /* 蓝色链接 */
  text-decoration: underline;  /* 不仅靠颜色区分 */
}

/* ✅ 焦点样式 */
:focus {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* ❌ 不要移除焦点样式 */
:focus {
  outline: none;  /* 错误! */
}

不仅依赖颜色

html
<!-- ✅ 使用图标 + 颜色 + 文字 -->
<div class="alert alert-error">
  <span class="icon">⚠️</span>
  <span class="text">错误:用户名已存在</span>
</div>

<div class="alert alert-success">
  <span class="icon">✓</span>
  <span class="text">成功:账号已创建</span>
</div>

<!-- ✅ 表单错误:颜色 + 图标 + 文字 -->
<input type="email" class="error" aria-invalid="true">
<span class="error-icon">⚠️</span>
<span class="error-message">请输入有效的邮箱地址</span>

<!-- ❌ 仅使用颜色表示状态 -->
<input type="email" style="border-color: red;">

媒体无障碍

视频字幕

html
<video controls>
  <source src="video.mp4" type="video/mp4">

  <!-- 字幕轨道 -->
  <track
    kind="subtitles"
    src="subtitles-zh.vtt"
    srclang="zh"
    label="中文字幕"
    default
  >
  <track
    kind="subtitles"
    src="subtitles-en.vtt"
    srclang="en"
    label="English"
  >

  <!-- 音频描述 -->
  <track
    kind="descriptions"
    src="descriptions.vtt"
    srclang="zh"
    label="音频描述"
  >
</video>

WebVTT 字幕格式

WEBVTT

00:00:01.000 --> 00:00:04.000
大家好,欢迎来到我们的频道

00:00:04.500 --> 00:00:08.000
今天我们要讲的是 Web 无障碍

音频转录

html
<figure>
  <audio controls>
    <source src="podcast.mp3" type="audio/mpeg">
  </audio>
  <figcaption>
    <details>
      <summary>查看文字记录</summary>
      <p>主持人:大家好,欢迎收听今天的播客...</p>
      <p>嘉宾:谢谢邀请,很高兴来到这里...</p>
    </details>
  </figcaption>
</figure>

屏幕阅读器隐藏文本

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;
}

/* 可聚焦时显示 */
.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
html
<!-- 使用示例 -->
<button>
  <svg aria-hidden="true">...</svg>
  <span class="sr-only">关闭菜单</span>
</button>

<a href="tel:+8612345678900">
  <span aria-hidden="true">📞</span>
  <span class="sr-only">拨打电话:</span>
  123-4567-8900
</a>

<!-- 表格标题 -->
<table>
  <caption class="sr-only">用户列表,共3列:姓名、邮箱、操作</caption>
  <thead>
    <tr>
      <th>姓名</th>
      <th>邮箱</th>
      <th>操作</th>
    </tr>
  </thead>
  <!-- ... -->
</table>

测试工具

自动化测试

bash
# axe-core
npm install axe-core

# pa11y
npm install -g pa11y
pa11y https://example.com

# lighthouse
npx lighthouse https://example.com --view
javascript
// axe-core 使用
import axe from 'axe-core';

axe.run(document, {}, (err, results) => {
  if (err) throw err;
  console.log('违规项:', results.violations);
  console.log('通过项:', results.passes);
});

浏览器扩展

  • axe DevTools - Chrome/Firefox 扩展
  • WAVE - Web 可访问性评估工具
  • Lighthouse - Chrome DevTools 内置

手动测试

  1. 键盘测试

    • 仅使用 Tab、Enter、方向键导航
    • 检查焦点可见性
    • 测试所有交互功能
  2. 屏幕阅读器测试

    • NVDA(Windows,免费)
    • VoiceOver(macOS/iOS,内置)
    • TalkBack(Android,内置)
  3. 对比度检查

    • Chrome DevTools 颜色选择器
    • WebAIM Contrast Checker

常见面试题

Q1: 什么是 ARIA?什么时候使用?

答案: ARIA(Accessible Rich Internet Applications)是一组属性,用于增强 HTML 元素的可访问性语义。

使用原则

  1. 首选原生 HTML - 能用 <button> 就不用 <div role="button">
  2. 不改变语义 - 不要用 ARIA 覆盖原生语义
  3. 可键盘访问 - 添加 ARIA 角色的元素必须可键盘操作
  4. 不隐藏可聚焦元素 - 不要对可交互元素使用 aria-hidden
html
<!-- ❌ 错误使用 -->
<button role="heading">这是标题</button>

<!-- ✅ 正确使用 -->
<div role="alert">操作成功!</div>

Q2: 如何让自定义组件具有无障碍性?

答案

javascript
// 自定义下拉框示例
class AccessibleSelect {
  constructor(element) {
    this.element = element;
    this.button = element.querySelector('.select-button');
    this.listbox = element.querySelector('.select-listbox');
    this.options = element.querySelectorAll('.select-option');

    this.init();
  }

  init() {
    // 添加 ARIA 属性
    this.button.setAttribute('role', 'combobox');
    this.button.setAttribute('aria-haspopup', 'listbox');
    this.button.setAttribute('aria-expanded', 'false');

    this.listbox.setAttribute('role', 'listbox');
    this.listbox.setAttribute('tabindex', '-1');

    this.options.forEach((option, index) => {
      option.setAttribute('role', 'option');
      option.setAttribute('id', `option-${index}`);
    });

    // 键盘事件
    this.button.addEventListener('keydown', this.handleKeydown.bind(this));
    this.listbox.addEventListener('keydown', this.handleKeydown.bind(this));
  }

  handleKeydown(e) {
    switch (e.key) {
      case 'Enter':
      case ' ':
        this.toggle();
        break;
      case 'ArrowDown':
        this.focusNext();
        break;
      case 'ArrowUp':
        this.focusPrevious();
        break;
      case 'Escape':
        this.close();
        break;
    }
  }

  toggle() {
    const isExpanded = this.button.getAttribute('aria-expanded') === 'true';
    this.button.setAttribute('aria-expanded', !isExpanded);
    this.listbox.hidden = isExpanded;

    if (!isExpanded) {
      this.options[0].focus();
    }
  }

  // ... 其他方法
}

Q3: 如何测试网站的无障碍性?

答案

  1. 自动化测试

    • 使用 axe-core、pa11y 等工具
    • 集成到 CI/CD 流程
  2. 手动测试

    • 键盘导航测试
    • 屏幕阅读器测试
    • 颜色对比度检查
  3. 用户测试

    • 邀请残障用户参与测试
    • 收集真实反馈

总结

核心要点

  1. 语义化 HTML 是基础 - 使用正确的元素
  2. ARIA 是补充 - 在原生语义不足时使用
  3. 键盘可访问 - 所有功能都能通过键盘操作
  4. 颜色不是唯一依据 - 使用多种方式传达信息
  5. 测试是关键 - 自动化 + 手动 + 用户测试

快速检查清单

  • [ ] 所有图片都有合适的 alt 文本
  • [ ] 表单元素都有关联的标签
  • [ ] 页面有清晰的标题层级
  • [ ] 所有功能可通过键盘访问
  • [ ] 焦点样式可见
  • [ ] 颜色对比度满足 AA 级
  • [ ] 动态内容有 aria-live 通知
  • [ ] 错误信息清晰且关联到表单字段