Skip to content
This page has been auto-translated and may contain errors.View in English

React 无障碍设计

每个 React 应用都渲染成浏览器一直以来就有的 HTML,所有辅助工具都是通过你的组件产生的 DOM 来工作的。React 中的无障碍设计主要就是对这个 DOM 做的一系列小的选择:你渲染哪个元素、它怎样获得名称,以及当屏幕上的内容改变时,看不见这个改变的人会发生什么。

讲别的之前先说一个 JSX 的细节。React 把 class 改成了 className,把 for 改成了 htmlFor,但 ARIA 属性保留连字符:aria-livearia-label,还有普通的 role

语义化元素是基础

<button> 自带一大堆行为。它在制表符顺序里,所以键盘能到达它。它在 Enter 和 Space 时触发点击处理程序。屏幕阅读器把它读作按钮,读出它的文本作为名称,这也是语音控制软件的目标名称。浏览器处理禁用状态、焦点环和激活样式。

jsx
// 浏览器给你焦点、键盘激活和"按钮"提示
<button className="die" onClick={hold}>{value}</button>

一个带 onClick 处理程序的 <div> 只能从那个列表中得到一项:点击。Tab 会跳过它,Enter 和 Space 做不了什么,屏幕阅读器把它读作一段文本,完全没有提示任何互动会发生什么。

常见的补救方法是加上 role="button"tabIndex={0},这把元素放进制表符顺序,改变了阅读的内容。但行为还是缺少的。你需要加一个 onKeyDown 处理程序,检查 Enter 和 Space,在 Space 上调用 preventDefault() 让页面停止滚动,然后手动维护一个和样式同步的禁用状态。这是一大堆代码,只是在重建浏览器本来就有的东西。用真正的 <button> 是更短的路,而且当浏览器改变时也能保持正确。

同样的逻辑贯穿整个标记:用 <a href> 导航,用 <nav><main> 作为屏幕阅读器能在其间跳转的地标,按顺序用标题构建大纲供人导航。React 代码库里大部分无障碍设计工作就是选择已经能做这项工作的元素。

通知内容改变

单页应用原地更新。没有页面加载来告诉屏幕阅读器发生了什么,所以屏幕中间渲染的改变可能完全无声。Live region(活跃区域)把这个信息交给屏幕阅读器:一个容器,屏幕阅读器会监视它,每当内容改变时就通知用户。下面的 sr-only 类隐藏了它的视觉外观,使用的是本章后面介绍的 CSS 模式。

jsx
<div aria-live="polite" className="sr-only">
  {isGameWon && <p>你赢了!按新游戏重新开始。</p>}
</div>

这个包装容器每次都渲染,一开始是空的,当 isGameWon 改变时 React 把一个段落交换进去。这个顺序是大家容易搞错的地方。携带 aria-live 的元素必须在内容到达之前就在 DOM 里,因为屏幕阅读器在遇到活跃区域时就注册它们,然后监视变化。如果你在一次渲染里同时挂载区域和它的文本,很多屏幕阅读器什么都不会通知:整个东西看起来像普通的新内容。在树里保留一个空区域成本为零,却能让通知可靠。

aria-live="polite" 把通知放进一个队列。屏幕阅读器会先读完它正在读的,然后在下一个自然停顿处传递你的消息,这可能是在视觉改变后的一刻。这个延迟是故意的,polite 对几乎所有情况都是正确的设置。

键盘交互

Tab 在可获焦点元素间向前移动,Shift+Tab 向后移动,Enter 激活链接和按钮,Space 激活按钮和切换复选框。

Tab 顺序遵循 DOM 顺序,所以你的 JSX 渲染的顺序就是人们通过的顺序。用 CSS 在视觉上重新排序会留下一个在屏幕上跳跳蹦蹦的 Tab 顺序,正的 tabIndex 值也会故意造成同样的混乱。tabIndex={-1} 是有用的那个:它让一个元素能从 JavaScript 获得焦点,同时保持它在 Tab 序列之外,这正是像对话框标题这样的焦点目标需要的。

还有两条规则。保持焦点可见:除非用自己的 :focus-visible 样式替换,否则避免 outline: none。保持一个退出路线:一个故意在内部锁定焦点的模态框需要 Escape 来关闭,并需要把焦点还给触发它的元素。

有意移动焦点

当 UI 改变形状时,焦点可能最后没地方了。有人激活一个按钮,按钮被移除或替换,焦点回退到 <body>。下一次 Tab 从页面顶部开始,读者失去了他们的位置。

修复办法是把焦点移到某个合理的地方,这是 ref 的合法用途之一:

jsx
function NewGameButton({ isGameWon, onNewGame }) {
  const buttonRef = useRef(null)

  useEffect(() => {
    if (isGameWon) {
      buttonRef.current.focus()
    }
  }, [isGameWon])

  return <button ref={buttonRef} onClick={onNewGame}>新游戏</button>
}

effect 在 React 把那个节点提交到屏幕后运行,所以元素已经在那里可以接收焦点。在 isGameWon 上做守卫让它不会在每次渲染时都抢焦点。

同样的模式涵盖了其他常见时刻:对话框在打开时接收焦点,关闭时把它还给触发器,验证失败时把焦点送到第一个无效字段,删除一行时把焦点移到替代它的那一行。下面的规则只有一行:如果你的代码移除了拥有焦点的东西,你的代码就要决定焦点接下来去哪。

视觉隐藏文本

很多状态从布局上是显而易见的,但屏幕阅读器听不到:字段旁的绿色勾号、看起来被按下的骰子、清晰可见的数字。视觉隐藏文本把这个信息告诉听页面的人。

约定是用一个叫 sr-only 的类。它对 React 或浏览器没有意义:就是一个普通的类名,这些 CSS 规则才是做实际工作的。

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

元素留在无障碍树里,但在视觉上不占空间。display: nonevisibility: hidden 会把它也从树里剥离,对所有人都隐藏了。

只有图标的按钮是日常情况。要么给它一个 aria-label,要么把真实的文本放在里面并在视觉上隐藏它:

jsx
<button onClick={onClose}>
  <XIcon aria-hidden="true" />
  <span className="sr-only">关闭</span>
</button>

aria-hidden="true" 让装饰性的 SVG 不被通知,隐藏的 span 提供了名称。aria-label 有一个注意事项:它在交互元素和任何带显式 role 的东西上设置无障碍名称,但浏览器经常在没有 role 的普通 <div><span> 上忽略它。把它限制在按钮、链接、输入和标记的地标上。

只阅读源代码远远不够了解这一切。用 Cmd+F5 打开 VoiceOver 听听你自己的应用,在浏览器里运行 axe DevTools 来自动捕获缺失的标签和未命名的控件。

每个表单控件都需要一个标签,表单 是差距最明显的地方。一个绑定到输入的 <label> 给了字段它的无障碍名称,所以当焦点落在那里时,屏幕阅读器会读出"电子邮件地址,编辑文本",标签文本也成了字段的点击目标。

两种连接方式都行。用 React 的 htmlFor 指向 HTML 的 for 属性,通过 id 把标签指向输入:

jsx
<label htmlFor="email">电子邮件地址</label>
<input id="email" type="email" name="email" />

或者把输入包在标签里,完全跳过 id:

jsx
<label>
  电子邮件地址
  <input type="email" name="email" />
</label>

包装方式适合复选框或单选按钮,文本已经在控件旁了。htmlFor 版本让你在布局上有更多自由。

硬编码的 id,比如 email,在一个页面上的一个表单里还行。把这个标记提升到可复用的 <TextField> 里,同一页面上的两个实例会发出同样的 id,所以 htmlFor 绑定到先渲染的哪个,标签安静地就对后面的每个字段停止工作了。useId 为每个组件实例生成一个唯一的 id,这正是 React 加它的目的:

jsx
function TextField({ label, ...props }) {
  const id = useId()

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} {...props} />
    </>
  )
}

为相关的 id 给这个值加后缀,${id}-hint 给一个描述元素,一个调用就覆盖了整个控件。

占位符文本做的是另一项工作。占位符在有人打出一个字符的瞬间就消失了,所以当它是字段唯一的描述时,那个描述在最需要它的时候就消失了。默认占位符样式是浅灰色,这通常达不到对比度要求,屏幕阅读器对这个属性的支持也不一致。把它用于预期格式的例子,比如在标签"电子邮件地址"下的 [email protected]

额外帮助文本和错误消息通过 aria-describedby 附加,它指向持有文本的元素的 id:

jsx
<label htmlFor="password">密码</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
  aria-invalid={error ? true : undefined}
/>
<p id="password-hint">{error || '至少 12 个字符。'}</p>

描述在标签和字段类型之后读出,所以它是作为上下文而不是作为名称到达的。aria-invalid 把字段标记为验证失败,把错误文本交换到 aria-describedby 已经指向的元素里可以保持通知在屏幕阅读器跟踪的节点上。往上一层,一组单选按钮应该在 <fieldset> 里面,带着包含问题的 <legend>

aria-live 有三个值,选择决定了区域是帮助还是伤害。off 是默认值,意思是改变不会被通知。polite 把通知放进队列,在屏幕阅读器到达它已经在说的话的停顿时传递。assertive 是中断的,打断当前通知来传递你的。Assertive 几乎总是错的选择:只在真正阻碍人进展的东西上用它,比如会话在十秒内过期。保存确认、搜索结果计数、游戏状态改变都应该在 polite 区域里。

两个 role 内含政治性,往往比赤裸裸的 aria-live 属性被更一致地通知:role="status" 表现得像 polite,role="alert" 像 assertive,role="status" 加上 aria-live="polite" 是状态区域的坚实默认值。aria-atomic="true" 会在任何改变时读出区域的全部内容,这适合只有整体才有意义的短句子;默认的只读改变了什么,这适合每行都独立的日志。

值得说出来的失败模式是通知太多的区域。把一个连接到每次按键都更新的值,比如搜索框下的结果计数,每个字符都会排队另一个通知。Polite 传递会加到队列而不是替换它,所以人听到在他们仍在输入的字段上过时数字的流,他们自己的输入回显也被埋了。闪烁的加载标志或三个竞争的区域造成同样的堆积。

所以保持活跃区域少量,对任何由输入驱动的东西做防抖直到值稳定,只通知会让看得见的用户抬头的时刻。一个什么都不说的应用至少是可探索的:人可以用他们的屏幕阅读器自己的命令以自己的速度探索。一个不断说话的应用是人们会离开的。

Juno正确的元素承担大部分工作 当某个东西是可点击的时候用真正的 button,每个输入旁放一个真正的 label。这些元素自带键盘支持和屏幕阅读器能读的名称,全部免费。当屏幕上的东西改变时,听页面的人会不然会错过,把一个短句放在带 aria-live="polite"div 里,从一开始就把那个 div 保留在页面上,这样改变就会被注意到。
Juno正确的元素承担大部分工作 语义化元素给你焦点、键盘激活和通知,零代码,这就是为什么用 roletabIndex 补缀一个 div 会让你写自己的键处理。用 htmlFor 或包装的 label 标记每个控件,把占位符当作格式提示,因为有人一输入它就消失。保持一个 aria-live="polite" 区域已挂载并交换它的文本,每当你的代码移除拥有焦点的东西时用 ref 移动焦点。
Juno正确的元素承担大部分工作 活跃区域在屏幕阅读器遇到它们时被注册,所以区域必须在内容改变前在 DOM 里,polite 在下一个语音停顿处传递而 assertive 中断并几乎总是错的选择。role="status"role="alert" 带着同样的政治性但更好的一致性,aria-atomic 决定了整个区域还是仅仅变化被读出。一个由按键驱动的过度热心的区域排队通知比它们能被说出的还快,对用户来说比沉默更糟。

下一步:超越基础,一份基础之后是什么的地图。