Skip to content

屏幕阅读器适配 - 完整无障碍阅读指南

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 开启/关闭VoiceOver

3. 优化屏幕阅读器体验

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. 总结

屏幕阅读器适配的关键要点:

  1. 语义化HTML: 使用正确的HTML标签
  2. ARIA补充: 为自定义组件添加ARIA
  3. 动态通知: 使用Live Regions播报变化
  4. 表单可访问: 清晰的标签和错误提示
  5. 键盘导航: 完整的键盘支持
  6. 真实测试: 使用屏幕阅读器实测
  7. 用户反馈: 收集实际用户意见

通过正确适配屏幕阅读器,可以让视障用户无障碍地使用应用。