Web 可访问性(a11y):合规标准、技术实现与用户体验

系统性 Web 可访问性指南:WCAG 2.1/2.2 标准解读(感知性/可操作性/可理解性/健壮性四大原则)、语义化 HTML 与 ARIA 正确使用、键盘导航与焦点管理、屏幕阅读器兼容(NVDA/JAWS/VoiceOver)、色彩对比度与色觉障碍、减少动态内容(prefers-reduced-motion)、表单可访问性、可访问性测试工具(axe-Core/Lighthouse/pa11y)、法律合规(ADA/EN 301 549/中国无障碍环境建设法)。附 Vue/React 实战代码。

可访问性不是「为少数人做额外工作」,而是「让产品能被尽可能多的人使用」。 全球约有 15% 的人口有某种形式的残障,而且他们不是你的「边缘用户」——他们是你的父母、你的朋友、你的客户。Web 可访问性既是技术要求,也是道德责任,在很多国家更是法律义务。


一、WCAG 标准解读

1.1 WCAG 2.1 四大原则(POUR)

原则英文核心要求示例
感知性Perceivable信息和组件必须可被用户感知图片有 alt 文本、视频有字幕、色彩不是唯一信息通道
可操作性Operable界面组件必须可操作全部功能可键盘操作、不限制时间、无闪烁
可理解性Understandable信息和操作必须可理解页面有标题、错误有说明、语言可识别
健壮性Robust内容必须兼容辅助技术有效的 HTML、正确的 ARIA、符合标准

1.2 合规等级

等级要求适用场景
A最低要求,无障碍的门槛必须满足
AA常见要求,大多数组织目标网站/应用的推荐标准
AAA最高要求,更难实现政府、教育、金融(部分)

2025 年欧洲《无障碍法案》(EAA)生效,要求数字产品必须符合 EN 301 549(等同于 WCAG 2.1 AA)。中国《无障碍环境建设法》2023 年生效,对公共服务网站有明确要求。


二、语义化 HTML:可访问性的基石

2.1 不要只用 div

<!-- ❌ 错误:纯 div,屏幕阅读器完全无法理解 -->
<div class="header">
  <div class="nav">
    <div class="item" onclick="goHome()">首页</div>
    <div class="item" onclick="goAbout()">关于</div>
  </div>
</div>

<!-- ✅ 正确:语义化标签自带可访问性 -->
<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>
</main>

<aside aria-label="相关推荐">
  <h2>推荐阅读</h2>
  <!-- ... -->
</aside>

<footer>
  <p>&copy; 2026 My Company</p>
</footer>

2.2 标题层级

<!-- ✅ 正确的标题层级 —— 像书的目录 -->
<h1>网站标题</h1>
  <h2>章节一</h2>
    <h3>小节 A</h3>
    <h3>小节 B</h3>
  <h2>章节二</h2>
    <h3>小节 C</h3>
      <h4>细节 1</h4>

<!-- ❌ 错误:跳过层级、用样式代替语义 -->
<p class="h1-style">看起来像标题但不是</p>
<h1>一级标题</h1>
<h3>直接跳到三级</h3>

三、ARIA:语义化 HTML 的补充

3.1 ARIA 使用原则

「第一原则:如果能用原生 HTML 元素实现,就不要用 ARIA。」

<!-- ❌ 过度使用 ARIA -->
<div role="button" tabindex="0" aria-pressed="false">提交</div>

<!-- ✅ 原生元素自带全部 ARIA 属性 -->
<button type="submit">提交</button>

<!-- ✅ 只有在原生元素无法表达时才用 ARIA -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
  <h2 id="dialog-title">确认删除</h2>
  <p>确定要删除这条记录吗?此操作不可撤销。</p>
  <button>取消</button>
  <button>删除</button>
</div>

3.2 常用 ARIA 属性

属性用途示例
aria-label为元素提供文本标签<nav aria-label="主导航">
aria-labelledby引用另一个元素作为标签<div role="dialog" aria-labelledby="title">
aria-describedby补充描述<input aria-describedby="hint">
aria-expanded控件是否展开<button aria-expanded="false">菜单</button>
aria-hidden对辅助技术隐藏<svg aria-hidden="true">
aria-live动态内容通知<div aria-live="polite">加载中...</div>
role定义元素角色role="alert"role="tablist"

3.3 动态内容:aria-live

<!-- 状态变化自动播报 -->
<div id="status" aria-live="polite" aria-atomic="true"></div>

<script>
// 当表单提交后更新状态
function showStatus(message) {
  document.getElementById('status').textContent = message;
  // 屏幕阅读器会自动播报:"保存成功"
}
showStatus('保存成功!');
</script>

<!-- aria-live 取值:
     off      — 不播报(默认)
     polite   — 礼貌:当前说完后播报
     assertive — 打断:立即播报(紧急错误用)
-->

四、键盘导航与焦点管理

4.1 可交互元素必须可聚焦

<!-- ✅ 原生可交互元素自动可聚焦 -->
<a href="/">链接</a>
<button>按钮</button>
<input type="text">
<select>
<textarea>

<!-- ❌ 虚假按钮 —— 无法键盘聚焦和触发 -->
<div class="btn" onclick="submit()">提交</div>

<!-- ✅ 无 href 的链接也需要 focus -->
<a href="javascript:void(0)" role="button" tabindex="0"
   @keydown.enter="submit">更多</a>

<!-- ✅ 或用真正的按钮 -->
<button type="button" @click="submit">更多</button>

4.2 焦点顺序与可见性

/* 确保焦点可见 */
:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

/* 不要隐藏焦点! */
/* ❌ 错误 */
*:focus { outline: none; }

/* 不要用 tabindex > 0 强行改变顺序 —— 让 DOM 顺序反映视觉顺序 */

4.3 焦点陷阱(Focus Trap)

// 模态框必须困住焦点
function trapFocus(element: HTMLElement) {
  const focusable = element.querySelectorAll(
    'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0] as HTMLElement;
  const last = focusable[focusable.length - 1] as HTMLElement;

  element.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(); // 打开时聚焦第一个元素
}

// 关闭时恢复焦点
let previousFocus: HTMLElement | null = null;

function openModal() {
  previousFocus = document.activeElement as HTMLElement;
  modal.showModal();
  trapFocus(modal);
}

function closeModal() {
  modal.close();
  previousFocus?.focus(); // 回到触发元素
}

五、色彩与对比度

5.1 WCAG 对比度要求

等级正文(< 18pt)大文本(≥ 18pt / 14pt bold)
AA4.5:13:1
AAA7:14.5:1
/* ✅ 对比度足够 */
.text-primary {
  color: #1e3a5f; /* 深蓝色 */
  background: #ffffff; /* 对比度 12.5:1 ✅ */
}

/* ❌ 对比度不足 */
.text-muted {
  color: #cccccc; /* 浅灰 */
  background: #ffffff; /* 对比度 1.6:1 ❌ */
}

5.2 不要只用颜色传递信息

<!-- ❌ 色觉障碍用户无法区分红绿 -->
<span class="status-red">错误</span>
<span class="status-green">成功</span>

<!-- ✅ 颜色 + 图标 + 文字 -->
<span class="text-red-600">
  <svg aria-hidden="true"><!-- 错误图标 --></svg>
  错误:请检查输入
</span>
<span class="text-green-600">
  <svg aria-hidden="true"><!-- 成功图标 --></svg>
  成功:保存完成
</span>

<!-- ✅ 表单错误同时用多种方式 -->
<input aria-invalid="true" aria-describedby="error-name">
<span id="error-name" class="text-red-600">姓名不能为空</span>

5.3 prefers-reduced-motion

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

/* 或在 Vue/React 中动态判断 */

六、表单可访问性

6.1 标签关联

<!-- ✅ 显式关联 -->
<label for="email">邮箱地址</label>
<input type="email" id="email" name="email" required
       aria-required="true"
       aria-describedby="email-hint email-error">
<p id="email-hint">我们将向您发送验证邮件</p>

<!-- ✅ 隐式关联 -->
<label>
  用户名
  <input type="text" name="username">
</label>

<!-- ❌ 无关联:屏幕阅读器无法知道 input 的用途 -->
<span>邮箱</span>
<input type="email">

6.2 错误提示

<form>
  <label for="password">密码</label>
  <input
    type="password"
    id="password"
    aria-invalid="true"
    aria-describedby="password-error"
  >
  <span id="password-error" role="alert" class="error">
    密码至少需要 8 位字符
  </span>
</form>

七、Vue / React 可访问性

7.1 Vue 3 中的 a11y

<script setup lang="ts">
import { ref, watch } from 'vue';

const isOpen = ref(false);
const modalRef = ref<HTMLElement>();

// 打开时聚焦模态框,关闭时恢复
watch(isOpen, (open) => {
  if (open) {
    setTimeout(() => modalRef.value?.focus(), 0);
  }
});
</script>

<template>
  <button @click="isOpen = true" aria-haspopup="dialog">
    打开设置
  </button>

  <div
    v-if="isOpen"
    ref="modalRef"
    role="dialog"
    aria-modal="true"
    aria-labelledby="modal-title"
    tabindex="-1"
    @keydown.esc="isOpen = false"
  >
    <h2 id="modal-title">设置</h2>
    <!-- 内容 -->
    <button @click="isOpen = false">关闭</button>
  </div>
</template>

7.2 React 中的 a11y

import { useRef, useEffect } from 'react';

function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      previousFocus.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();

      // 焦点陷阱
      const handleTab = (e: KeyboardEvent) => {
        if (e.key !== 'Tab') return;
        const focusable = modalRef.current?.querySelectorAll(
          'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
        );
        if (!focusable?.length) return;
        const first = focusable[0] as HTMLElement;
        const last = focusable[focusable.length - 1] as HTMLElement;

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

      document.addEventListener('keydown', handleTab);
      return () => {
        document.removeEventListener('keydown', handleTab);
        previousFocus.current?.focus();
      };
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      tabIndex={-1}
      onKeyDown={(e) => e.key === 'Escape' && onClose()}
    >
      <h2 id="modal-title">{title}</h2>
      {children}
    </div>
  );
}

八、可访问性测试工具

8.1 自动化测试

# axe-core(最权威的 a11y 测试引擎)
npm install -D @axe-core/cli

# CLI 扫描
npx axe https://example.com --tags wcag2aa

# 集成 Playwright
npm install -D @axe-core/playwright
// e2e/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage should not have accessibility violations', async ({ page }) => {
  await page.goto('/');

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

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

8.2 浏览器插件

工具功能安装
axe DevTools实时扫描、智能修复建议Chrome 扩展商店
WAVE可视化标注问题wave.webaim.org
Lighthouse内置 a11y 审计Chrome DevTools

8.3 手动测试

键盘测试清单:
□ Tab 键可以遍历所有可交互元素
□ Shift+Tab 反向遍历
□ Enter/Space 可以激活按钮和链接
□ Esc 可以关闭模态框/下拉菜单
□ 焦点顺序符合视觉顺序
□ 焦点始终可见

屏幕阅读器测试:
□ 页面有标题(<title>)
□ 图片有替代文本
□ 表单有标签关联
□ 错误信息被播报
□ 数据表格有表头

九、法律合规

法规地区要求
ADA Title III美国公共场所网站必须无障碍
Section 508美国(政府)联邦电子和信息技术可访问性
EAA (EU)欧洲2025 年 6 月生效,WCAG 2.1 AA
EN 301 549欧洲公共采购的 ICT 产品标准
中国无障碍环境建设法中国2023 年 9 月生效
AODA加拿大(安大略)公共服务网站 WCAG 2.0 AA

十、可访问性 Checklist

检查项等级验证方式
页面有</td> <td>A</td> <td>自动</td> </tr> <tr> <td>图片有 alt 文本</td> <td>A</td> <td>自动 + 人工</td> </tr> <tr> <td>表单有 label 关联</td> <td>A</td> <td>自动</td> </tr> <tr> <td>全部功能可键盘操作</td> <td>A</td> <td>手动</td> </tr> <tr> <td>焦点可见</td> <td>A</td> <td>手动</td> </tr> <tr> <td>颜色对比度 ≥ 4.5:1</td> <td>AA</td> <td>自动(axe)</td> </tr> <tr> <td>文本可缩放至 200%</td> <td>AA</td> <td>手动</td> </tr> <tr> <td>无自动播放音频</td> <td>A</td> <td>自动</td> </tr> <tr> <td>错误有文本说明</td> <td>A</td> <td>手动</td> </tr> <tr> <td>支持 prefers-reduced-motion</td> <td>AAA</td> <td>手动</td> </tr> </tbody> </table> <hr> <h2 id="参考与延伸阅读">参考与延伸阅读</h2> <ul> <li><a href="https://www.w3.org/TR/WCAG21/">WCAG 2.1 中文版</a></li> <li><a href="https://www.w3.org/TR/WCAG22/">WCAG 2.2</a></li> <li><a href="https://www.w3.org/WAI/ARIA/apg/">ARIA Authoring Practices</a></li> <li><a href="https://www.deque.com/axe/">axe-core 文档</a></li> <li><a href="https://webaim.org/resources/contrastchecker/">WebAIM 对比度检查器</a></li> <li><a href="https://www.a11yproject.com/">The A11Y Project</a></li> </ul> <footer class="plume-article__tags" aria-label="文章标签"> <a href="/tags/a11y/">a11y</a> <a href="/tags/accessibility/">accessibility</a> <a href="/tags/wcag/">wcag</a> <a href="/tags/aria/">aria</a> <a href="/tags/inclusivity/">inclusivity</a> <a href="/tags/ux/">ux</a> <a href="/tags/vue/">vue</a> <a href="/tags/react/">react</a> <a href="/tags/testing/">testing</a> </footer><nav class="plume-article__adjacent" aria-label="相邻文章导航"> <div class="plume-article__adjacent-inner"><a class="plume-article__adjacent-prev" href="/pwa-offline-service-worker/" rel="prev"> <span class="plume-article__adjacent-label">← 上一篇</span> <strong>PWA 与 Service Worker:离线化、缓存策略与服务扩展</strong> </a><a class="plume-article__adjacent-next" href="/frontend-security-advanced/" rel="next"> <span class="plume-article__adjacent-label">下一篇 →</span> <strong>前端安全进阶:XSS、CSP、SRI 与供应链安全</strong> </a></div> </nav> <section class="plume-article-cta" aria-labelledby="plume-article-cta-title"> <div> <p class="plume-article-cta__eyebrow">继续阅读</p> <h2 id="plume-article-cta-title">探索更多技术文章</h2> <p>浏览归档,发现更多关于系统设计、工具链和工程实践的内容。</p> </div> <div class="plume-article-cta__actions"> <a class="plume-button plume-button--primary" href="/posts/">全部文章</a> <a class="plume-button plume-button--secondary" href="/">返回首页</a> </div> </section> <section class="plume-same-category" aria-labelledby="plume-same-category-title"> <h2 id="plume-same-category-title">「frontend」更多文章</h2> <ol class="plume-same-category__list"> <li> <a href="/api-design-bff-layer/"> <span class="plume-same-category__number" aria-hidden="true">01</span> <strong>API 设计与 BFF 层:REST、GraphQL、tRPC 选型与前后端协作</strong> <time datetime="2026-08-13T11:20:00+08:00">2026-08-13</time> <span class="plume-same-category__arrow" aria-hidden="true">→</span> </a> </li> <li> <a href="/webassembly-frontend/"> <span class="plume-same-category__number" aria-hidden="true">02</span> <strong>WebAssembly 前端工程化实践:编译链、性能对比与混合架构</strong> <time datetime="2026-08-13T11:10:00+08:00">2026-08-13</time> <span class="plume-same-category__arrow" aria-hidden="true">→</span> </a> </li> <li> <a href="/modern-browser-apis/"> <span class="plume-same-category__number" aria-hidden="true">03</span> <strong>现代浏览器 API 与 Web 平台能力地图</strong> <time datetime="2026-08-13T11:00:00+08:00">2026-08-13</time> <span class="plume-same-category__arrow" aria-hidden="true">→</span> </a> </li> </ol> </section> <div class="plume-comments"> </div> </div> <aside class="plume-article__aside" aria-label="文章导航"> <section class="plume-article__toc"> <h2>目录</h2> <nav id="TableOfContents"> <ul> <li><a href="#一wcag-标准解读">一、WCAG 标准解读</a> <ul> <li><a href="#11-wcag-21-四大原则pour">1.1 WCAG 2.1 四大原则(POUR)</a></li> <li><a href="#12-合规等级">1.2 合规等级</a></li> </ul> </li> <li><a href="#二语义化-html可访问性的基石">二、语义化 HTML:可访问性的基石</a> <ul> <li><a href="#21-不要只用-div">2.1 不要只用 div</a></li> <li><a href="#22-标题层级">2.2 标题层级</a></li> </ul> </li> <li><a href="#三aria语义化-html-的补充">三、ARIA:语义化 HTML 的补充</a> <ul> <li><a href="#31-aria-使用原则">3.1 ARIA 使用原则</a></li> <li><a href="#32-常用-aria-属性">3.2 常用 ARIA 属性</a></li> <li><a href="#33-动态内容aria-live">3.3 动态内容:aria-live</a></li> </ul> </li> <li><a href="#四键盘导航与焦点管理">四、键盘导航与焦点管理</a> <ul> <li><a href="#41-可交互元素必须可聚焦">4.1 可交互元素必须可聚焦</a></li> <li><a href="#42-焦点顺序与可见性">4.2 焦点顺序与可见性</a></li> <li><a href="#43-焦点陷阱focus-trap">4.3 焦点陷阱(Focus Trap)</a></li> </ul> </li> <li><a href="#五色彩与对比度">五、色彩与对比度</a> <ul> <li><a href="#51-wcag-对比度要求">5.1 WCAG 对比度要求</a></li> <li><a href="#52-不要只用颜色传递信息">5.2 不要只用颜色传递信息</a></li> <li><a href="#53-prefers-reduced-motion">5.3 prefers-reduced-motion</a></li> </ul> </li> <li><a href="#六表单可访问性">六、表单可访问性</a> <ul> <li><a href="#61-标签关联">6.1 标签关联</a></li> <li><a href="#62-错误提示">6.2 错误提示</a></li> </ul> </li> <li><a href="#七vue--react-可访问性">七、Vue / React 可访问性</a> <ul> <li><a href="#71-vue-3-中的-a11y">7.1 Vue 3 中的 a11y</a></li> <li><a href="#72-react-中的-a11y">7.2 React 中的 a11y</a></li> </ul> </li> <li><a href="#八可访问性测试工具">八、可访问性测试工具</a> <ul> <li><a href="#81-自动化测试">8.1 自动化测试</a></li> <li><a href="#82-浏览器插件">8.2 浏览器插件</a></li> <li><a href="#83-手动测试">8.3 手动测试</a></li> </ul> </li> <li><a href="#九法律合规">九、法律合规</a></li> <li><a href="#十可访问性-checklist">十、可访问性 Checklist</a></li> <li><a href="#参考与延伸阅读">参考与延伸阅读</a></li> </ul> </nav> </section><section class="plume-context-panel"> <h2>相关文章</h2> <ul class="plume-context-list"><li> <a href="/frontend-security-advanced/">前端安全进阶:XSS、CSP、SRI 与供应链安全</a> <span>frontend · 4 分钟阅读 </span> </li><li> <a href="/pwa-offline-service-worker/">PWA 与 Service Worker:离线化、缓存策略与服务扩展</a> <span>frontend · 6 分钟阅读 </span> </li><li> <a href="/frontend-state-management-guide/">前端状态管理全景对比与选型指南</a> <span>frontend · 6 分钟阅读 </span> </li><li> <a href="/web-performance-core-web-vitals/">Web 性能指标全解析:Core Web Vitals 与真实用户监控(RUM)</a> <span>frontend · 5 分钟阅读 </span> </li><li> <a href="/typescript-engineering-advanced/">TypeScript 工程化实战:从类型体操到架构安全</a> <span>frontend · 5 分钟阅读 </span> </li> </ul> </section></aside> </div> </article> </main> <footer class="plume-site-footer" role="contentinfo"> <div class="plume-site-footer__inner"> <a class="plume-site-footer__copyright" href="https://plumephp.com/"> © PlumePHP 2026 </a> <nav class="plume-site-footer__links" aria-label="页脚导航"> <span><a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">京ICP备20029263号-1</a></span> <a href="/terms-of-service/">服务条款</a> <a href="/privacy-policy/">隐私政策</a> </nav> <div class="plume-site-footer__social"> <a class="plume-social-link" href="https://github.com/spcent" rel="noopener noreferrer" target="_blank" aria-label="GitHub"> <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"> <path d="M12 .5a12 12 0 0 0-3.79 23.39c.6.11.82-.26.82-.58v-2.02c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.74.08-.74 1.2.09 1.84 1.24 1.84 1.24 1.08 1.84 2.82 1.31 3.51 1 .11-.78.42-1.31.76-1.61-2.66-.3-5.46-1.33-5.46-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.45 11.45 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.49 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.69.83.57A12 12 0 0 0 12 .5Z" fill="currentColor"/> </svg> <span>GitHub</span> </a> </div> </div> </footer> </body> </html>