React 基础体系 · 第 17/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容
可访问性(Accessibility,通常缩写为 A11y)不是给页面额外添加几个 aria-* 属性,而是让不同输入方式、不同感知能力和不同辅助技术的用户,都能完成与产品要求相同的任务。
对 React 应用而言,可访问性问题通常来自五个机制之间的不一致:
- HTML 元素表达的语义不正确;
- 鼠标交互没有对应的键盘路径;
- 焦点没有随着界面状态正确移动或恢复;
- ARIA 属性描述的状态与真实 DOM 不一致;
- Portal、异步渲染和动态内容改变了 DOM 位置或更新时机。
可以用一个简单模型理解可访问性交互:
其中:
- 可感知名称:用户和辅助技术知道控件是什么;
- 可到达:能通过键盘或其他输入方式找到控件;
- 可操作:操作方式不依赖鼠标;
- 状态可理解:展开、选中、禁用、加载等状态可被表达;
- 结果可感知:操作完成或失败后,用户能知道发生了什么。
本文使用 React 19、TypeScript 和现代浏览器能力。React 组件可以运行在服务端或客户端,但涉及 document、window、焦点和 Portal 的代码只能在浏览器中执行。
语义:先让 HTML 表达真实的控件含义
语义元素是什么
语义元素是浏览器能够识别用途的 HTML 元素。例如:
button表示一个可触发操作的按钮;a href="..."表示一个链接;nav表示导航区域;main表示页面主内容;dialog表示对话框;ul、ol和li表示列表结构;label表示表单控件的标签;h1到h6表示标题层级。
浏览器会根据这些元素建立可访问性树。屏幕阅读器等辅助技术通常不是直接读取 React 组件,而是读取浏览器基于 DOM、HTML 语义和 ARIA 属性计算出的结果。
因此,以下代码虽然视觉上可能一样,但交互语义不同:
// 原生按钮:默认可聚焦,支持 Enter 和 Space,带 button 语义
<button type="button" onClick={onOpen}>
打开设置
</button>
// 非语义元素:默认不可聚焦,也没有按钮语义
<div className="button" onClick={onOpen}>
打开设置
</div>
第二种写法的问题不只是“键盘不能点击”。它还缺少:
- 浏览器默认的按钮角色;
- 默认的键盘行为;
- 默认的禁用模型;
- 辅助技术对“这是一个按钮”的识别;
- 一些浏览器和操作系统级别的交互一致性。
优先使用原生元素通常比“给 div 补 ARIA”更可靠。
button、链接和可点击区域不能混用
按钮和链接表达的是不同的意图:
- 按钮:改变当前页面状态、提交表单、打开菜单、展开面板;
- 链接:导航到一个 URL,通常可以在新标签页打开、复制链接地址或使用浏览器历史。
// 改变当前页面状态
<button type="button" onClick={() => setOpen(true)}>
编辑资料
</button>
// 导航
<a href="/profile/edit">编辑资料</a>
把所有可点击内容都写成 button 会破坏导航语义;把所有可点击内容都写成 a href="#" 会引入无意义的 URL、默认跳转和历史记录问题。
如果确实需要自定义交互元素,应先判断是否能使用原生元素。只有原生元素无法表达时,才考虑 role、键盘事件和焦点管理。
表单标签不是视觉装饰
表单控件必须有可计算的名称(accessible name)。最常见的方式是使用 label:
<label htmlFor="email">邮箱地址</label>
<input id="email" name="email" type="email" autoComplete="email" />
htmlFor 必须对应控件的 id。这建立了以下关系:
label ──for──> input#email
用户点击标签时,浏览器会将焦点交给输入框;屏幕阅读器读取输入框时,也能读出“邮箱地址”。
对于视觉上隐藏但仍需被辅助技术读取的标签,可以使用 CSS 的视觉隐藏类,而不是 display: none:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
<label htmlFor="search" className="sr-only">
搜索文章
</label>
<input id="search" type="search" />
display: none、hidden 和 aria-hidden="true" 通常会让内容从辅助技术可访问树中消失,因此不能用它们实现“只对屏幕阅读器可见”。
标题层级表达内容结构
标题不是单纯的字号样式。用户可能通过标题列表快速浏览页面,因此标题层级应反映内容结构:
<main>
<h1>账户设置</h1>
<section aria-labelledby="security-title">
<h2 id="security-title">安全设置</h2>
{/* ... */}
</section>
<section aria-labelledby="notification-title">
<h2 id="notification-title">通知设置</h2>
{/* ... */}
</section>
</main>
不要为了改变字号而跳过标题层级。视觉大小应由 CSS 控制,结构应由 h1 到 h6 表达。
键盘:定义可操作控件的完整输入模型
键盘可访问性不是“按 Tab 能找到”
键盘可访问性至少包括:
- 能通过
Tab到达主要交互控件; - 能使用控件规定的按键操作;
- 焦点顺序符合视觉和任务顺序;
- 弹出层、菜单和对话框中的焦点不会丢失;
- 用户能退出当前模式并回到合理位置。
对于原生按钮,浏览器已经提供了合适的行为:
Tab将焦点移动到按钮;Enter触发按钮;Space触发按钮;disabled阻止交互并表达禁用状态。
因此,下面的实现通常不需要自己监听键盘:
<button type="button" onClick={save}>
保存
</button>
自定义按钮必须补齐行为和状态
如果必须使用非原生元素,至少需要:
role="button";tabIndex={0};Enter和Space行为;- 禁用状态;
- 视觉焦点样式;
- 可能还需要处理触摸、指针和表单语义。
type FauxButtonProps = {
disabled?: boolean;
onPress: () => void;
children: React.ReactNode;
};
function FauxButton({
disabled = false,
onPress,
children,
}: FauxButtonProps) {
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (event.key === "Enter") {
event.preventDefault();
onPress();
}
if (event.key === " ") {
// 阻止 Space 导致页面滚动,并在 keyup 时触发操作
event.preventDefault();
}
}
function onKeyUp(event: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (event.key === " ") {
event.preventDefault();
onPress();
}
}
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
onClick={disabled ? undefined : onPress}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
className={disabled ? "button is-disabled" : "button"}
>
{children}
</div>
);
}
这里的 Space 使用 keydown 阻止滚动、keyup 触发操作,是为了接近原生按钮的行为。如果在 keydown 和 keyup 都触发,会导致一次按键触发两次操作。
但是,这段代码仍然不如原生 button 完整。它没有自然继承浏览器的按钮行为,也可能需要额外处理辅助输入设备、拖拽、表单提交和测试工具。因此自定义按钮应当是边界情况,而不是组件库的默认实现。
不要随意使用正数 tabIndex
浏览器默认会按照 DOM 顺序管理焦点。tabIndex={0} 将元素加入自然 Tab 顺序,tabIndex={-1} 允许脚本聚焦但不加入 Tab 顺序,正数 tabIndex 会创建一套独立于 DOM 的焦点顺序。
// 适合作为脚本控制的目标
<div tabIndex={-1} ref={headingRef} />
// 通常不建议
<div tabIndex={3} />
正数 tabIndex 的问题是:随着页面新增控件,需要重新维护一套全局排序,很容易出现键盘焦点跳跃。更可靠的做法是让 DOM 顺序接近任务顺序,使用 tabIndex={-1} 作为命令式聚焦目标。
复合控件需要明确键盘模型
菜单、列表框、树、标签页、网格等组件不是“多个按钮加起来”这么简单。常见的焦点模型有两种。
roving tabIndex
只有当前项目具有 tabIndex={0},其他项目为 -1:
function Tabs() {
const [active, setActive] = useState(0);
return (
<div role="tablist" aria-label="账户设置">
{["资料", "安全", "通知"].map((label, index) => (
<button
key={label}
type="button"
role="tab"
tabIndex={active === index ? 0 : -1}
aria-selected={active === index}
onClick={() => setActive(index)}
>
{label}
</button>
))}
</div>
);
}
实际组件还需要在方向键、Home、End 后移动 DOM 焦点:
function moveTab(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number,
count: number,
focusAt: (index: number) => void,
) {
let next = index;
if (event.key === "ArrowRight") next = (index + 1) % count;
if (event.key === "ArrowLeft") next = (index - 1 + count) % count;
if (event.key === "Home") next = 0;
if (event.key === "End") next = count - 1;
if (next !== index) {
event.preventDefault();
focusAt(next);
}
}
aria-activedescendant
容器本身保持 DOM 焦点,当前项目通过 aria-activedescendant 表达:
<div
role="listbox"
tabIndex={0}
aria-activedescendant={`option-${activeIndex}`}
>
{options.map((option, index) => (
<div
id={`option-${index}`}
key={option.id}
role="option"
aria-selected={activeIndex === index}
>
{option.label}
</div>
))}
</div>
这种模型要求:
aria-activedescendant指向真实存在的后代或符合规范关系的元素;- 当前元素始终保持焦点;
- 自己实现方向键、Home、End 等行为;
- 视觉高亮与
aria-activedescendant同步。
两种模型都可以成立,但不能只加 role="tab" 或 role="listbox",却不实现对应的键盘和状态语义。
焦点:把它当作界面状态,而不是 DOM 副作用
焦点的三个阶段
复杂交互中的焦点管理通常包含三个阶段:
触发点获得焦点
│
▼
打开覆盖层,焦点进入覆盖层
│
▼
关闭覆盖层,焦点返回触发点
如果只实现“显示弹窗”,而不处理焦点,常见结果是:
- 弹窗打开后焦点仍停留在背景按钮;
- 用户按 Tab 后进入背景页面;
- 弹窗关闭后焦点回到页面顶部;
- React 重渲染后当前输入框失去焦点;
- 弹窗中的内容异步加载后,用户不知道焦点在哪里。
useRef 和命令式焦点
焦点是浏览器 DOM 的命令式状态,不适合完全用 React state 表达。useRef 可以保存 DOM 节点:
import { useRef } from "react";
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
function focusSearch() {
inputRef.current?.focus();
}
return (
<>
<button type="button" onClick={focusSearch}>
聚焦搜索框
</button>
<input ref={inputRef} type="search" aria-label="搜索" />
</>
);
}
useRef 的特点是:
ref.current的变化不会触发重新渲染;- 组件挂载后由 React 写入 DOM 节点;
- 卸载时通常恢复为
null; - 不能在服务端直接调用
.focus(); - 不应在渲染阶段读取或修改 DOM。
React 19 支持函数组件直接接收 ref 属性:
type TextInputProps = React.ComponentProps<"input">;
function TextInput({ ref, ...props }: TextInputProps) {
return <input ref={ref} {...props} />;
}
传统的 forwardRef 仍然是现有代码和跨版本组件库中的常见方式:
const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
function TextInput(props, ref) {
return <input ref={ref} {...props} />;
},
);
在 React 19 项目中可以优先采用 ref 作为 prop;如果组件库需要兼容旧版本 React,仍需依据支持范围保留 forwardRef。这里的核心边界不变:组件应只暴露必要的命令式能力,例如 focus() 或滚动,而不是把内部 DOM 结构完全暴露给调用方。
useEffect 和 useLayoutEffect
当界面打开后必须立即聚焦某个元素,通常使用 useLayoutEffect,因为它在浏览器绘制前运行,可以减少“先显示未聚焦状态、再跳转焦点”的闪烁:
useLayoutEffect(() => {
if (!open) return;
initialFocusRef.current?.focus();
}, [open]);
但是 useLayoutEffect 在服务端没有 DOM。服务端渲染项目通常需要客户端边界或同构安全封装:
import { useEffect, useLayoutEffect } from "react";
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
这段代码只能在构建工具允许服务端和客户端共同执行模块时使用;如果框架提供 "use client" 等客户端组件边界,应把依赖 DOM 的交互组件放在客户端边界内。无论采用哪种框架,规则都是:服务端生成 HTML,客户端挂载后才能访问 DOM、读取 document.activeElement 或调用 .focus()。
焦点恢复必须保存真实触发点
弹窗打开时保存触发按钮,关闭时恢复:
const triggerRef = useRef<HTMLButtonElement>(null);
function closeDialog() {
setOpen(false);
// 不能立即假定 DOM 已经卸载完成;
// 关闭动画或条件渲染时应在关闭完成时恢复焦点。
requestAnimationFrame(() => {
triggerRef.current?.focus();
});
}
更严谨的做法是让对话框组件接收 onCloseComplete,在退出动画完成、内容真正卸载后恢复焦点。因为如果关闭过程仍在动画中,立即调用 .focus() 可能被后续卸载或焦点管理逻辑覆盖。
还要处理触发点已不存在的情况。例如用户打开对话框后切换了路由,原按钮已经卸载,此时应选择合理的备用目标:
function restoreFocus(
trigger: HTMLElement | null,
fallback: HTMLElement | null,
) {
if (trigger && document.contains(trigger)) {
trigger.focus();
} else {
fallback?.focus();
}
}
ARIA:描述语义和状态,但不能替代正确 HTML
ARIA 的作用
ARIA(Accessible Rich Internet Applications)是一组属性和角色,用来向辅助技术描述:
- 控件是什么:
role="dialog"、role="tab"; - 控件当前状态:
aria-expanded、aria-selected、aria-checked; - 控件与其他节点的关系:
aria-controls、aria-labelledby; - 控件名称和说明:
aria-label、aria-labelledby、aria-describedby; - 内容更新:
aria-live、aria-busy。
ARIA 不会自动实现交互。下面的代码只声明了“这是一个按钮”,并不会让 div 自动支持键盘:
<div role="button">删除</div>
它仍然需要焦点、键盘操作、禁用状态和视觉反馈。可以概括为:
可访问名称与描述
辅助技术需要为控件计算名称。常见优先顺序是:
- 可见文本;
aria-labelledby指向的文本;aria-label;- HTML 原生标签关系。
例如图标按钮必须有名称:
<button type="button" aria-label="关闭">
<CloseIcon aria-hidden="true" />
</button>
装饰性图标应从辅助技术树中隐藏。若图标本身包含重要文字,就不能简单加 aria-hidden="true"。
对于复杂说明,使用 aria-describedby:
<label htmlFor="password">密码</label>
<p id="password-help">至少包含 12 个字符。</p>
<input
id="password"
type="password"
aria-describedby="password-help"
/>
aria-describedby 提供补充说明,不应替代输入框的主要标签。
状态必须和 React 状态保持一致
ARIA 属性是 DOM 输出的一部分,因此必须由同一个状态源驱动:
const [expanded, setExpanded] = useState(false);
<button
type="button"
aria-expanded={expanded}
aria-controls="advanced-options"
onClick={() => setExpanded(value => !value)}
>
高级选项
</button>
<div id="advanced-options" hidden={!expanded}>
{/* 内容 */}
</div>
这里有三个必须同时成立的事实:
aria-expanded表示面板是否展开;aria-controls指向面板的真实id;hidden控制面板是否参与布局和可访问性树。
如果只更新视觉 CSS,不更新 aria-expanded,辅助技术看到的状态就会过时。
aria-hidden 的危险边界
aria-hidden="true" 会让节点及其后代从辅助技术可访问树中隐藏,但不一定让它从视觉布局和键盘顺序中消失。这样会产生严重错误:
<div aria-hidden="true">
<button type="button">仍然可以 Tab 到</button>
</div>
视觉上可见、键盘可聚焦、但辅助技术无法读取名称的控件,称为“焦点落入隐藏树”一类的问题。隐藏整个区域时,应优先使用:
hidden;- 条件渲染;
display: none;- 合适的
inert。
inert 会让一个子树不能被聚焦或交互,并且通常不会进入可访问性树。现代浏览器已支持原生 inert,但如果需要兼容旧环境,应确认框架目标和 polyfill 策略。
对话框:语义、焦点和背景隔离必须一起完成
一个模态对话框至少要解决:
- 用户知道焦点已进入对话框;
- 对话框有名称;
Escape可以关闭;Tab不会绕到背景;- 关闭后焦点回到触发点;
- 背景内容不能被继续操作;
- 关闭状态不会遗留在 DOM 中。
下面给出一个简化但可运行的 React 19 示例。它使用 createPortal 将内容放到 document.body,同时通过 inert 隔离应用背景。
import {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
function useIsomorphicLayoutEffect(
effect: React.EffectCallback,
deps: React.DependencyList,
) {
const hook =
typeof window === "undefined" ? useEffect : useLayoutEffect;
hook(effect, deps);
}
function getTabbable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
[
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(","),
),
).filter(element => {
const style = window.getComputedStyle(element);
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
!element.hasAttribute("hidden")
);
});
}
type ModalProps = {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
};
function Modal({ open, title, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = "settings-dialog-title";
useIsomorphicLayoutEffect(() => {
if (!open || !dialogRef.current) return;
const dialog = dialogRef.current;
const previousActive = document.activeElement as HTMLElement | null;
const tabbables = getTabbable(dialog);
(tabbables[0] ?? dialog).focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const current = getTabbable(dialog);
if (current.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = current[0];
const last = current[current.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
dialog.addEventListener("keydown", onKeyDown);
return () => {
dialog.removeEventListener("keydown", onKeyDown);
if (previousActive && document.contains(previousActive)) {
previousActive.focus();
}
};
}, [open, onClose]);
if (!open || typeof document === "undefined") {
return null;
}
return createPortal(
<div className="modal-layer">
<div
className="modal-backdrop"
onMouseDown={event => {
if (event.target === event.currentTarget) {
onClose();
}
}}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="modal"
>
<h2 id={titleId}>{title}</h2>
<div className="modal-content">{children}</div>
<button type="button" onClick={onClose}>
关闭
</button>
</div>
</div>,
document.body,
);
}
function SettingsPage() {
const [open, setOpen] = useState(false);
useEffect(() => {
const app = document.getElementById("app");
if (!app) return;
if (open) {
app.setAttribute("inert", "");
} else {
app.removeAttribute("inert");
}
return () => {
app.removeAttribute("inert");
};
}, [open]);
return (
<>
<div id="app">
<main>
<h1>账户设置</h1>
<button type="button" onClick={() => setOpen(true)}>
打开设置
</button>
</main>
</div>
<Modal
open={open}
title="账户设置"
onClose={() => setOpen(false)}
>
<label htmlFor="display-name">显示名称</label>
<input id="display-name" defaultValue="Ada" />
</Modal>
</>
);
}
这个示例的执行顺序
假设用户聚焦“打开设置”并按下 Enter:
setOpen(true)更新 React 状态;- React 渲染
Modal; createPortal把模态节点插入document.body;useIsomorphicLayoutEffect在客户端执行;- 保存打开前的
document.activeElement; - 查找对话框内可聚焦元素;
- 将焦点移动到输入框,若没有输入框则移动到对话框容器;
- 安装
Escape和Tab处理器; - 设置应用背景的
inert; - 用户关闭对话框;
- React 卸载模态;
- effect 清理函数将焦点恢复到打开按钮。
getTabbable 只是一个教学示例,不是完整的可聚焦元素算法。生产组件还需要考虑:
contenteditable;audio、video控件;- disabled fieldset;
visibility、遮挡和动画;- Shadow DOM;
- iframe;
- 组件内嵌套的复合控件;
- 浏览器和辅助技术差异。
生产组件库通常会使用经过验证的对话框实现,或者严格依据 WAI-ARIA Authoring Practices 实现并进行真实辅助技术测试。
dialog 元素与 role="dialog"
现代浏览器提供原生 <dialog>。showModal() 会建立浏览器级模态行为,包含焦点和背景交互处理的一部分;但不同浏览器版本、关闭事件、样式和 SSR 使用方式仍需要验证。
<div role="dialog" aria-modal="true"> 是 ARIA 方式。aria-modal="true" 的含义是:辅助技术应将对话框视为模态区域。它不会自动阻止鼠标点击、Tab 或脚本访问背景,所以仍要实现背景隔离和焦点管理。
不能同时假定 aria-modal 已经完成了 inert、焦点陷阱和关闭逻辑。
Portal:DOM 位置改变,React 树关系不变
Portal 的定义
createPortal(children, domNode) 将 React 子树渲染到指定 DOM 节点,而不是当前父节点的 DOM 位置:
createPortal(<Tooltip />, document.body)
这形成两棵不同的树:
React 树:
Page
└── Modal
DOM 树:
body
├── #app
└── .modal-layer
Portal 中的组件仍然保留 React 上下文和 React 事件传播关系:
- Context 仍从 React 父树继承;
- React 事件可能沿 React 父子关系冒泡;
- Provider、错误边界和状态关系仍在原 React 树中。
但它不再继承原 DOM 位置的很多效果:
- CSS 的祖先选择器可能失效;
overflow: hidden、transform、z-index的层叠关系改变;- DOM 事件委托和原生
event.target关系需要重新检查; - 屏幕阅读器看到的 DOM 顺序可能发生变化;
aria-labelledby和aria-describedby的引用必须指向真实存在的唯一id。
Portal 不是自动可访问的
Portal 解决的是渲染位置问题,不解决:
- 对话框语义;
- 焦点进入和恢复;
- 背景隔离;
- Esc 关闭;
- Tab 循环;
- 名称和描述;
- 动态内容播报。
一个被 Portal 到 body 的 div 仍然只是一个 div:
createPortal(
<div className="dialog">删除账户?</div>,
document.body,
)
它不会自动成为对话框。至少需要正确的角色、名称、焦点和生命周期。
SSR 与 Portal 的边界
服务端没有 document,所以以下代码不能在服务端渲染阶段执行:
// 错误:模块执行或渲染时直接访问 document
const modalRoot = document.getElementById("modal-root");
更安全的方式是在客户端渲染阶段访问:
function ClientPortal({ children }: { children: React.ReactNode }) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
setContainer(document.body);
}, []);
if (!container) return null;
return createPortal(children, container);
}
代价是:模态内容会在客户端 effect 后才出现。如果首屏必须包含该内容,应由框架提供稳定的客户端 Portal 容器,并确保服务端和客户端初始结构一致,避免 hydration mismatch。
客户端交互组件也应避免在渲染期间根据 window.innerWidth、document.activeElement 等浏览器信息决定不同的初始 HTML。服务端和客户端第一次渲染结果不一致,会导致水合警告或事件绑定异常。
动态内容:更新不等于用户已经知道
React 更新 DOM 后,视觉用户可能立即看到变化,但屏幕阅读器不一定会主动读取变化。动态内容需要使用合适的 live region。
aria-live 的工作方式
aria-live 声明某个区域发生变化时是否需要通知辅助技术:
<div aria-live="polite" aria-atomic="true">
{message}
</div>
常见等级:
polite:等当前朗读完成后再通知;assertive:更快打断当前朗读,应谨慎使用;off:默认不主动通知。
role="status" 通常适合普通状态变化,例如“已保存”;role="alert" 适合错误或需要立即注意的内容,但不应把所有提示都做成 alert。
function SaveStatus({ status }: { status: "idle" | "saving" | "saved" | "error" }) {
const text = {
idle: "",
saving: "正在保存",
saved: "已保存",
error: "保存失败",
}[status];
return (
<p role={status === "error" ? "alert" : "status"} aria-atomic="true">
{text}
</p>
);
}
live region 应稳定存在
以下实现可能无法可靠播报,因为节点只在消息存在时才挂载:
// 不够稳定
return message ? <div role="status">{message}</div> : null;
更稳定的是让 live region 始终存在,只更新其文本:
function Announcement({ message }: { message: string }) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}
如果连续两次更新内容完全相同,例如两次都是“保存失败”,辅助技术可能不会将其视为新变化。可以在语义上增加上下文,或在确有必要时清空后再设置,但不应为了强制播报而制造重复或干扰性的通知。
加载状态要表达范围
aria-busy="true" 表示某个区域正在更新:
<section aria-labelledby="results-title" aria-busy={loading}>
<h2 id="results-title">搜索结果</h2>
{loading ? <p>正在加载结果</p> : <Results items={items} />}
</section>
aria-busy 不会自动显示加载动画,也不会自动阻止交互。它只是告诉辅助技术:区域内容尚未稳定。
按钮的异步提交还应表达禁用或忙碌状态:
<button
type="submit"
disabled={saving}
aria-busy={saving}
>
{saving ? "保存中…" : "保存"}
</button>
需要区分:
disabled:不能操作;aria-busy:正在处理;- 文本状态:让用户知道当前阶段;
- live region:让已经在页面其他位置的用户收到结果。
表单错误:关联字段、错误信息和提交结果
表单错误必须同时提供视觉和程序关联:
type EmailFieldProps = {
value: string;
error?: string;
onChange: (value: string) => void;
};
function EmailField({ value, error, onChange }: EmailFieldProps) {
const errorId = "email-error";
return (
<div>
<label htmlFor="email">邮箱地址</label>
<input
id="email"
name="email"
type="email"
value={value}
onChange={event => onChange(event.target.value)}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
/>
{error ? (
<p id={errorId} role="alert">
{error}
</p>
) : null}
</div>
);
}
状态变化如下:
无错误:
aria-invalid = false 或省略
aria-describedby = 无
有错误:
aria-invalid = true
aria-describedby = email-error
email-error 节点存在并包含错误文本
只把输入框边框改红是不够的,因为颜色不能成为唯一错误信号,也不能被所有用户感知。
提交失败时,还需要决定焦点策略:
- 错误字段较少:聚焦第一个错误字段;
- 错误字段较多:聚焦错误摘要,再允许用户进入字段;
- 页面跳转或服务端验证后:确保焦点进入新页面主标题或错误摘要。
聚焦错误摘要时,标题或容器通常需要 tabIndex={-1}:
const summaryRef = useRef<HTMLDivElement>(null);
function showErrors() {
setHasSubmitted(true);
requestAnimationFrame(() => {
summaryRef.current?.focus();
});
}
<div
ref={summaryRef}
tabIndex={-1}
role="alert"
aria-labelledby="error-summary-title"
>
<h2 id="error-summary-title">表单存在错误</h2>
<p>请检查邮箱地址。</p>
</div>
不要在每次输入时强制聚焦错误消息,否则用户无法继续编辑字段。
动画、隐藏和内容卸载
CSS 隐藏方式会影响可访问性:
| 方式 | 是否参与布局 | 通常是否可聚焦 | 通常是否在可访问性树 |
|---|---|---|---|
display: none |
否 | 否 | 否 |
hidden |
否 | 否 | 否 |
visibility: hidden |
否或占据布局视 CSS | 否 | 否 |
opacity: 0 |
是 | 可能 | 可能 |
aria-hidden="true" |
是 | 可能 | 否 |
inert |
是 | 否 | 通常否 |
因此,不能仅用 opacity: 0 隐藏一个弹窗。它可能仍然出现在 Tab 顺序中,也可能继续被辅助技术识别。
有退出动画时,应区分三个状态:
closed 不在 DOM,不可访问
opening 在 DOM,焦点进入
open 在 DOM,可交互
closing 仍在 DOM,但不应允许新交互
unmounted 动画结束后移除
如果在 closing 阶段还允许 Tab 进入内部控件,焦点管理会出现竞态。常见解决方案是:
- 在关闭开始时立即移除交互能力;
- 在动画结束后再卸载;
- 焦点恢复绑定到关闭完成事件;
- 不让 React 状态、CSS 动画和 Portal 清理分别管理同一个生命周期。
用户开启“减少动态效果”时,应尊重 prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
React 并发和可访问性状态的一致性
React 可能将状态更新批处理或推迟提交。可访问性属性、视觉状态和事件处理必须来自同一份状态,而不能各自维护副本。
不可靠的结构:
const [open, setOpen] = useState(false);
const [ariaExpanded, setAriaExpanded] = useState(false);
<button
aria-expanded={ariaExpanded}
onClick={() => {
setOpen(value => !value);
setAriaExpanded(value => !value);
}}
>
更多
</button>
{open && <Panel />}
两个 state 可能因不同事件、异常路径或后续重构而不同步。
可靠的结构:
const [open, setOpen] = useState(false);
<button
type="button"
aria-expanded={open}
aria-controls="more-panel"
onClick={() => setOpen(value => !value)}
>
更多
</button>
<div id="more-panel" hidden={!open}>
<Panel />
</div>
这里 open 同时决定:
- 视觉展开;
aria-expanded;hidden;- 后续焦点逻辑的触发条件。
使用 startTransition 更新非关键内容时,也应保证关键反馈先发生。例如点击保存后,按钮的 disabled 和“保存中”状态属于操作反馈,不应依赖一个可能延迟的非紧急更新;搜索结果列表则可以作为非关键内容更新。
另外,React Strict Mode 在开发环境可能重复执行 effect 的安装和清理,用来暴露副作用不对称问题。焦点监听、键盘监听、Portal 容器创建和 inert 修改都必须可重复安装和清理:
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
如果 effect 添加了监听却没有精确移除,开发环境可能表现为一次按键触发多次关闭,生产中则可能形成泄漏。
唯一 ID、SSR 和 useId
ARIA 关系依赖唯一且稳定的 ID:
<h2 id={titleId}>设置</h2>
<div role="dialog" aria-labelledby={titleId} />
在列表或多个组件实例中,手写固定 ID 会产生重复:
// 多个实例会生成相同的 id
const titleId = "dialog-title";
React 提供 useId 生成适合同构渲染的稳定 ID:
import { useId } from "react";
function DialogTitle() {
const id = useId();
return (
<>
<h2 id={`${id}-title`}>设置</h2>
<div role="dialog" aria-labelledby={`${id}-title`} />
</>
);
}
useId 适合用于标签关联、描述关联和组件内部 ID。它不适合:
- 业务实体 ID;
- 数据库主键;
- 需要跨请求持久化的 ID;
- 依赖随机值的缓存键。
如果服务端和客户端渲染路径不同,例如条件分支导致 useId 调用顺序不同,就仍然可能出现 hydration 问题。Hooks 调用顺序和初始树结构必须保持一致。
组件库与设计系统中的可访问性边界
可访问性不能只靠业务页面补救。组件 API 应当把语义和状态作为接口的一部分。
组件应暴露语义,而不是只暴露样式
一个 Button 组件不应只接受 className 和 onClick,还应保留原生按钮属性:
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "secondary";
};
function Button({
variant = "primary",
className,
...props
}: ButtonProps) {
return (
<button
type="button"
className={`button button-${variant} ${className ?? ""}`}
{...props}
/>
);
}
组件库若把所有东西都渲染成 div,业务层很难恢复正确的默认行为。
组合 API 要定义责任边界
例如一个 Dialog 组件可以内部负责:
role="dialog";aria-labelledby;- 焦点进入和恢复;
Escape;- Tab 循环;
- 背景
inert; - Portal。
调用方负责:
- 标题内容;
- 业务关闭条件;
- 表单校验;
- 异步保存状态。
如果调用方还要手动设置 aria-modal、自己监听 Esc、自己恢复焦点,就容易出现重复逻辑和竞态。
Token 不能牺牲焦点可见性
设计 Token 应包含焦点环的颜色、宽度、偏移,而不是把 outline 全局清除:
:focus-visible {
outline: 3px solid var(--color-focus-ring);
outline-offset: 3px;
}
:focus-visible 允许浏览器根据输入方式决定何时显示强焦点指示。键盘用户必须始终能看到当前焦点;不能用下面的全局规则替代:
/* 错误倾向:隐藏所有焦点 */
*:focus {
outline: none;
}
如果组件库改变主题,应验证焦点环、错误颜色、禁用颜色和背景对比度,而不是只验证普通文本。
常见失败表现与诊断路径
看起来能用,但辅助技术读不懂
表现:
- 图标按钮被读成“按钮”但没有名称;
- 展开面板视觉打开,仍被读成关闭;
- 输入框报错但屏幕阅读器没有读取错误;
- 对话框打开后没有标题。
诊断步骤:
- 用浏览器开发者工具查看 Accessibility Tree;
- 检查控件的 role、name、description、state;
- 确认
aria-labelledby和aria-describedby的目标 ID 存在且唯一; - 检查实际 DOM 是否与 React state 一致;
- 使用至少一种屏幕阅读器验证真实朗读结果。
Tab 顺序跳到不可见区域
表现:
- 弹窗关闭后仍能 Tab 到其中的按钮;
opacity: 0的菜单继续获取焦点;aria-hidden="true"的区域含有可聚焦控件。
诊断步骤:
- 连续按 Tab,记录每次
document.activeElement; - 对每个焦点元素检查祖先是否隐藏或
inert; - 查看 CSS 是否只改变透明度;
- 在关闭路径中确认节点是否卸载或移除交互能力。
可以在开发环境临时记录:
document.addEventListener("focusin", event => {
const target = event.target;
if (target instanceof HTMLElement) {
console.log("focus:", target.tagName, target.id, target.textContent);
}
});
Portal 中点击背景导致事件误判
React 事件沿 React 树传播,而 DOM 节点已经位于 body。如果页面父组件监听 onClick,Portal 内的点击可能仍然触发该 React 父组件的处理逻辑。
因此,判断“点击是否发生在遮罩层”时,不要只依赖 React 父级事件冒泡,应直接比较目标:
function onBackdropMouseDown(
event: React.MouseEvent<HTMLDivElement>,
) {
if (event.target === event.currentTarget) {
close();
}
}
event.target 是真正点击的节点,event.currentTarget 是当前处理器绑定的遮罩节点。只有二者相等,才表示点击了遮罩空白本身,而不是对话框内部内容。
自动化测试通过,但键盘仍不可用
axe、eslint 插件和组件快照可以发现一部分问题,例如缺少 label、重复 ID、错误 ARIA 关系,但不能完全验证:
- Tab 顺序是否符合任务;
- Escape 是否关闭并恢复焦点;
- 屏幕阅读器是否按预期朗读动态内容;
- 复合控件的方向键模型;
- 动画结束时焦点是否丢失。
最低限度的手动路径应包括:
加载页面
→ 只用 Tab 找到入口
→ 用 Enter/Space 操作
→ 验证焦点位置
→ 验证 Escape、Shift+Tab、方向键
→ 关闭后验证焦点恢复
→ 制造错误和加载状态
→ 验证状态和结果可感知
自动化测试可以检查关键 DOM 结果:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("打开对话框后焦点进入并可关闭", async () => {
const user = userEvent.setup();
render(<SettingsPage />);
const trigger = screen.getByRole("button", { name: "打开设置" });
await user.click(trigger);
expect(
screen.getByRole("dialog", { name: "账户设置" }),
).toBeVisible();
expect(screen.getByLabelText("显示名称")).toHaveFocus();
await user.keyboard("{Escape}");
expect(trigger).toHaveFocus();
});
这个测试验证的是 DOM 焦点和角色名称,不代表已经完成真实屏幕阅读器测试。两者的覆盖范围不同,不能互相替代。
规范保证、浏览器实现与工程建议的区别
以下内容应明确区分:
- 规范或平台语义:原生
button的默认键盘行为、label与表单控件的关联、DOM 顺序影响 Tab 顺序; - 常见浏览器实现:
inert的支持、dialog的具体行为、屏幕阅读器对 live region 的播报时机; - 工程建议:使用
useLayoutEffect减少焦点闪烁、采用稳定的 Portal 容器、在组件库统一管理 Dialog 焦点。
尤其是 ARIA 播报,浏览器、操作系统和屏幕阅读器共同决定最终体验。aria-live="polite" 表示通知策略,不是对所有辅助技术都能保证完全相同的朗读时机。
同样,自动化工具报告“没有可访问性违规”只能说明其规则集未发现已覆盖的问题,不能推出应用对所有用户都可用。
最终的因果链
一个可访问的 React 交互组件,应能沿着以下因果链解释:
真实任务
↓
选择正确 HTML 语义
↓
浏览器提供基础焦点和键盘行为
↓
React state 驱动视觉状态与 ARIA 状态
↓
useRef/useEffect 管理必要的 DOM 命令
↓
Portal 改变 DOM 位置时补齐焦点和背景隔离
↓
异步更新通过 live region、busy 和错误关联传达
↓
键盘、Accessibility Tree、屏幕阅读器共同验证
如果一个组件需要大量 onKeyDown、role、tabIndex 和手动焦点代码,首先应重新检查它是否本来可以由原生 HTML 完成。原生语义是最可靠的基础;ARIA 用来补充复杂关系和状态;useRef 负责不可避免的命令式焦点边界;Portal 只负责渲染位置;动态内容则需要显式设计结果通知。把这些职责分开并保持状态一致,React 应用的可访问性才不会依赖偶然的视觉表现。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 并发渲染:Transition、Deferred Value、Suspense 和一致性
- 下一篇:React 前端安全:XSS、dangerouslySetInnerHTML、URL、Token 和依赖
- 延伸:React Ref 与 DOM:useRef、forwardRef、测量、焦点和命令式边界
- 延伸:React 组件库与设计系统:组合 API、Token、主题和版本治理
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论