Files
cs-note/hhs/REACT/5. 工程实践篇/17-可访问性.md
T
2026-05-24 11:42:38 +08:00

17 KiB
Raw Blame History

tags, create time
tags create time
React
A11y
Accessibility
Frontend
2026-04-29 22:16

可访问性

概述

可访问性(Accessibility,简称 a11y)确保残障用户也能正常使用应用。这不仅是道德责任,在许多国家和地区也是法律要求。本文档梳理 React 中实现无障碍的关键实践。

WCAG 核心原则

graph TB
    A["WCAG 2.1 四大原则"] --> B["Perceivable<br/>可感知"]
    A --> C["Operable<br/>可操作"]
    A --> D["Understandable<br/>可理解"]
    A --> E["Robust<br/>鲁棒性"]
    
    B --> B1["文本替代"]
    B --> B2["颜色对比度 ≥ 4.5:1"]
    
    C --> C1["键盘可达"]
    C --> C2["足够的时间"]
    
    D --> D1["可读的文本"]
    D --> D2["一致导航"]
    
    E --> E1["兼容辅助技术"]
    
    style A fill:#F5A87D,color:#000
    style B fill:#4FC08D,color:#fff
    style C fill:#61DAFB,color:#000
    style D fill:#A0AEC0,color:#000
    style E fill:#ED8936,color:#000

语义化 HTML(最重要的一条)

[!tip] 黄金法则 能用原生元素,绝不用 div + onClick。 原生 HTML 元素天生具备键盘交互、屏幕阅读器支持和 SEO 友好能力。这是可访问性的基石,比任何 ARIA 属性都重要。

思考:为什么一个 <div> 加上 onClick 事件后,仍然不能代替 <button>?

答案涉及三个层面:

  1. 键盘 — div 不在 Tab 焦点流中,Enter/Space 不会触发点击
  2. 屏幕阅读器 — 读作"按钮,未绑定", 用户不知道它是干什么的
  3. 搜索引擎 — 无法识别为可交互元素
// ❌ 滥用 div + onClick——视觉上是按钮,实质上什么都不是
<div className="btn" onClick={() => navigate("/home")}>Home</div>
<div className="link" onClick={() => goTo("/about")}>About</div>

// ✅ 使用原生元素——天生支持键盘、屏幕阅读器、SEO
<button type="button">按钮</button>
<a href="/home">首页</a>
<a href="/about">关于</a>

常用语义标签对照表

功能 错误写法 正确写法 原因
按钮行为 <div onClick> <button> 原生键盘交互
链接跳转 <span onClick=navigate> <a href> 右键打开新标签、Ctrl+点击
表单输入 <div contentEditable> <input>/<textarea> 输入法兼容、自动填充
弹窗 <div className="modal"> <dialog> 或 role="dialog" 焦点管理、Escape 关闭
导航区 <div class="nav"> <nav> 跳过导航快捷入口
侧边栏 <aside class="sidebar"> <aside> 页面结构角色识别
主内容 <main className="content"> <main> 直达主内容快捷键

ARIA 属性体系

[!warning] ARIA 铁律(ARIA Gods)

  1. 不要使用 ARIA —— 用原生语义化元素解决一切
  2. 不要将原生交互改为 role="presentation" —— 会破坏已有无障碍支持
  3. 所有 ARIA 可用状态和属性必须有对应原生支持 —— 不要用 ARIA 模拟原生行为

ARIA 是最后一道防线,用于当原生 HTML 无法满足需求时。核心原则:优先使用原生语义,ARIA 作为补充。

aria-label:为无声元素赋予名称

给没有可见文本的图标、装饰性按钮添加屏幕阅读器可读的描述。

// 关闭按钮——只有图标,需要 label 告诉用户功能
<button aria-label="关闭对话框">
  <CloseIcon />
</button>

// 搜索图标按钮
<button aria-label="搜索商品">
  <SearchIcon />
</button>

注意:aria-label 会完全替代元素的可见文本(如果有),屏幕阅读器只朗读 label 内容。如果已有清晰可见文本,优先用 aria-labelledby 关联。

aria-describedby:提供补充说明

当字段需要额外解释文字时使用,屏幕阅读器会在朗读输入框名称后继续朗读描述。

<input
  aria-label="邮箱地址"
  aria-describedby="email-hint"
/>
<p id="email-hint">请使用注册时使用的邮箱</p>

{/* 效果:屏幕阅读器会读 "邮箱地址,请使用注册时使用的邮箱" */}

aria-live:动态内容变化通知

让屏幕阅读器自动感知并朗读动态变化的内容区域。适用于 Toast 消息、表单验证错误、实时搜索结果等场景。

<div aria-live="polite">
  {formErrors.length > 0 && (
    <ul>
      {formErrors.map((err, i) => <li key={i}>{err}</li>)}
    </ul>
  )}
</div>

[!note] polite vs assertive

  • polite(默认)— 等待用户当前操作空闲后再朗读,不打断用户
  • assertive — 立即中断当前朗读,优先播报紧急信息

非紧急提示(如"保存成功")用 polite;安全警告、支付确认用 assertive。

aria-expanded:折叠面板展开状态

对可展开/收起的元素(手风琴、下拉菜单、折叠面板),标记当前展开状态。用户打开或关闭时,屏幕阅读器会自动读出 "已展开" 或 "已折叠"。

<button
  aria-expanded={isExpanded}
  onClick={() => setIsExpanded(!isExpanded)}
  aria-controls="panel-content"
>
  {isExpanded ? "收起" : "展开"}
  <ChevronIcon rotated={isExpanded ? 180 : 0} />
</button>
<div id="panel-content" hidden={!isExpanded}>
  {/* 面板内容 */}
</div>

技巧:配合 aria-controls 指向被控制的内容区域 ID,帮助屏幕阅读器建立两者之间的关系。

aria-hidden:隐藏装饰性元素

将纯装饰性的 SVG 图标或对视觉重要但屏幕阅读器不应朗读的图片隐藏。

/* 装饰性背景图——不想让屏幕阅读器朗读文件名 */
<img src="decorative-pattern.png" alt="" aria-hidden="true" />

/* SVG 图标中,textContent 已经通过 aria-label 表达了 */
<svg aria-hidden="true" focusable="false">
  <path d="M..." />
</svg>

[!question] 思考:什么时候应该用 aria-hidden="true",什么时候应该用 alt=""?

  • aria-hidden:告诉辅助技术"忽略这个元素"(用于重复信息,如文本旁已标注功能的图标)
  • alt=""(空 alt):告诉屏幕阅读器"这是一张装饰图片"(用于图片本身是装饰性的)
  • 两者有时可以配合使用

键盘导航

[!warning] 核心原则 默认 Tab 顺序 = DOM 顺序。 绝大多数情况下,不要通过 JavaScript 改变自然的页面阅读顺序。手动设置 tabIndex 是例外而非常规。

Tab Order 管理

// ✅ 在 Tab 流中(正常用户应该能用 Tab 到达的元素)
<NavButton tabIndex={0} />

// ✅ 仅程序化聚焦(浮动按钮、Toast 等不需要 Tab 的元素)
<FloatingActionButton tabIndex={-1} />

// ❌ 永远不要全局禁用键盘——这剥夺了所有用户的操作能力
<div tabIndex={-1} onKeyDown={handler}>...</div>

自定义快捷键 Hook

function useKeyboardShortcut(key: string, handler: () => void) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // 避免在输入框、文本域等中触发快捷键
      if (e.key === key && !isInputFocused()) {
        handler();
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [key, handler]);
}

解释:这段代码的关键在于 !isInputFocused() 检查。如果不加这个判断,用户在输入密码或搜索词时按下快捷键,会导致意外行为。例如用户在搜索框中输入 "s",如果同时触发了某个搜索提交快捷键,会打断用户的正常输入。

Focus Trap(模态框焦点陷阱)

打开模态对话框时,必须确保 Tab 键的焦点不会跑到背景内容上。焦点应该在弹窗内的可交互元素之间循环。

[!note] 为什么需要 Focus Trap? 想象你打开了一个确认删除弹窗,此时按 Tab 焦点跑到了背景的菜单链接上——用户完全不知道自己在操作什么。Focus Trap 将焦点锁在弹窗内部形成闭环:

flowchart LR
    A["Tab 在弹窗内"] --> B["到达最后元素"]
    B -->|Shift+Tab| C["回到第一个元素"]
    B -->|Tab| D["回到第一个元素\n(焦点循环)"]
    C -->|"Tab"| A
    style A fill:#4FC08D,color:#000
    style D fill:#F5A87D,color:#000
    style C fill:#61DAFB,color:#000

实现要点:

import { useRef, useEffect } from "react";

function Modal({ isOpen, onClose, children }: Props) {
  const modalRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    if (!isOpen) return;

    // 收集弹窗内所有可聚焦元素
    const focusableElements = modalRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstEl = focusableElements?.[0] as HTMLElement;
    const lastEl = focusableElements?.[focusableElements.length - 1] as HTMLElement;

    // Tab 键循环逻辑
    const handleTab = (e: KeyboardEvent) => {
      if (e.key !== "Tab") return;

      if (e.shiftKey) {
        // Shift+Tab:从第一个元素回到最后一个
        if (document.activeElement === firstEl) {
          e.preventDefault();
          lastEl.focus();
        }
      } else {
        // Tab:从最后一个元素回到第一个
        if (document.activeElement === lastEl) {
          e.preventDefault();
          firstEl.focus();
        }
      }
    };

    document.addEventListener("keydown", handleTab);
    firstEl?.focus();

    return () => document.removeEventListener("keydown", handleTab);
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <dialog ref={modalRef} open onClose={onClose}>
      {children}
    </dialog>
  );
}

解释:这个 Hook 做了三件事:

  1. 自动聚焦首个元素 —— 打开弹窗后立即将焦点放到第一个可交互元素,让用户无需按 Tab 即可操作
  2. Tab 循环 —— Shift+Tab 从第一个回到最后一个,Tab 从最后一个回到第一个
  3. 清理监听器 —— 关闭弹窗时移除事件监听,防止内存泄漏

实际项目中推荐直接使用 @radix-ui/react-dialog 或 MUI/Chakra 等组件库,它们已经内置了 Focus Trap 和大量 edge case 处理。

颜色与视觉

[!tip] 设计检查清单

  1. Contrast Ratio — 正文文本对比度 ≥ 4.5:1,大文本 ≥ 3:1
  2. 不依赖颜色传达信息 — 错误提示除了红色还要加图标和文字
  3. 缩放 200% 下仍可用 — 不支持水平滚动查看内容
  4. 深色模式 ≠ 反转全部颜色 — 需单独测试对比度
/* 确保有焦点指示器 */
button:focus-visible {
  outline: 2px solid #0066ff;
  outline-offset: 2px;
}

/* 保留系统偏好: prefers-reduced-motion / prefers-contrast */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0s !important; transition-duration: 0s !important; }
}

React 中的关键注意点

表单字段关联

屏幕阅读器依赖 <label> 元素来告知用户当前输入框的作用。这是最基础的无障碍要求,但也是最容易被忽视的。

// ✅ 显式关联:通过 htmlFor / id 配对(适合复杂布局)
<label htmlFor="username">用户名:</label>
<input id="username" name="username" />

// ✅ 隐式关联:input 嵌套在 label 内部(最简单可靠)
<label>
  用户名:
  <input name="username" />
</label>

// ❌ 缺少 label——屏幕阅读器只会读 "编辑文本,未命名"
<input name="username" placeholder="请输入用户名" />

{/* placeholder ≠ label!placeholder 消失后用户不知道该填什么 */}

解释:placeholder 属性仅作为提示文字使用,当用户开始输入时它就会消失。而 label 始终可见且与控件绑定,是屏幕阅读器的主要信息来源。两者可以配合使用,但不能互相替代。

路由切换后的焦点管理

SPA 路由切换时,页面内容虽然变了,但 URL 也变了——屏幕阅读器不会自动将焦点移到新页面的顶部。需要手动处理:

import { useEffect } from "react";
import { useLocation } from "react-router-dom";

function FocusManager() {
  const location = useLocation();

  useEffect(() => {
    // 回到顶部 + 将焦点移到主内容区域
    window.scrollTo(0, 0);
    document.getElementById("main-content")?.focus();
  }, [location.pathname]);

  return null; // 这是一个纯逻辑组件,不渲染任何 UI
}

// HTML 中设置锚点
<main id="main-content" tabIndex={-1}>
  {/* 路由内容会在这里替换 */}
</main>

解释:这个模式对 SPA 项目尤为重要。当用户在侧边栏点击链接跳转到新页面时,屏幕阅读器用户可能仍在听旧页面的内容——因为他们不知道焦点实际上还留在原地。将焦点主动移到 <main> 元素上,等于告诉屏幕阅读器 "页面已经切换了,请开始朗读新内容"。

Next.js 等框架内置了此行为,无需手动实现。

Suspense fallback 可读性

<Suspense fallback={<p>Loading profile...</p>}>
  <Profile />
</Suspense>

{/* ✅ 有意义的加载文案,而非空白的 spinner */}
<Suspense fallback={<p>Loading user profile data...</p>}>
  <Profile />
</Suspense>

/* ❌ 无意义的 loading —— 无法帮助等待中的用户理解发生了什么 */
<Suspense fallback={<div className="spinner" />} >
  <Profile />
</Suspense>

图标按钮的可访问性

所有只有图标的按钮都必须提供描述性文本。视觉用户看到图标就明白功能,但屏幕阅读器用户只能听到一串字符。

<Button aria-label="删除这条消息">
  <TrashIcon />
</Button>

{/* 如果同时有可见文本,不需要 aria-label */}
<Button onClick={() => deleteMessage(id)}>
  <TrashIcon />
  删除
</Button>

跳过导航链接

长页面或复杂的导航结构应该提供一个"跳过到主内容"的快捷入口。这是 WCAG 2.4.1 的要求。

// 放在页面最顶部,通常用 CSS 隐藏,获得焦点时才显示
<a
  href="#main-content"
  className="skip-link"
  style={{
    position: "absolute",
    top: "-40px",
    left: "0",
    background: "#000",
    color: "#fff",
    padding: "8px 16px",
    zIndex: 9999,
  }}
  onFocus={(e) => (e.currentTarget.style.top = "0")}
  onBlur={(e) => (e.currentTarget.style.top = "-40px")}
>
  跳到主内容
</a>

<main id="main-content" tabIndex={-1}>
  {/* 页面内容 */}
</main>

测试辅助技术兼容性

[!tip] 分层测试策略 自动化测试只是第一道防线——快速捕捉低 hanging fruit。但要真正保证无障碍体验,需要三层组合:

graph LR
    A["🛡️ 自动化 lint 规则\n(eslint-plugin-jsx-a11y)"] -->|覆盖 ~30%| B["🤖 自动化测试\n(wAI11y / axe-core)"]
    B -->|覆盖 ~40%| C["⌨️ 键盘 + 屏幕阅读器\n手动测试"]
    C -->|发现剩余 ~30%| D["👥 真实残障用户测试"]
    style A fill:#A0AEC0,color:#000
    style B fill:#61DAFB,color:#000
    style C fill:#4FC08D,color:#000
    style D fill:#ED8936,color:#fff

自动化检测工具

工具 用途 集成方式
axe DevTools Chrome/Firefox 扩展,手动审计页面 浏览器安装
Lighthouse CI 中生成 a11y 评分报告 npx lighthouse --view
eslint-plugin-jsx-a11y 编码时实时报错 ESLint 插件
wAI11y Jest/Vitest 中的无障碍断言库 npm 包

wAI11y 基础用法

// 单元测试示例
import { render, screen } from "@testing-library/react";
import { accessibilityChecks } from "wai-aria";

it("关闭按钮应有关闭描述", () => {
  render(<Modal onClose={() => {}} isOpen />);
  const closeBtn = screen.getByRole("button", { name: /关闭/i });

  // 检查元素是否有正确的 aria 角色和状态
  expect(closeBtn).toHaveAttribute("aria-label", "关闭对话框");
  expect(closeBtn).toHaveAccessibleName();
});

手动测试清单

在发布前,务必用以下组合手动验证:

[!example] 人工测试步骤

  1. 纯键盘遍历 — 关掉鼠标,只用 Tab / Shift+Tab / Arrow Keys / Enter / Escape 操作完整流程
  2. 屏幕阅读器体验 — 开启 NVDA(Windows)或 VoiceOver(Mac),浏览核心页面
  3. 缩放测试 — 浏览器缩放至 200%,确认无内容被遮挡或水平滚动
  4. 对比度检查 — 使用 axe DevTools 验证所有文本对比度达标
  5. 深色模式 — 切换系统深色模式后重新走一遍流程

[!question] 思考:为什么自动化工具只能覆盖约 30% 的可访问性问题?

  • 自动化能检测缺少 alt、颜色对比不足等技术问题
  • 但无法判断交互逻辑是否合理、ARIA 含义是否正确表达、Tab 顺序是否符合直觉——这些需要人工测试

关联笔记

  • 3. 生态工具篇/08-路由管理 — 路由切换时的焦点管理策略
  • 5. 工程实践篇/16-测试 — 使用 wAI11y 编写可访问性断言测试
  • 5. 工程实践篇/15-性能优化 — 无障碍组件的性能考量