做React表单时,很多注意力都放在了状态管理和校验逻辑上,但无障碍这块经常被忽视。一个输入框如果缺少正确的label关联,屏幕阅读器用户听到的是三个没有意义的编辑框,而不是姓名、邮箱、密码。同样,校验失败时哪怕页面上红字写得再醒目,只要没有通过aria-describedby关联到输入框,辅助技术用户依然感知不到错误的存在。本文重点讲清楚label关联和错误信息关联这两件事的具体实现方式。

label关联:htmlFor与包裹式两种写法
原生HTML里label和输入控件的关联有两种方式,React里同样适用。第一种是显式关联,用label的for属性指向input的id。注意在JSX中for是保留字,必须写成htmlFor:
<label htmlFor="username">用户名</label> <input id="username" type="text" />
第二种是隐式关联,直接把input包裹在label内部:
<label> 用户名 <input type="text" /> </label>
两种方式都能让点击label文本聚焦到输入框,屏幕阅读器也会在聚焦input时朗读label内容。但实际项目中更推荐显式关联,原因是包裹式写法在需要复杂布局时(label和input分属不同的flex容器)会变得很别扭,而且有些第三方UI库的组件不方便被label包裹。另外要注意id的唯一性,React项目里组件经常被复用,多个实例渲染出重复id会导致关联错乱,可以用useId来生成唯一id:
import { useId } from 'react';
function UsernameField() {
const id = useId();
return (
<>
<label htmlFor={id}>用户名</label>
<input id={id} type="text" />
</>
);
}还有一个容易遗漏的控件是select和textarea,它们同样需要label关联,写法和input完全一致。如果是自定义的 checkbox 或 radio,务必保证每个选项都有自己的label,否则用户只能靠猜测来判断选项含义。
aria-describedby:把错误信息和帮助文本挂到输入框上
label解决的是这个控件是什么的问题,aria-describedby解决的是这个控件的补充说明是什么,包括格式提示、帮助文本和校验错误。它的工作机制是指向一个或多个元素的id,屏幕阅读器聚焦输入框时,会把label、描述内容一起朗读出来。
典型的错误提示结构是这样的:
function EmailField() {
const [error, setError] = useState('');
return (
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
aria-describedby={error ? 'email-error' : undefined}
aria-invalid={error ? true : undefined}
onBlur={(e) => {
if (!e.target.value.includes('@')) {
setError('请输入有效的邮箱地址');
} else {
setError('');
}
}}
/>
{error && (
<p id="email-error" style={{ color: 'red' }}>
{error}
</p>
)}
</div>
);
}这段代码有几个关键细节。第一,错误提示元素必须渲染出来且带有id,aria-describedby的值才能指向它。第二,搭配aria-invalid属性,明确告诉辅助技术当前值无效,很多屏幕阅读器会朗读无效编辑框这样的提示。第三,当没有错误时最好把aria-describedby移除或者不设置,避免朗读无关内容。
aria-describedby还支持空格分隔的多个id,比如输入框既有格式说明又有错误提示时,可以写成aria-describedby="email-hint email-error",屏幕阅读器会依次朗读两个元素的内容。这个特性在做密码强度提示、必填项说明时非常实用。
封装一个无障碍表单输入组件
每个输入框都手写一遍id管理和aria属性既繁琐又容易出错,更好的做法是封装统一的字段组件,把无障碍逻辑收敛到一处:
import { useId, useState } from 'react';
function TextField({ label, error, hint, ...props }) {
const id = useId();
const hintId = useId();
const errorId = useId();
// 组合需要朗读的描述id
const describedBy = [
hint ? hintId : null,
error ? errorId : null,
].filter(Boolean).join(' ');
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
{...props}
/>
{hint && <p id={hintId}>{hint}</p>}
{error && (
<p id={errorId} role="alert" style={{ color: 'red' }}>
{error}
</p>
)}
</div>
);
}这个组件把id生成、描述关联、错误标记全部封装好了,业务侧只需要传入label、error和hint三个属性。错误提示上加了role="alert",这样即使输入框没有重新获得焦点,错误文本出现的瞬间屏幕阅读器也会主动播报,对于即时校验场景特别有用。
最后提几个实践建议。错误提示不要只靠颜色区分,最好带上文字或图标加文字,照顾色弱用户;必填标记可以用aria-required="true"替代单纯的红色星号;提交时如果校验失败,应该把焦点移动到第一个出错的输入框,而不是让用户自己满屏找。这些细节叠加起来,表单的无障碍体验才算是完整。可以借助浏览器自带的辅助功能检查器,或者用NVDA、VoiceOver实际操作一遍表单流程,验证label和错误提示是否都能被正确朗读。
React表单无障碍aria-describedbylabel关联修改时间:2026-09-08 13:16:46