可访问性(a11y)工程实践
第22章:可访问性(a11y)工程实践
a11y 直接影响 SEO 排名、法律合规性,以及所有用户的使用体验——包括在强光下用手机的你。
本章核心问题:a11y 为什么是工程问题而非慈善问题?React 特有的 a11y 挑战有哪些? 读完本章你将理解:
- 语义化 HTML 是第一原则——原生元素自带大量 a11y 行为
- 键盘导航、焦点管理、aria-live 动态通知是三大核心能力
- SPA 路由切换时需要手动管理焦点和标题更新
Level 1 · 你需要知道的(1-3年经验)
语义化 HTML:第一原则
在 React 里用 div 实现任何交互元素是一个常见的坏习惯:
// 错误:div 没有任何语义,键盘无法聚焦,屏幕阅读器无法识别
<div onClick={handleDelete} className="delete-btn">
删除
</div>
// 正确:button 天生具备键盘可操作性、ARIA role、焦点管理
<button onClick={handleDelete} type="button">
删除
</button>
原生 HTML 元素自带了大量 a11y 行为,不需要手动实现:
| 元素 | 自带能力 |
|---|---|
<button> |
键盘聚焦、Enter/Space 触发点击、role="button" |
<a href> |
键盘聚焦、Enter 触发、role="link"、右键菜单 |
<input> |
键盘聚焦、与 <label> 的关联、role 由 type 决定 |
<nav> |
role="navigation" 地标 |
<main> |
role="main" 地标,页面主内容区域 |
<header> |
role="banner"(在顶层时) |
<footer> |
role="contentinfo"(在顶层时) |
正确的标题层级
// 错误:标题层级跳跃,为了样式而用大号标题
<h1>仪表盘</h1>
<h3>销售概览</h3> {/* 跳过了 h2 */}
<h5>今日数据</h5> {/* 跳过了 h4 */}
// 正确:标题层级反映内容层次结构
<h1>仪表盘</h1>
<h2>销售概览</h2>
<h3>今日数据</h3>
如果样式需要大号字体但语义上是 h3,通过 CSS 调整字体大小,而不是错用标题层级:
<h3 className="text-2xl font-bold">视觉上像 h1 的 h3</h3>
ARIA 属性:何时用,如何用
ARIA(Accessible Rich Internet Applications)是语义化 HTML 的补充,用于描述原生 HTML 无法表达的 UI 模式。ARIA 的第一原则:能用原生 HTML 实现的,不要用 ARIA。
aria-label 与 aria-labelledby
当可见文本无法清晰描述元素用途时,使用 aria-label:
// 图标按钮没有可见文字,必须加 aria-label
<button aria-label="关闭对话框" onClick={onClose}>
<XIcon />
</button>
// 搜索输入框有占位文本但没有 label
<input
type="search"
aria-label="搜索产品"
placeholder="输入关键词..."
/>
aria-labelledby 指向页面上已有的文本元素,适合复用已有标签:
<h2 id="section-title">用户设置</h2>
<section aria-labelledby="section-title">
{/* 这个区域的无障碍名称来自 id="section-title" 的元素 */}
</section>
aria-describedby:补充说明
<div>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
aria-describedby="password-hint password-error"
/>
<p id="password-hint">密码至少 8 位,包含大小写字母和数字</p>
{error && <p id="password-error" role="alert">{error}</p>}
</div>
aria-live:动态内容通知
当页面上有动态变化的内容(加载状态、错误提示、计数变化),屏幕阅读器不会自动感知 DOM 变化。aria-live 告诉辅助技术"这块区域有动态内容需要播报":
function StatusMessage({ message, type }: { message: string; type: 'info' | 'error' }) {
return (
<div
role="status" // 隐含 aria-live="polite"(等当前内容读完再播报)
aria-live="polite" // 显式声明,等效
aria-atomic="true" // 作为整体播报,而不是增量播报
>
{message}
</div>
);
}
// 错误信息需要立即打断:用 role="alert"(隐含 aria-live="assertive")
function ErrorAlert({ message }: { message: string }) {
return (
<div role="alert">
{message}
</div>
);
}
注意:aria-live 区域必须在页面初始化时就存在于 DOM 中,后来插入的 aria-live 区域不一定会被辅助技术识别。正确做法是始终渲染容器,通过内容的变化来触发播报。
Level 2 · 它是怎么运行的(3-5年经验)
键盘导航与焦点管理
键盘可操作性是 a11y 的核心。很多用户完全依赖键盘(或键盘等效设备),无法使用鼠标。
焦点陷阱:模态框的正确实现
当模态框打开时,焦点必须被限制在模态框内,不能逃逸到背后的内容:
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 保存打开前的焦点元素
previousFocusRef.current = document.activeElement as HTMLElement;
// 将焦点移到模态框
modalRef.current?.focus();
} else {
// 关闭时恢复焦点
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1} // 使 div 可聚焦,但不进入 Tab 顺序
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
className="modal"
>
<h2 id="modal-title">确认删除</h2>
{children}
<button onClick={onClose}>取消</button>
<button onClick={handleConfirm}>确认删除</button>
</div>
);
}
生产环境推荐使用 focus-trap-react 库来处理焦点陷阱的所有边界情况(循环 Tab、初始焦点、还原焦点等)。
跳过链接(Skip Links)
对于键盘用户,每次加载新页面都要 Tab 经过导航栏才能到达主内容,这是巨大的摩擦。跳过链接允许用户直接跳到主内容:
// 放在 <body> 的最开始
function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
>
跳转到主内容
</a>
);
}
// CSS:默认不可见,聚焦时显示
// .skip-link {
// position: absolute;
// top: -100%;
// left: 0;
// }
// .skip-link:focus {
// top: 0;
// }
// 主内容区域
<main id="main-content" tabIndex={-1}>
<PageContent />
</main>
React Router 中的焦点管理
SPA 路由切换时,屏幕阅读器不会自动感知"页面已经变了",焦点可能停在一个已消失的元素上:
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer() {
const location = useLocation();
const announceRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// 路由变化时,将焦点移到页面标题
headingRef.current?.focus();
}, [location.pathname]);
return (
<>
{/* 隐藏的播报区域,通知屏幕阅读器页面已变化 */}
<div ref={announceRef} aria-live="polite" className="sr-only">
已导航到:{document.title}
</div>
</>
);
}
Level 3 · 规范怎么定义的(资深)
屏幕阅读器测试
自动化工具能发现约 30-40% 的 a11y 问题,其余问题只能通过真实的辅助技术测试发现。
macOS/iOS:VoiceOver
- 开启:
Cmd + F5 - 导航:
VO + 方向键(VO =Ctrl + Option) - 进入/离开组:
VO + Shift + Down/Up - 表单模式:
VO + Shift + J
Windows:NVDA(免费)
- 下载:nvaccess.org
- 浏览模式:方向键逐元素浏览
- 表单模式:
Enter进入,Escape退出 - 元素列表:
NVDA + F7(查看所有标题/链接/表单元素)
测试核实清单:
- 所有交互元素能用 Tab 到达
- 焦点可见(有明显的焦点环,不要
outline: none) - 模态框打开时焦点进入,关闭时焦点还原
- 表单元素都有关联的 label
- 错误信息被
role="alert"或aria-live播报 - 图标按钮都有
aria-label - 路由切换后焦点位置合理
自动化检查工具
@axe-core/react(开发环境)
npm install -D @axe-core/react
// src/main.tsx(仅开发环境)
if (import.meta.env.DEV) {
import('@axe-core/react').then(({ default: axe }) => {
axe(React, ReactDOM, 1000); // 每秒检查一次,结果输出到控制台
});
}
axe-core 会在浏览器控制台输出 a11y 违规,包含违规的 DOM 节点、规则说明和修复建议。
eslint-plugin-jsx-a11y(编码时)
npm install -D eslint-plugin-jsx-a11y
// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
jsxA11y.flatConfigs.recommended,
{
rules: {
// 根据项目实际情况调整
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-noninteractive-element-interactions': 'error',
'jsx-a11y/alt-text': 'error',
'jsx-a11y/label-has-associated-control': 'error',
},
},
];
这个插件能在编写代码时捕获大量常见错误:缺少 alt 的图片、没有关联控件的 label、onClick 没有对应的 onKeyDown 等。
Level 4 · 边界与陷阱(所有人)
为什么 a11y 是工程问题,不是慈善问题
法律层面:美国 ADA(《美国残障人士法》)、欧盟 EAA(欧洲无障碍法案,2025 年全面生效),以及越来越多国家的本地法规要求数字产品符合 WCAG 标准。违规可能面临诉讼,2023 年仅美国就有超过 4000 起 a11y 相关诉讼。
SEO 层面:Google 的爬虫在很多方面类似屏幕阅读器——它依赖语义化 HTML、正确的标题层级、alt 文本来理解页面内容。a11y 好的页面在 SEO 上通常也表现更好。
工程质量层面:强制写语义化 HTML、合理的 ARIA 标签、清晰的焦点管理,实际上逼迫你写出结构更清晰、逻辑更分明的组件。a11y 是代码质量的外显指标之一。
React 特有的 a11y 问题
问题一:key 变化导致焦点丢失
当列表项的 key 在数据更新后改变,React 会卸载再挂载元素,焦点丢失。使用稳定的 ID(通常是数据的 id 字段)作为 key,而非数组索引。
问题二:条件渲染与焦点
// 错误:isEditing 切换时,焦点跳回 body
{isEditing ? <input autoFocus /> : <p>{value}</p>}
// 改进:使用 autoFocus 让输入框在出现时自动聚焦(但要确保这符合用户期望)
问题三:Portal 与焦点陷阱
Modal 通常通过 ReactDOM.createPortal 渲染到 body,使其脱离原始 DOM 层级。焦点陷阱仍然有效(焦点管理是视觉层面的),但 Escape 键的事件冒泡路径需要注意。
问题四:动态路由与标题
每次路由变化,document.title 应当更新为新页面的标题。这对屏幕阅读器用户尤其重要,他们无法从视觉布局判断当前在哪个页面:
function usePageTitle(title: string) {
useEffect(() => {
const prevTitle = document.title;
document.title = `${title} | YiteAI`;
return () => {
document.title = prevTitle;
};
}, [title]);
}
a11y 不应该是项目上线前的最后一步检查,而应该从第一行代码开始就融入工程实践。使用语义化 HTML、配置 eslint-plugin-jsx-a11y、定期用真实辅助技术测试——这些习惯成本很低,但能显著提升产品的可访问性和整体质量。