引言
打开任何一个稍大一点的网站,用键盘按两下 Tab,很多站点会立刻露出马脚:焦点消失不见、焦点陷在某个弹窗里出不来、屏幕阅读器把一堆 div 念成"空白"。这些问题的共同根源是——可访问性(Accessibility,a11y)没有被当作工程问题来对待,而是被当成上线前补一补的"可选项"。
这篇文章从工程化的视角,把 Web 可访问性拆成一条可落地的主线:语义化 HTML 打地基 → ARIA 补盲区 → 键盘与焦点管理 → 颜色对比度 → 屏幕阅读器验证 → 自动化检测守住回归。每一节都配可直接使用的代码,最后落在 CI 与 lint 上,让"无障碍"从口号变成可以自动验证的工程标准。
一、为什么可访问性值得被当成工程问题
法律风险不是"如果",而是"何时"
在美国,《美国残疾人法案》(ADA)框架下针对网站的诉讼在过去十年持续高发,企业因网站无法被屏幕阅读器使用而被起诉的案例并不少见。欧洲《欧洲无障碍法案》(European Accessibility Act)已于 2025 年 6 月起对大多数企业生效,要求公共服务网站和应用达到 WCAG 2.1 AA 标准。中国《无障碍环境建设法》自 2023 年 9 月施行,明确要求涉及民生的重要网站和应用必须提供无障碍服务。把可访问性当成法律合规的底线,而不是道德上的可选项,是工程判断的第一步。
商业层面:可访问性是覆盖率的增量
世界卫生组织估计,全球约 15% 的人口存在某种形式的残障。这还只是显性人群——老年人、临时受伤者、弱光环境下看手机的用户,同样受益于可访问性设计。提高可访问性等于扩大可触达的潜在用户与购买力,同时减少客服与诉讼成本。
工程层面:可访问性是好代码的副产品
语义化 HTML、清晰的焦点管理、合理的标题层级——这些恰恰是结构良好、可维护、对 SEO 友好的代码的同义词。一个"无障碍友好"的组件,往往也是一个"改起来不炸"的组件。可访问性检查能提前暴露大量 DOM 结构问题,这对工程质量是纯收益。
二、语义化 HTML:可访问性的地基
屏幕阅读器、键盘导航、搜索引擎爬虫,都在消费同一个东西:文档的语义结构。HTML 本身就是一门无障碍技术——button 自带可聚焦、可激活、可被读作"按钮"的属性;div 什么都不是。地基的第一步,是让标签表达含义。
页面骨架:landmark
<body>
<a class="skip-link" href="#main">跳到主内容</a>
<header>
<nav aria-label="主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/docs">文档</a></li>
</ul>
</nav>
</header>
<main id="main">
<h1>Web 可访问性工程化</h1>
<p>正文……</p>
</main>
<footer>
<p>© 2026 Your Company</p>
</footer>
</body>header、nav、main、footer等元素会自动映射为 ARIA landmark 角色,屏幕阅读器用户可以用快捷键在区块间跳转,不用逐个 Tab。- 每个页面只能有一个
main,且它是"跳到主内容"的锚点。 nav在页面上有多个时,用aria-label区分,例如"主导航"与"页脚导航"。
标题层级:可访问性的"目录"
标题层级是文档的"目录"。规则只有一条:不要跳级。
<h1>Web 可访问性工程化</h1>
<h2>语义化 HTML</h2>
<h3>页面骨架</h3>
<h3>标题层级</h3>
<h2>ARIA</h2>常见错误:
- 出于样式目的使用
h3而不管逻辑层级。 - 页面上出现多个
h1(除非每个部分是独立入口的模块,否则尽量避免)。 - 用
font-size加粗"伪造"标题,而没有真正的h1到h6。
视觉样式与语义结构应当分离:不要为了让字变大而选错标题级别,也不要为了样式硬塞一个不匹配的
hN。
交互元素:能原生就别造轮子
<!-- 错误:div 不可聚焦、不响应 Enter,屏幕阅读器读不出"按钮" -->
<div class="btn" onclick="submit()">提交</div>
<!-- 正确:原生 button 自带可聚焦、可激活、可读作"按钮"的能力 -->
<button type="submit">提交</button>用原生元素,等于免费获得键盘支持、焦点管理、无障碍名称与 ARIA 角色。自定义控件永远比原生控件更贵、也更脆弱。
三、ARIA:语义不足时的"补丁"
ARIA(Accessible Rich Internet Applications)为辅助技术补充语义,但它的第一条规则是:
不要使用 ARIA——如果可以用原生 HTML 元素或属性实现同样的功能。
何时真的需要 ARIA
| 场景 | 方案 |
|---|---|
| 自定义下拉框、弹窗等无法用原生元素表达 | role="dialog" + aria-modal + 焦点管理 |
| 动态内容:表单错误、toast、加载状态 | aria-live="polite" / role="alert" |
| 自定义控件的当前状态 | aria-expanded、aria-checked、aria-pressed |
| 无文本的图标按钮 | aria-label 提供无障碍名称 |
| 必填字段提示 | aria-required(通常与 required 一起使用) |
一个真实的弹窗例子
<div id="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">确认删除</h2>
<p>删除后无法恢复,确定继续吗?</p>
<button type="button">确定</button>
<button type="button">取消</button>
</div>aria-labelledby 把弹窗标题关联进来,屏幕阅读器读弹窗时会先念出标题。aria-modal="true" 提示辅助技术:焦点应被限制在弹窗内(还需要 JS 配合,见下文"焦点陷阱")。
aria-live:把动态变化"说"出来
屏幕阅读器默认不会主动播报 DOM 的改动。当错误信息、加载状态动态插入时,需要用 aria-live 声明这块区域的"活跃程度":
<div aria-live="polite">
<!-- 内容被 JS 替换后,读屏器会温和地播报 -->
</div>polite:当前任务结束后播报,适合大多数提示。assertive:立即打断播报,只用于"必须马上知道"的错误,滥用会非常吵。role="alert"相当于aria-live="assertive"的语义化版本,用于真正的错误提示。
常见 ARIA 误用清单
- 给
div加role="button"却忘了加tabindex="0"与键盘事件——白加了,还可能更糟。 aria-label覆盖了可见文字,读出来的和看到的不一致,造成困惑。- 重复语义:
button上又加role="button",纯属冗余。 - 滥用
aria-live="assertive",把整个页面变成"抢话"现场。 - 只管加
aria-expanded,不管焦点与视觉状态同步。
ARIA 的黄金法则:优先原生,其次 ARIA;加一个 ARIA 属性,就要保证它反映真实的交互状态。
四、键盘导航与焦点管理
键盘是很多用户唯一的输入方式。可访问性的核心体验,往往就是按 Tab 能不能顺畅走完整个流程。
1. focus-visible:只在键盘导航时显示焦点
默认的焦点轮廓被很多人用 outline: none 粗暴移除,这是最常见、也最危险的反模式。正确做法是区分鼠标点击与键盘导航:
/* 移除按钮的默认轮廓,但仅限鼠标场景 */
button:focus:not(:focus-visible) {
outline: none;
}
/* 键盘导航时保留清晰、可自定义的焦点环 */
button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}:focus-visible 是 CSS 标准(现代浏览器均支持),它在键盘导航时显示焦点、鼠标点击时不显示,兼顾了美观与可用性。
2. 跳过导航链接
让键盘用户不用按几十下 Tab 才能到正文。跳转链接是最早、也最有效的可访问性投入之一:
<a class="skip-link" href="#main">跳到主内容</a>
<main id="main" tabindex="-1">
...
</main>.skip-link {
position: absolute;
left: -9999px; /* 移出视口 */
top: 0;
background: #fff;
padding: 0.5rem 1rem;
z-index: 100;
}
.skip-link:focus-visible {
left: 0; /* 聚焦时回到视口 */
}给
main加tabindex="-1",让跳转链接聚焦后能真正把焦点"移"进去,而不仅仅是滚动。
3. 焦点陷阱:弹窗的正确做法
弹窗(modal)必须把焦点"困"在里面,否则 Tab 会跑到背景页面。这是最容易写错、也最影响体验的一处:
// 简易焦点陷阱:弹窗打开时循环焦点,关闭时把焦点还给触发按钮
function trapFocus(dialog: HTMLElement, previous: HTMLElement) {
const focusable =
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
const nodes = Array.from(dialog.querySelectorAll<HTMLElement>(focusable))
.filter((el) => el.offsetParent !== null); // 只留可见元素
const onKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
dialog.addEventListener('keydown', onKeydown);
dialog.focus();
return () => {
dialog.removeEventListener('keydown', onKeydown);
previous.focus(); // 关闭后焦点还给触发按钮
};
}关键点:
- 打开时把焦点移入弹窗(可聚焦容器或首个元素)。
- Tab / Shift+Tab 在首尾元素间循环。
- 关闭后把焦点还给触发弹窗的按钮——否则焦点会"丢"到 body 顶部,用户得重新找路。
4. 焦点顺序:tabindex 的正确用法
- 只用
tabindex="0"让元素可聚焦,不要用正数tabindex="1"这类值去"排序"——它会把焦点顺序从 DOM 顺序里硬拽出来,维护成本极高。 tabindex="-1"用于"可以编程聚焦但不在 Tab 序中"的元素(如跳转目标、弹窗容器)。
5. 可见文字等于无障碍名称
链接、按钮的可访问名称(accessibility name)应来自可见文字。避免"阅读全文"这类只能靠上下文猜的文案:
<!-- 不建议:多个"阅读全文"无法区分 -->
<a href="/a">阅读全文</a>
<a href="/b">阅读全文</a>
<!-- 建议:名称自含上下文 -->
<a href="/a">阅读:如何搭建 CI</a>五、颜色对比度:看得清,是底线
对比度不是"审美问题",而是能不能读出来的问题。弱视、色弱、老年用户,以及任何在强光下看手机的人,都依赖足够的对比度。
WCAG 2.2 的对比度阈值
WCAG 对比度按"相对亮度"计算,公式是 (L1 + 0.05) / (L2 + 0.05),其中 L1 是较亮的颜色。阈值如下:
| 内容类型 | AA | AAA | 说明 |
|---|---|---|---|
| 正文(常规文字) | 4.5:1 | 7:1 | 常规大小文字 |
| 大字(≥24px,或 ≥18.66px 加粗) | 3:1 | 4.5:1 | WCAG 的"大字"定义 |
| UI 组件与图形(焦点环、图标、边框) | 3:1 | — | 非文本对比度 |
实操建议:
- 正文一律按 4.5:1 起步,深色正文配浅色背景是"默认安全"。
- 不要只靠颜色传达信息(例如"红色等于错误"),要配合图标或文字。
- 表单占位符、禁用态文字常常对比度不足,是巡检时最先翻车的地方。
- 用
@media (prefers-contrast: more)提供高对比度变体:
body {
color: #1f2937; /* 常规 */
}
@media (prefers-contrast: more) {
body {
color: #000; /* 高对比度偏好 */
}
}WCAG 2.2 的焦点外观要求
WCAG 2.2 新增了 2.4.13 Focus Appearance (AA):焦点指示器与相邻颜色对比度至少 3:1,且面积不小于"2 CSS 像素粗细的组件周长"。这意味着"淡淡的 1px 灰环"不再达标:
a:focus-visible {
outline: 2px solid #2563eb; /* 3:1 以上的饱和蓝 */
outline-offset: 2px;
}对比度的工程化手段
- 把颜色收敛进设计 token,单一来源,改一处全站生效。
- 在组件测试里断言关键配色的对比度,防止回归(见下文自动化章节)。
- 让对比度检查进入设计评审,而不是等到开发完成后才发现。
六、屏幕阅读器测试:让代码"被听见"
自动化工具抓不到所有问题——**只有用真正的屏幕阅读器读一遍,才知道用户实际听到什么。**最常用的两个组合是 Windows 上的 NVDA(免费开源)与 macOS 的 VoiceOver。
最小验证流程
- 只靠键盘走完核心流程:登录、下单、搜索,全程不碰鼠标。
- 用 VoiceOver(macOS 上按
Cmd+F5开启)读首页:- 按
Cmd+5进"网页"导航模式,用左右箭头浏览。 - 确认朗读顺序等于视觉阅读顺序。
- 确认每个
h1到h6都读成标题、层级正确。 - 确认表单字段有名字,错误提示会被播报。
- 按
- 用 NVDA(Windows 上按
Insert+空格切浏览模式)复测同一流程——浏览器与读屏器的组合不同,听到的也不同,两个都要测。
读屏器测试清单
| 检查项 | 期望结果 |
|---|---|
| 打开页面 | 先读标题(title),再读 h1 |
| 按 H 键 | 在标题间跳转,顺序与层级正确 |
| 按 Tab | 焦点可见、顺序符合视觉 |
| 表单提交失败 | aria-live 区域播报错误 |
| 弹窗打开 | 读屏器聚焦并念出标题,焦点被限制 |
| 图标按钮 | 能读出 aria-label 的无障碍名称 |
一个判断标准:如果闭着眼睛操作不了,说明可访问性还没到位。
七、自动化测试:把"无障碍"写进回归
人工测试永远做不完所有页面。工程化的最后一步,是用工具把可访问性检查变成自动化的、不可跳过的一环。标准工具是 axe-core(Deque 开源的核心引擎),它实现了 WCAG 2.2 规则,并且几乎不存在误报。
1. lint 层:在提交前拦截
在 React 项目里,eslint-plugin-jsx-a11y 能在写代码时就拦住一批低级错误:
{
"plugins": ["jsx-a11y"],
"extends": ["plugin:jsx-a11y/recommended"]
}它能自动发现的问题包括:img 缺 alt、非交互元素绑事件、重复的无障碍属性、ARIA 角色误用等。lint 拦截"最便宜"的那类问题。
2. 组件与单测层:断言关键属性
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
test('按钮提供无障碍名称', () => {
render(<button aria-label="关闭">×</button>);
expect(screen.getByRole('button', { name: '关闭' })).toBeInTheDocument();
});
test('组件通过 axe 自动检查', async () => {
const { container } = render(<MyDialog open={true} />);
expect(await axe(container)).toHaveNoViolations();
});3. E2E 层:真实浏览器加 axe
用 Playwright 配合 @axe-core/playwright,在真实渲染的页面上跑完整规则集:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('核心页面无 WCAG 违规', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('弹窗打开时也检查一次', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: '删除账号' }).click();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});4. CI:让失败阻塞合并
# .github/workflows/a11y.yml
name: Accessibility
on:
pull_request:
push:
branches: [main]
jobs:
axe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:a11y # 内部封装 Playwright + axe要点:
- 把可访问性测试挂在 PR 必须通过的门禁上,和单测、构建同级。
- 关键路径(注册、登录、支付)做全流程的 axe 覆盖,必要时再叠加读屏器人工测试。
- axe 的结果要写入报告并归档,方便追踪整改。
自动化能做什么、不能做什么
| 自动化能做 | 自动化不能做 |
|---|---|
| 检查 alt、对比度、ARIA 语法 | 判断"这个图标读作 X 是否合适" |
| 检查表单标签、焦点可见性 | 评估交互是否反直觉 |
| 覆盖所有可访问的 DOM | 替代真人读屏器测试 |
| 快速回归 | 判断文案是否清晰 |
结论:自动化是底线,不是天花板。 lint 与 axe 保证"不会比昨天更差",人工读屏测试保证"真的可用"。
结语
把 Web 可访问性工程化,本质上是在回答一个问题:你的产品,能不能被所有用户用起来? 这条路并不神秘——语义 HTML 打底,ARIA 补盲区,键盘与焦点管理保证操作路径,对比度保证看得清,读屏器测试保证听得懂,自动化检测保证不回潮。
最后给一张可执行的路线图:
- 先扫一遍:跑一次 axe,把违规清单按严重级别清掉。
- 修地基:补齐语义标签、标题层级、
alt、表单标签。 - 管焦点:做跳转链接、
focus-visible、弹窗焦点陷阱。 - 上工具:接入
jsx-a11ylint 与 Playwright axe,挂进 CI。 - 测真人:用 NVDA / VoiceOver 走一遍核心流程,修复体验问题。
可访问性不是上线前的"加急单",而是与性能、安全同级的一等公民。把它放进工程流程的每一天,而不是最后一天——你的产品会因此对更多人可用,也对未来更健壮。