17 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
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>?
答案涉及三个层面:
- 键盘 — div 不在 Tab 焦点流中,Enter/Space 不会触发点击
- 屏幕阅读器 — 读作"按钮,未绑定", 用户不知道它是干什么的
- 搜索引擎 — 无法识别为可交互元素
// ❌ 滥用 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)
- 不要使用 ARIA —— 用原生语义化元素解决一切
- 不要将原生交互改为
role="presentation"—— 会破坏已有无障碍支持- 所有 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 做了三件事:
- 自动聚焦首个元素 —— 打开弹窗后立即将焦点放到第一个可交互元素,让用户无需按 Tab 即可操作
- Tab 循环 —— Shift+Tab 从第一个回到最后一个,Tab 从最后一个回到第一个
- 清理监听器 —— 关闭弹窗时移除事件监听,防止内存泄漏
实际项目中推荐直接使用
@radix-ui/react-dialog或 MUI/Chakra 等组件库,它们已经内置了 Focus Trap 和大量 edge case 处理。
颜色与视觉
[!tip] 设计检查清单
- Contrast Ratio — 正文文本对比度 ≥ 4.5:1,大文本 ≥ 3:1
- 不依赖颜色传达信息 — 错误提示除了红色还要加图标和文字
- 缩放 200% 下仍可用 — 不支持水平滚动查看内容
- 深色模式 ≠ 反转全部颜色 — 需单独测试对比度
/* 确保有焦点指示器 */
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] 人工测试步骤
- 纯键盘遍历 — 关掉鼠标,只用 Tab / Shift+Tab / Arrow Keys / Enter / Escape 操作完整流程
- 屏幕阅读器体验 — 开启 NVDA(Windows)或 VoiceOver(Mac),浏览核心页面
- 缩放测试 — 浏览器缩放至 200%,确认无内容被遮挡或水平滚动
- 对比度检查 — 使用 axe DevTools 验证所有文本对比度达标
- 深色模式 — 切换系统深色模式后重新走一遍流程
[!question] 思考:为什么自动化工具只能覆盖约 30% 的可访问性问题?
- 自动化能检测缺少 alt、颜色对比不足等技术问题
- 但无法判断交互逻辑是否合理、ARIA 含义是否正确表达、Tab 顺序是否符合直觉——这些需要人工测试
关联笔记
- 3. 生态工具篇/08-路由管理 — 路由切换时的焦点管理策略
- 5. 工程实践篇/16-测试 — 使用 wAI11y 编写可访问性断言测试
- 5. 工程实践篇/15-性能优化 — 无障碍组件的性能考量