引言

打开任何一个稍大一点的网站,用键盘按两下 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

html
<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>

标题层级:可访问性的"目录"

标题层级是文档的"目录"。规则只有一条:不要跳级。

html
<h1>Web 可访问性工程化</h1>
<h2>语义化 HTML</h2>
<h3>页面骨架</h3>
<h3>标题层级</h3>
<h2>ARIA</h2>

常见错误:

  1. 出于样式目的使用 h3 而不管逻辑层级。
  2. 页面上出现多个 h1(除非每个部分是独立入口的模块,否则尽量避免)。
  3. 用 font-size 加粗"伪造"标题,而没有真正的 h1 到 h6。

视觉样式与语义结构应当分离:不要为了让字变大而选错标题级别,也不要为了样式硬塞一个不匹配的 hN。

交互元素:能原生就别造轮子

html
<!-- 错误: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 一起使用)

一个真实的弹窗例子

html
<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 声明这块区域的"活跃程度":

html
<div aria-live="polite">
  <!-- 内容被 JS 替换后,读屏器会温和地播报 -->
</div>

常见 ARIA 误用清单

  1. 给 div 加 role="button" 却忘了加 tabindex="0" 与键盘事件——白加了,还可能更糟。
  2. aria-label 覆盖了可见文字,读出来的和看到的不一致,造成困惑。
  3. 重复语义:button 上又加 role="button",纯属冗余。
  4. 滥用 aria-live="assertive",把整个页面变成"抢话"现场。
  5. 只管加 aria-expanded,不管焦点与视觉状态同步。

ARIA 的黄金法则:优先原生,其次 ARIA;加一个 ARIA 属性,就要保证它反映真实的交互状态。


四、键盘导航与焦点管理

键盘是很多用户唯一的输入方式。可访问性的核心体验,往往就是按 Tab 能不能顺畅走完整个流程。

1. focus-visible:只在键盘导航时显示焦点

默认的焦点轮廓被很多人用 outline: none 粗暴移除,这是最常见、也最危险的反模式。正确做法是区分鼠标点击与键盘导航:

css
/* 移除按钮的默认轮廓,但仅限鼠标场景 */
button:focus:not(:focus-visible) {
  outline: none;
}

/* 键盘导航时保留清晰、可自定义的焦点环 */
button:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

:focus-visible 是 CSS 标准(现代浏览器均支持),它在键盘导航时显示焦点、鼠标点击时不显示,兼顾了美观与可用性。

2. 跳过导航链接

让键盘用户不用按几十下 Tab 才能到正文。跳转链接是最早、也最有效的可访问性投入之一:

html
<a class="skip-link" href="#main">跳到主内容</a>

<main id="main" tabindex="-1">
  ...
</main>
css
.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 会跑到背景页面。这是最容易写错、也最影响体验的一处:

typescript
// 简易焦点陷阱:弹窗打开时循环焦点,关闭时把焦点还给触发按钮
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(); // 关闭后焦点还给触发按钮
  };
}

关键点:

4. 焦点顺序:tabindex 的正确用法

5. 可见文字等于无障碍名称

链接、按钮的可访问名称(accessibility name)应来自可见文字。避免"阅读全文"这类只能靠上下文猜的文案:

html
<!-- 不建议:多个"阅读全文"无法区分 -->
<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 — 非文本对比度

实操建议:

  1. 正文一律按 4.5:1 起步,深色正文配浅色背景是"默认安全"。
  2. 不要只靠颜色传达信息(例如"红色等于错误"),要配合图标或文字。
  3. 表单占位符、禁用态文字常常对比度不足,是巡检时最先翻车的地方。
  4. 用 @media (prefers-contrast: more) 提供高对比度变体:
css
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 灰环"不再达标:

css
a:focus-visible {
  outline: 2px solid #2563eb; /* 3:1 以上的饱和蓝 */
  outline-offset: 2px;
}

对比度的工程化手段

  1. 把颜色收敛进设计 token,单一来源,改一处全站生效。
  2. 在组件测试里断言关键配色的对比度,防止回归(见下文自动化章节)。
  3. 让对比度检查进入设计评审,而不是等到开发完成后才发现。

六、屏幕阅读器测试:让代码"被听见"

自动化工具抓不到所有问题——**只有用真正的屏幕阅读器读一遍,才知道用户实际听到什么。**最常用的两个组合是 Windows 上的 NVDA(免费开源)与 macOS 的 VoiceOver。

最小验证流程

  1. 只靠键盘走完核心流程:登录、下单、搜索,全程不碰鼠标。
  2. 用 VoiceOver(macOS 上按 Cmd+F5 开启)读首页:
    • 按 Cmd+5 进"网页"导航模式,用左右箭头浏览。
    • 确认朗读顺序等于视觉阅读顺序。
    • 确认每个 h1 到 h6 都读成标题、层级正确。
    • 确认表单字段有名字,错误提示会被播报。
  3. 用 NVDA(Windows 上按 Insert+空格 切浏览模式)复测同一流程——浏览器与读屏器的组合不同,听到的也不同,两个都要测。

读屏器测试清单

检查项 期望结果
打开页面 先读标题(title),再读 h1
按 H 键 在标题间跳转,顺序与层级正确
按 Tab 焦点可见、顺序符合视觉
表单提交失败 aria-live 区域播报错误
弹窗打开 读屏器聚焦并念出标题,焦点被限制
图标按钮 能读出 aria-label 的无障碍名称

一个判断标准:如果闭着眼睛操作不了,说明可访问性还没到位。


七、自动化测试:把"无障碍"写进回归

人工测试永远做不完所有页面。工程化的最后一步,是用工具把可访问性检查变成自动化的、不可跳过的一环。标准工具是 axe-core(Deque 开源的核心引擎),它实现了 WCAG 2.2 规则,并且几乎不存在误报。

1. lint 层:在提交前拦截

在 React 项目里,eslint-plugin-jsx-a11y 能在写代码时就拦住一批低级错误:

json
{
  "plugins": ["jsx-a11y"],
  "extends": ["plugin:jsx-a11y/recommended"]
}

它能自动发现的问题包括:img 缺 alt、非交互元素绑事件、重复的无障碍属性、ARIA 角色误用等。lint 拦截"最便宜"的那类问题。

2. 组件与单测层:断言关键属性

typescript
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,在真实渲染的页面上跑完整规则集:

typescript
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:让失败阻塞合并

yaml
# .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

要点:

自动化能做什么、不能做什么

自动化能做 自动化不能做
检查 alt、对比度、ARIA 语法 判断"这个图标读作 X 是否合适"
检查表单标签、焦点可见性 评估交互是否反直觉
覆盖所有可访问的 DOM 替代真人读屏器测试
快速回归 判断文案是否清晰

结论:自动化是底线,不是天花板。 lint 与 axe 保证"不会比昨天更差",人工读屏测试保证"真的可用"。


结语

把 Web 可访问性工程化,本质上是在回答一个问题:你的产品,能不能被所有用户用起来? 这条路并不神秘——语义 HTML 打底,ARIA 补盲区,键盘与焦点管理保证操作路径,对比度保证看得清,读屏器测试保证听得懂,自动化检测保证不回潮。

最后给一张可执行的路线图:

  1. 先扫一遍:跑一次 axe,把违规清单按严重级别清掉。
  2. 修地基:补齐语义标签、标题层级、alt、表单标签。
  3. 管焦点:做跳转链接、focus-visible、弹窗焦点陷阱。
  4. 上工具:接入 jsx-a11y lint 与 Playwright axe,挂进 CI。
  5. 测真人:用 NVDA / VoiceOver 走一遍核心流程,修复体验问题。

可访问性不是上线前的"加急单",而是与性能、安全同级的一等公民。把它放进工程流程的每一天,而不是最后一天——你的产品会因此对更多人可用,也对未来更健壮。