Appearance
屏幕阅读器适配 - 完整无障碍阅读指南
1. 屏幕阅读器基础
1.1 主流屏幕阅读器
typescript
const screenReaders = {
NVDA: {
platform: 'Windows',
cost: '免费开源',
share: '约50%盲人用户',
url: 'https://www.nvaccess.org/'
},
JAWS: {
platform: 'Windows',
cost: '付费',
share: '约30%盲人用户',
url: 'https://www.freedomscientific.com/products/software/jaws/'
},
VoiceOver: {
platform: 'macOS/iOS',
cost: '系统内置',
share: '约10%盲人用户',
keys: 'VO键 = Control + Option'
},
TalkBack: {
platform: 'Android',
cost: '系统内置',
share: 'Android用户'
},
Narrator: {
platform: 'Windows',
cost: '系统内置',
usage: '较少专业用户'
}
};1.2 屏幕阅读器如何工作
typescript
const screenReaderWorkflow = {
step1: '解析HTML生成可访问性树(Accessibility Tree)',
step2: '从可访问性树提取信息',
step3: '将信息转换为语音输出',
step4: '响应用户的导航命令',
step5: '报告页面状态变化'
};
// 可访问性树示例
const accessibilityTree = {
button: {
role: 'button',
name: '提交',
state: 'enabled',
focusable: true
},
input: {
role: 'textbox',
name: '邮箱地址',
value: 'user@example.com',
required: true,
invalid: false
}
};2. 屏幕阅读器测试
2.1 Windows - NVDA
typescript
// NVDA快捷键
const nvdaShortcuts = {
'NVDA + T': '读取标题',
'NVDA + F7': '元素列表',
'H': '下一个标题',
'Shift + H': '上一个标题',
'B': '下一个按钮',
'E': '下一个编辑框',
'F': '下一个表单字段',
'G': '下一个图片',
'K': '下一个链接',
'L': '下一个列表',
'T': '下一个表格',
'Insert + ↓': '朗读当前行',
'Insert + F5': '刷新',
'Ctrl': '停止朗读'
};
// 启动NVDA测试
function testWithNVDA() {
// 1. 启动NVDA (Ctrl + Alt + N)
// 2. 访问测试页面
// 3. 按H键浏览标题
// 4. 按F键浏览表单
// 5. 按L键浏览链接
// 6. 检查ARIA标签是否正确播报
}2.2 macOS - VoiceOver
typescript
// VoiceOver快捷键 (VO = Control + Option)
const voiceOverShortcuts = {
'VO + A': '开始阅读',
'VO + →': '下一项',
'VO + ←': '上一项',
'VO + U': '转子(导航菜单)',
'VO + H H': '下一个标题',
'VO + J': '下一个表单控件',
'VO + L': '下一个链接',
'VO + G': '下一个图片',
'VO + T': '下一个表格',
'VO + Space': '激活项目',
'Ctrl': '停止朗读',
'VO + Shift + I': '打开Web Item',
'VO + Shift + H': '打开Help'
};
// 启动VoiceOver测试
// Cmd + F5 开启/关闭VoiceOver3. 优化屏幕阅读器体验
3.1 语义化HTML
html
<!-- ❌ 不好 - 屏幕阅读器无法理解 -->
<div onclick="submit()">提交</div>
<!-- ✅ 好 - 自动播报"提交 按钮" -->
<button onclick="submit()">提交</button>
<!-- ❌ 不好 - 缺少上下文 -->
<a href="/details">更多</a>
<!-- ✅ 好 - 完整信息 -->
<a href="/details">查看产品详情</a>
<!-- ❌ 不好 - 无意义的图片alt -->
<img src="photo.jpg" alt="图片">
<!-- ✅ 好 - 描述性alt -->
<img src="sunset.jpg" alt="海滩上的日落景色,天空呈橙红色">3.2 ARIA标签
html
<!-- aria-label: 直接标签 -->
<button aria-label="关闭对话框">
<svg><!-- X图标 --></svg>
</button>
<!-- aria-labelledby: 引用标签 -->
<h2 id="section-title">用户设置</h2>
<section aria-labelledby="section-title">
设置内容...
</section>
<!-- aria-describedby: 额外描述 -->
<input
type="password"
aria-label="密码"
aria-describedby="password-requirements"
>
<div id="password-requirements">
密码必须至少包含8个字符,包括大小写字母和数字
</div>
<!-- aria-live: 动态更新 -->
<div aria-live="polite" aria-atomic="true">
<span>搜索结果:</span>
<span id="count">10</span>
<span>条</span>
</div>3.3 React组件实现
tsx
// ScreenReaderOnly.tsx - 仅屏幕阅读器可见
export function ScreenReaderOnly({ children }: { children: React.ReactNode }) {
return (
<span className="sr-only">
{children}
</span>
);
}
// CSS
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// IconButton.tsx
export function IconButton({
icon,
label,
onClick
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button onClick={onClick} aria-label={label}>
{icon}
<ScreenReaderOnly>{label}</ScreenReaderOnly>
</button>
);
}
// 使用
<IconButton
icon={<TrashIcon />}
label="删除项目"
onClick={handleDelete}
/>4. 动态内容通知
4.1 Live Regions
html
<!-- polite: 等待当前朗读完成 -->
<div aria-live="polite">
保存成功!
</div>
<!-- assertive: 立即打断朗读 -->
<div aria-live="assertive">
错误:表单验证失败!
</div>
<!-- off: 不播报 -->
<div aria-live="off">
静默更新
</div>
<!-- aria-atomic: 播报整个区域还是仅变化部分 -->
<div aria-live="polite" aria-atomic="true">
<span>剩余时间:</span>
<span id="timer">5分钟</span>
</div>
<!-- aria-relevant: 播报哪些变化 -->
<div
aria-live="polite"
aria-relevant="additions text"
>
<!-- additions: 添加的内容 -->
<!-- removals: 删除的内容 -->
<!-- text: 文本变化 -->
<!-- all: 所有变化 -->
</div>4.2 React Live Region组件
tsx
// LiveRegion.tsx
interface LiveRegionProps {
message: string;
type?: 'polite' | 'assertive';
clearAfter?: number;
}
export function LiveRegion({
message,
type = 'polite',
clearAfter = 5000
}: LiveRegionProps) {
const [content, setContent] = useState(message);
useEffect(() => {
setContent(message);
if (clearAfter > 0) {
const timer = setTimeout(() => setContent(''), clearAfter);
return () => clearTimeout(timer);
}
}, [message, clearAfter]);
return (
<div
role="status"
aria-live={type}
aria-atomic="true"
className="sr-only"
>
{content}
</div>
);
}
// Announcer.tsx - 全局通知器
export function Announcer() {
const [announcements, setAnnouncements] = useState<string[]>([]);
useEffect(() => {
const handleAnnounce = (e: CustomEvent) => {
setAnnouncements(prev => [...prev, e.detail.message]);
setTimeout(() => {
setAnnouncements(prev => prev.slice(1));
}, 3000);
};
window.addEventListener('announce' as any, handleAnnounce);
return () => window.removeEventListener('announce' as any, handleAnnounce);
}, []);
return (
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcements.map((msg, i) => (
<div key={i}>{msg}</div>
))}
</div>
);
}
// 使用
function announce(message: string) {
window.dispatchEvent(
new CustomEvent('announce', { detail: { message } })
);
}
announce('文件上传成功');4.3 加载状态
tsx
// LoadingState.tsx
export function LoadingState({
isLoading,
message = '加载中...'
}: {
isLoading: boolean;
message?: string;
}) {
if (!isLoading) return null;
return (
<div
role="status"
aria-live="polite"
aria-busy="true"
>
<span className="spinner" aria-hidden="true" />
<span>{message}</span>
</div>
);
}
// AsyncContent.tsx
export function AsyncContent<T>({
promise,
fallback,
children
}: {
promise: Promise<T>;
fallback: React.ReactNode;
children: (data: T) => React.ReactNode;
}) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
promise.then(data => {
setData(data);
setLoading(false);
announce('内容加载完成');
});
}, [promise]);
if (loading) {
return (
<>
{fallback}
<div aria-live="polite" className="sr-only">
正在加载内容...
</div>
</>
);
}
return <>{children(data!)}</>;
}5. 表单可访问性
5.1 标签和说明
html
<!-- 基础标签 -->
<label for="email">邮箱地址</label>
<input type="email" id="email" name="email" required>
<!-- 带说明文字 -->
<label for="username">用户名</label>
<input
type="text"
id="username"
aria-describedby="username-help"
required
>
<small id="username-help">
3-20个字符,仅支持字母和数字
</small>
<!-- 错误提示 -->
<label for="password">密码</label>
<input
type="password"
id="password"
aria-invalid="true"
aria-errormessage="password-error"
aria-describedby="password-requirements"
>
<div id="password-requirements">
至少8个字符
</div>
<div id="password-error" role="alert">
密码格式不正确
</div>5.2 React表单组件
tsx
// FormInput.tsx
interface FormInputProps {
label: string;
type?: string;
name: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
helpText?: string;
error?: string;
}
export function FormInput({
label,
type = 'text',
name,
value,
onChange,
required = false,
helpText,
error
}: FormInputProps) {
const inputId = useId();
const helpId = useId();
const errorId = useId();
return (
<div className="form-field">
<label htmlFor={inputId}>
{label}
{required && (
<>
<span aria-label="必填">*</span>
<ScreenReaderOnly>(必填)</ScreenReaderOnly>
</>
)}
</label>
<input
type={type}
id={inputId}
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
aria-invalid={!!error}
aria-describedby={
[helpText && helpId, error && errorId]
.filter(Boolean)
.join(' ') || undefined
}
aria-errormessage={error ? errorId : undefined}
/>
{helpText && (
<small id={helpId}>{helpText}</small>
)}
{error && (
<div id={errorId} role="alert" className="error">
<ScreenReaderOnly>错误:</ScreenReaderOnly>
{error}
</div>
)}
</div>
);
}5.3 表单验证反馈
tsx
// FormValidation.tsx
export function FormValidation() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const validate = (name: string, value: string) => {
let error = '';
if (name === 'email') {
if (!value) {
error = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
error = '邮箱格式不正确';
}
}
setErrors(prev => ({ ...prev, [name]: error }));
// 播报错误
if (error && touched[name]) {
announce(`${name}字段错误: ${error}`);
}
};
return (
<form onSubmit={handleSubmit}>
<FormInput
label="邮箱"
name="email"
value={formData.email}
onChange={(value) => {
setFormData({ ...formData, email: value });
validate('email', value);
}}
onBlur={() => setTouched({ ...touched, email: true })}
error={touched.email ? errors.email : undefined}
required
/>
{/* 表单级别错误摘要 */}
{Object.keys(errors).length > 0 && (
<div role="alert" aria-live="assertive" className="form-errors">
<h3>表单包含以下错误:</h3>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>
<a href={`#${field}`}>{field}: {error}</a>
</li>
))}
</ul>
</div>
)}
<button type="submit">提交</button>
</form>
);
}6. 复杂组件适配
6.1 数据表格
tsx
// AccessibleTable.tsx
interface Column {
key: string;
header: string;
description?: string;
}
export function AccessibleTable({
caption,
columns,
data
}: {
caption: string;
columns: Column[];
data: Record<string, any>[];
}) {
return (
<table>
<caption>{caption}</caption>
<thead>
<tr>
{columns.map(col => (
<th key={col.key} scope="col" abbr={col.description}>
{col.header}
{col.description && (
<ScreenReaderOnly>({col.description})</ScreenReaderOnly>
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((col, colIndex) => {
const Cell = colIndex === 0 ? 'th' : 'td';
const props = colIndex === 0 ? { scope: 'row' as const } : {};
return (
<Cell key={col.key} {...props}>
{row[col.key]}
</Cell>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={columns.length}>
<ScreenReaderOnly>
表格结束。共{data.length}行数据
</ScreenReaderOnly>
</td>
</tr>
</tfoot>
</table>
);
}6.2 自动完成
tsx
// Autocomplete.tsx
export function Autocomplete({
label,
suggestions
}: {
label: string;
suggestions: string[];
}) {
const [value, setValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputId = useId();
const listId = useId();
const filtered = suggestions.filter(s =>
s.toLowerCase().includes(value.toLowerCase())
);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev =>
prev < filtered.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => (prev > 0 ? prev - 1 : -1));
break;
case 'Enter':
if (activeIndex >= 0) {
e.preventDefault();
setValue(filtered[activeIndex]);
setIsOpen(false);
announce(`已选择: ${filtered[activeIndex]}`);
}
break;
case 'Escape':
setIsOpen(false);
setActiveIndex(-1);
break;
}
};
useEffect(() => {
if (activeIndex >= 0 && filtered[activeIndex]) {
announce(`${filtered[activeIndex]}, ${activeIndex + 1} / ${filtered.length}`);
}
}, [activeIndex]);
return (
<div className="autocomplete">
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
type="text"
role="combobox"
aria-autocomplete="list"
aria-controls={listId}
aria-expanded={isOpen}
aria-activedescendant={
activeIndex >= 0 ? `option-${activeIndex}` : undefined
}
value={value}
onChange={(e) => {
setValue(e.target.value);
setIsOpen(true);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
/>
{isOpen && filtered.length > 0 && (
<ul id={listId} role="listbox">
{filtered.map((item, index) => (
<li
key={index}
id={`option-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
setValue(item);
setIsOpen(false);
announce(`已选择: ${item}`);
}}
className={index === activeIndex ? 'active' : ''}
>
{item}
</li>
))}
</ul>
)}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{isOpen && `${filtered.length}个建议可用`}
</div>
</div>
);
}7. 测试与验证
7.1 屏幕阅读器测试清单
typescript
const screenReaderTestChecklist = {
structure: [
'页面标题是否清晰描述内容',
'标题层级是否正确(h1-h6)',
'地标区域是否正确标记',
'跳过导航链接是否有效'
],
content: [
'所有图片是否有合适的alt文本',
'链接文本是否有描述性',
'按钮是否有清晰的标签',
'表单字段是否有关联的label'
],
interaction: [
'动态内容是否通过live region播报',
'错误消息是否及时播报',
'加载状态是否播报',
'状态变化是否播报'
],
navigation: [
'键盘导航是否完整',
'焦点是否可见',
'焦点顺序是否合理',
'模态框焦点管理是否正确'
]
};7.2 自动化测试
typescript
// screen-reader.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Screen Reader Accessibility', () => {
it('should have accessible names', () => {
const { getByRole } = render(<MyComponent />);
const button = getByRole('button');
expect(button).toHaveAccessibleName('Submit');
const input = getByRole('textbox');
expect(input).toHaveAccessibleName('Email address');
});
it('should announce form errors', async () => {
const { getByRole, getByText } = render(<MyForm />);
const submit = getByRole('button', { name: 'Submit' });
fireEvent.click(submit);
const alert = getByRole('alert');
expect(alert).toHaveTextContent('Email is required');
});
it('should have no axe violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});8. 最佳实践
typescript
const screenReaderBestPractices = {
content: [
'使用语义化HTML',
'提供有意义的alt文本',
'使用aria-label补充图标',
'确保链接文本有描述性',
'避免"点击这里"等无意义文本'
],
aria: [
'优先使用原生HTML',
'使用ARIA补充而非替代',
'正确使用aria-live播报变化',
'为自定义组件提供ARIA',
'测试ARIA标签的播报效果'
],
interaction: [
'所有功能可键盘访问',
'提供视觉焦点指示器',
'动态内容及时播报',
'错误消息立即通知',
'加载状态明确告知'
],
testing: [
'使用真实屏幕阅读器测试',
'测试主流平台(NVDA/VoiceOver)',
'检查播报内容是否准确',
'验证导航体验是否流畅',
'收集盲人用户反馈'
]
};9. 总结
屏幕阅读器适配的关键要点:
- 语义化HTML: 使用正确的HTML标签
- ARIA补充: 为自定义组件添加ARIA
- 动态通知: 使用Live Regions播报变化
- 表单可访问: 清晰的标签和错误提示
- 键盘导航: 完整的键盘支持
- 真实测试: 使用屏幕阅读器实测
- 用户反馈: 收集实际用户意见
通过正确适配屏幕阅读器,可以让视障用户无障碍地使用应用。