目录
一、a11y 不是「少数人的需求」
很多团队把可访问性(accessibility,缩写 a11y)当成上线前的「合规打勾项」——直到有人在 issue 里贴出「Tab 键根本走不到提交按钮」的截图。
可访问性真正服务的用户远比想象中多:
- 全球约有 2.5 亿 视障人士,但更大的群体是色觉障碍(约 8% 的男性)、手部运动障碍、认知障碍用户;
- 任何人在特定场景下都是「暂时性残障」:地铁上单手操作、强光下看不清低对比度文字、鼠标没电只能用键盘;
- 屏幕阅读器用户、语音控制用户、放大镜用户,都在用与你完全不同的方式浏览 DOM。
换句话说,a11y 不是给「边缘人群」做的慈善,而是让界面在更多输入方式下依然可用。而它的成本,绝大多数情况下只是「把 HTML 写对」。
下面是一份可以直接贴进 Code Review checklist 的实战清单。
二、第一优先级:语义化 HTML
浏览器内置了大量的可访问性语义:<button> 天然可聚焦、可用空格/回车触发;<a> 天然进入 Tab 序列;<input> 天然绑定 label;<nav>、<main>、<header> 天然形成地标(landmark),屏幕阅读器用户可以一键跳转。
只要不滥用 <div>,你就能免费拿到 80% 的可访问性。
<!-- ❌ 反面教材:屏幕阅读器只看到一堆文本 -->
<div class="btn" onclick="submit()">提交</div>
<div class="link" onclick="go()">查看详情</div>
<!-- ✅ 正确写法:语义、焦点、键盘事件全部免费 -->
<button type="submit">提交</button>
<a href="/detail">查看详情</a>一条经验法则:如果你给一个 div 写了 onclick、tabindex="0"、role="button" 和键盘事件监听,那你其实是在手写一个 <button>——直接用它就好。
页面结构同理:
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header>
<nav aria-label="主导航">...</nav>
</header>
<main id="main">
<h1>文章标题</h1>
<!-- 标题层级不要跳级:h1 → h2 → h3 -->
</main>
<footer>...</footer>
</body>aria-label 用于区分多个同类地标(页面上有两个 <nav> 时,屏幕阅读器需要知道哪个是主导航、哪个是页脚导航)。
三、键盘可达性:焦点是用户体验的一部分
只用键盘(Tab / Shift+Tab / Enter / Esc / 方向键)走一遍你的核心流程,是投入产出比最高的测试。常见问题集中在三处:
1. 自定义组件不管理焦点。 打开 Modal 时,焦点应该移入弹窗并被「困」在里面;关闭时,焦点应该回到触发按钮。
function openDialog(dialog, trigger) {
const focusables = dialog.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
dialog.showModal(); // 原生 <dialog> 自带焦点陷阱与 Esc 关闭
first?.focus();
dialog.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
dialog.addEventListener('close', () => trigger?.focus(), { once: true });
}值得强调的是:原生的 <dialog> + showModal() 已经免费提供了焦点陷阱、Esc 关闭、背景 inert 化。手写这套逻辑是 a11y bug 的高发区,能用原生就别重造。
2. 焦点样式被 outline: none 干掉。 为了「好看」而全局移除焦点轮廓,等于让键盘用户彻底迷路。正确做法是保留、并美化它:
/* ❌ 不要这样 */
*:focus { outline: none; }
/* ✅ 用 :focus-visible 只给键盘用户显示,且保证对比度 */
:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
border-radius: 4px;
}:focus-visible 的妙处在于:鼠标点击不会触发轮廓,键盘 Tab 会——既保住了设计稿,也保住了可用性。
3. 动态内容变化没有通知。 表单校验失败、Toast 提示、加载完成,这些对视觉用户是「看得见的变化」,对屏幕阅读器用户则是「什么都没发生」。用 aria-live 播报:
<div role="status" aria-live="polite" class="sr-only">
已保存 3 项更改
</div>
<!-- 紧急错误用 aria-live="assertive",它会打断当前播报,慎用 -->配合一个视觉隐藏但读屏可见的工具类:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}四、ARIA:能不用就不用
ARIA 的第一规则是:如果你能用原生 HTML 元素表达语义,就不要用 ARIA。 原生元素的行为(键盘交互、焦点管理、状态同步)是浏览器实现好的,而 ARIA 只管「告诉屏幕阅读器这是什么」,不管行为。
<!-- ❌ ARIA 滥用:有 role 但行为没实现,反而更糟 -->
<div role="checkbox" aria-checked="false">同意条款</div>
<!-- ✅ 原生元素,行为与语义都是对的 -->
<label>
<input type="checkbox" name="terms" />
同意条款
</label>ARIA 真正该出手的场景有三类:
- 原生元素无法表达的状态:
aria-expanded(折叠面板)、aria-current="page"(当前导航项)、aria-invalid(校验失败); - 原生元素无法表达的关系:
aria-describedby(把帮助文本关联到输入框)、aria-controls; - 自定义复合组件:如 combobox、tree、tablist ——这类组件应当直接采用 WAI-ARIA Authoring Practices 中给定的模式,而不是自己发明。
一个高频且几乎总被忽略的细节是「图标按钮」:
<!-- 只有一个 SVG 图标,读屏用户听到的是「按钮」,不知道干什么 -->
<button><svg aria-hidden="true">...</svg></button>
<!-- ✅ 提供可读名称,同时让装饰性图标对 AT 隐藏 -->
<button aria-label="关闭对话框">
<svg aria-hidden="true" focusable="false">...</svg>
</button>五、视觉与感知:对比度、动效、不靠颜色传达信息
对比度是 WCAG 里最容易量化的一条:正文文字与背景的对比度至少 4.5,大号文字(≥18.66px 粗体或 ≥24px)至少 3,UI 组件边界至少 3。灰底浅灰字的「高级感设计」往往是重灾区。
不要只靠颜色传达信息——这是色觉障碍用户的核心痛点:
<!-- ❌ 只靠红色表示错误 -->
<input style="border-color: red" />
<!-- ✅ 颜色 + 图标 + 文字,三重冗余 -->
<input aria-invalid="true" aria-describedby="email-error" />
<p id="email-error" class="error">
<span aria-hidden="true">⚠</span> 邮箱格式不正确,请检查是否缺少 @
</p>尊重用户的动效偏好。前庭功能障碍用户可能因视差滚动、大幅位移动画而眩晕,系统级设置可以直接读取:
@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;
}
}注意这不是「关掉所有动画」,而是把装饰性位移降级为不移动的透明度变化,信息传达依然完整。
六、表单:错误提示要说清楚「怎么改」
表单是交互最复杂、也最容易出 a11y 问题的地方。三条硬性要求:
- 每个控件都要有程序化关联的标签(
<label for>或包裹式 label,或aria-label); - 错误提示必须在文字里说明如何修复,并关联到对应控件;
- 出错时把焦点移到第一个出错的控件,并让
aria-invalid状态可被读出。
<form novalidate>
<div class="field">
<label for="email">邮箱地址</label>
<input
id="email"
name="email"
type="email"
required
aria-describedby="email-hint email-error"
aria-invalid="false"
/>
<p id="email-hint" class="hint">用于接收登录验证码,不会对外公开</p>
<p id="email-error" class="error" role="alert" hidden></p>
</div>
<button type="submit">注册</button>
</form>form.addEventListener('submit', (e) => {
e.preventDefault();
const firstInvalid = form.querySelector('input:invalid');
form.querySelectorAll('[aria-invalid]').forEach((el) => {
const error = document.getElementById(`${el.id}-error`);
el.setAttribute('aria-invalid', String(el.matches(':invalid')));
error.hidden = el.validity.valid;
error.textContent = el.validity.valid
? ''
: el.validity.valueMissing
? '此项为必填,请输入邮箱地址'
: '邮箱格式不正确,应形如 name@example.com';
});
firstInvalid?.focus();
});注意 role="alert" 只在错误出现时才渲染文本,避免页面加载时空元素被播报。
七、测试:自动化扫 40%,手动走 60%
自动化工具能高效发现「结构性问题」——缺 alt、对比度不足、label 缺失、ARIA 属性拼错。
npm i -D @axe-core/cli
npx axe https://your-site.com --exit集成进 E2E 也只要几行:
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
test('首页无可访问性违规', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});但自动化测不出这些问题,必须手动:
- 拔掉鼠标,只用键盘走完注册 / 下单 / 提交表单的主流程;
- 打开 VoiceOver(macOS
Cmd+F5)或 NVDA,听一遍页面结构是否讲得通; - 把浏览器缩放到 200%,看内容是否仍可读、不重叠;
- 强制开启
prefers-reduced-motion与高对比度模式,确认界面不崩。
八、结语
可访问性不是一个可以「做完」的项目,而是一组持续的工程习惯。落地时建议按这个顺序推进:
- 语义化 HTML——成本最低、收益最大,改掉
div按钮; - 键盘可达 + 可见焦点——一条
:focus-visible样式就能救命; - 不靠颜色传达信息 + 对比度达标——多数问题用设计 token 一次性解决;
- 表单错误可读——直接影响转化率,业务方也乐意买单;
- CI 里跑 axe——把回归挡住,比事后审计便宜得多。
一个常被引用的观点是:可访问性做对了,所有人的体验都会变好——更清晰的标题层级、更明确的错误提示、更可预测的键盘交互。它本质上是好工程实践的副产品,而不是额外的负担。