无障碍访问A11y

What — 是什么

无障碍访问(Accessibility,简称 A11y)是确保 Web 内容对所有用户可用,包括视觉、听觉、运动和认知障碍人群。

核心概念:

  • ARIArolearia-labelaria-expanded 等属性,为辅助技术提供语义
  • 键盘导航:Tab 顺序、焦点管理、快捷键
  • 语义化 HTML:正确使用 <button><nav><main> 等标签
  • 颜色对比度:文本与背景 WCAG AA 标准 ≥ 4.5:1
  • 屏幕阅读器:NVDA、VoiceOver、JAWS 等辅助技术

WCAG 四原则(POUR):

  • Perceivable(可感知):内容可被所有感官感知
  • Operable(可操作):界面可用多种方式操作
  • Understandable(可理解):内容和操作可被理解
  • Robust(健壮):兼容各种辅助技术

关键特性:

  • 语义化 HTML 是最好的无障碍,ARIA 是补充而非替代
  • 键盘可达是一切交互的基础
  • A11y 不是额外功能,是 Web 的基本要求

Why — 为什么

适用场景:

  • 所有面向公众的网站(法律要求,如 ADA、EN 301 549)
  • 企业级产品(政府、金融、医疗强制要求)
  • 提升所有用户体验(A11y 改善受益所有人)

影响数据:

  • 全球 15% 人口有某种形式的残疾
  • 键盘可达性同时提升开发者的调试效率
  • 高对比度模式同时帮助强光环境下的用户

优缺点:

  • ✅ 优点:
    • 扩大用户覆盖范围
    • SEO 友好(语义化 HTML)
    • 法律合规
    • 提升代码质量
  • ❌ 缺点:
    • 需要额外学习和开发时间
    • 动态组件(Modal、拖拽)适配复杂

How — 怎么用

语义化 HTML

<!-- ❌ 无语义 -->
<div onclick="submit()">提交</div>
<div class="nav">...</div>
<div class="main">...</div>

<!-- ✅ 语义化 -->
<button type="submit">提交</button>
<nav aria-label="主导航">...</nav>
<main>...</main>

常用语义标签:

标签用途辅助技术行为
<button>可交互按钮可聚焦、可回车/空格触发
<a href>链接可聚焦、可回车跳转
<nav>导航区域自动识别为导航
<main>主内容跳转到主内容
<aside>侧边栏识别为补充内容
<form>表单识别为表单区域
<table>数据表识别行列关系
<dialog>对话框自动管理焦点陷阱

ARIA 属性

<!-- aria-label:无可见文本时提供标签 -->
<button aria-label="关闭" onclick="close()">
    <svg>✕</svg>
</button>

<!-- aria-labelledby:用其他元素的文本作标签 -->
<div id="dialog-title">确认删除</div>
<div role="dialog" aria-labelledby="dialog-title">
    <p>此操作不可撤销</p>
</div>

<!-- aria-expanded:折叠/展开状态 -->
<button aria-expanded="false" aria-controls="panel1">
    更多选项
</button>
<div id="panel1" hidden>展开的内容</div>

<!-- aria-live:动态内容通知 -->
<div aria-live="polite">已添加到购物车</div>
<div aria-live="assertive">表单验证失败</div>

<!-- role:自定义语义 -->
<div role="tablist">
    <button role="tab" aria-selected="true">详情</button>
    <button role="tab" aria-selected="false">评价</button>
</div>
<div role="tabpanel">详情内容</div>

键盘导航

<!-- 焦点顺序:tabindex -->
<!-- 0:可聚焦,按文档顺序 -->
<!-- -1:编程可聚焦,不在 Tab 序列中 -->
<!-- 正数:不推荐,打乱自然顺序 -->
<div tabindex="0" role="button" onclick="handleClick()"
     onkeydown="if(event.key==='Enter')handleClick()">
    自定义按钮
</div>

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

<style>
.skip-link {
    position: absolute;
    top: -100%;
}
.skip-link:focus {
    top: 0;
}
</style>

焦点管理:

// Modal 打开时聚焦到内部
function openModal(dialog: HTMLDialogElement) {
    dialog.showModal();
    const firstFocusable = dialog.querySelector(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    (firstFocusable as HTMLElement)?.focus();
}

// Modal 关闭时焦点回到触发元素
function closeModal(dialog: HTMLDialogElement, trigger: HTMLElement) {
    dialog.close();
    trigger.focus(); // 焦点回到触发按钮
}

表单无障碍

<form>
    <!-- label 关联 -->
    <label for="email">邮箱</label>
    <input id="email" type="email" required
           aria-describedby="email-hint"
           aria-invalid="false">
    <span id="email-hint">请输入公司邮箱</span>

    <!-- 错误状态 -->
    <label for="password">密码</label>
    <input id="password" type="password"
           aria-describedby="password-error"
           aria-invalid="true">
    <span id="password-error" role="alert">密码至少8位</span>

    <!-- 必填标识(不用 *,用 aria-required) -->
    <label for="name">姓名</label>
    <input id="name" aria-required="true">

    <!-- 分组 -->
    <fieldset>
        <legend>联系方式</legend>
        <label for="phone">电话</label>
        <input id="phone" type="tel">
    </fieldset>
</form>

React 无障碍

// 原生 <dialog> 实现 Modal
function ConfirmDialog({ open, onClose, onConfirm, title, children }) {
    const ref = useRef<HTMLDialogElement>(null);
    const triggerRef = useRef<HTMLElement | null>(null);

    useEffect(() => {
        if (open) {
            triggerRef.current = document.activeElement as HTMLElement;
            ref.current?.showModal();
        } else {
            ref.current?.close();
            triggerRef.current?.focus();
        }
    }, [open]);

    return (
        <dialog ref={ref} onClose={onClose} aria-labelledby="dialog-title">
            <h2 id="dialog-title">{title}</h2>
            <p>{children}</p>
            <button onClick={onConfirm}>确认</button>
            <button onClick={onClose}>取消</button>
        </dialog>
    );
}

// eslint-plugin-jsx-a11y 检查常见问题
// <img> 必须有 alt
// <button> 必须有可见文本或 aria-label
// onclick 元素需要 onkeydown 或 role

Vue 无障碍

<template>
    <!-- 自定义组件暴露 ARIA 属性 -->
    <div
        class="accordion"
        role="region"
        :aria-labelledby="`header-${id}`"
    >
        <button
            :id="`header-${id}`"
            :aria-expanded="isOpen"
            :aria-controls="`panel-${id}`"
            @click="isOpen = !isOpen"
        >
            {{ title }}
        </button>
        <div
            :id="`panel-${id}`"
            role="region"
            :hidden="!isOpen"
        >
            <slot />
        </div>
    </div>
</template>

自动化检测

# Lighthouse CLI
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json

# axe-core(可集成到测试)
npm install @axe-core/react
// React 开发环境接入 axe-core
import axe from '@axe-core/react';

if (import.meta.env.DEV) {
    axe(React, ReactDOM, 1000);
}

常见问题与踩坑

问题原因解决方案
div 当按钮用屏幕阅读器不识别<button> 或加 role="button" + 键盘事件
图片无 alt辅助技术无法理解图片装饰图 alt="",内容图描述性 alt
Modal 焦点逃逸Tab 可跳出对话框实现焦点陷阱(<dialog> 自动处理)
颜色对比度不够品牌色太浅用对比度检测工具,WCAG AA ≥ 4.5:1
动态内容无通知aria-live 未设置aria-live="polite" 通知状态变化

最佳实践

  • 优先语义化 HTML,ARIA 是补充不是替代
  • 所有交互元素可键盘操作(Tab/Enter/Space/Escape)
  • 图片必须有 alt(装饰图 alt=""
  • 表单 label 关联 + 错误提示 aria-describedby
  • 接入 Lighthouse + axe-core 自动化检测
  • 用屏幕阅读器实际测试(macOS VoiceOver / Windows NVDA)

面试题

Q1: ARIA的使用原则是什么?

核心原则:“No ARIA is better than bad ARIA”。优先使用原生语义化HTML标签(button/nav/main等),ARIA仅作补充。不要给语义标签添加多余的role(如<button role="button">是冗余的),不要用ARIA”修复”可访问性而不改变视觉行为。

Q2: 如何确保组件的键盘可达性?

所有交互元素必须可通过Tab聚焦、Enter/Space激活、Escape关闭。原生button/a自带键盘支持;自定义元素需添加tabindex=“0”、role和键盘事件监听。Modal需实现焦点陷阱(Tab不跳出对话框),关闭后焦点回到触发元素。避免使用正数tabindex打乱自然顺序。

Q3: img标签的alt属性应该怎么写?

内容图片:alt描述图片内容(如alt="团队合影");装饰图片:alt=""(空字符串,屏幕阅读器跳过);功能图片:alt描述功能(如alt="搜索")。永远不要省略alt属性,省略时屏幕阅读器会朗读文件名。不要用alt="图片"等无意义文本。

Q4: aria-live的polite和assertive有什么区别?

polite表示变化不紧急,屏幕阅读器会在当前朗读完成后播报更新(适合购物车数量、通知消息);assertive表示变化紧急,屏幕阅读器会立即中断当前朗读播报更新(适合表单验证错误、系统警告)。优先使用polite,避免过度使用assertive造成干扰。


相关链接: