A11y.md 无障碍上下文系统:从语义化到焦点管理的完整实践
2026/8/27 1:55:43 网站建设 项目流程

在做无障碍(Accessibility)改造时,很多人都有过这样的经历:给按钮加了aria-label,给图片补了alt,自认为已经把能做的都做了,结果屏幕阅读器一读,整个页面还是乱成一团。问题往往不在某个属性上,而是整个页面缺少一套“可理解的上下文”。身边不少同学遇到类似问题后,都会回来问我同一个问题:无障碍到底应该从哪里入手?这篇文章就围绕A11y.md这套上下文系统的设计思路,完整拆解无障碍开发的理念、流程和落地方式。文章会从一个典型组件的实战改造出发,覆盖语义化 HTML、焦点管理、ARIA、实时区域、自动化检测等内容。无论你是刚接触前端无障碍,还是已经做过一轮改造但效果不理想,都可以按这篇文章重新梳理一遍。

1. 背景:为什么无障碍开发总在“还债”

先解释一个词:a11y不是某个新框架的名字,而是英文单词accessibility的常见缩写形式。它的规则是保留首字母a和尾字母y,中间一共 11 个字母,所以写成了a11y。在很多开源项目和工程规范里,你都会看到a11y这个缩写,它代表的就是“无障碍”。

无障碍开发的目标,是让软件对尽可能多的人可用,包括依赖屏幕阅读器、键盘操作、语音控制,或者存在视觉、听觉、运动障碍的用户。很多团队并不是不想做无障碍,而是把它当成“上线前的临时检查项”。等到产品做完了,测试报出一堆问题,再回头补aria属性、调焦点顺序,结果就是不断“还债”。

这种还债式的改造之所以效果差,核心原因是:无障碍不是某个属性的堆叠,而是一种贯穿设计、开发、测试全流程的上下文。举个例子,一个按钮在页面上看起来是“删除”,但屏幕阅读器读出来的内容如果没有足够的上下文,用户根本无法判断点击之后会发生什么。这里缺失的,不是按钮本身,而是按钮所处的操作语境。

A11y.md正是针对这个问题的一种实践思路:用一份 Markdown 格式的上下文文档,把每个组件的无障碍行为、交互规则、边界情况、测试要点集中记录下来。开发者写代码之前先读这份文件,写完之后再对照这份文件自测,让无障碍开发从“凭记忆补属性”变成“基于上下文系统做设计”。

2. 无障碍开发的核心概念:上下文系统到底在说什么

在深入A11y.md之前,先建立三个基础概念。理解了这三个概念,后面看代码才不会迷茫。

2.1 语境(Context)是理解页面的关键

辅助技术(比如屏幕阅读器)和普通浏览器不一样。普通用户通过视觉快速扫视页面结构,一眼就知道左边是导航、中间是内容、弹窗里是确认按钮;但屏幕阅读器是按顺序朗读内容的,用户只能通过语音反馈在大脑中重建页面结构。

如果页面本身没有良好的语义和上下文,用户听到的只是一串碎片:

按钮 按钮 图片 编辑框

这串信息完全无法帮助用户做出判断。反之,如果上下文清晰,用户听到的会是:

对话框,确认删除 正文,删除后数据无法恢复,是否继续? 确认按钮,确认删除 取消按钮,返回

同样的元素,后者因为有了准确的上下文,用户就能理解当前所处的界面状态和可执行的操作。

2.2 语义(Semantics)是无障碍的地基

HTML 本身自带一套语义系统,比如buttonnavmainheadingform。这些标签在辅助技术中有默认的角色(Role)和行为。例如button天然支持键盘 Enter 和空格触发,天然会被读作“按钮”。

无障碍开发的第一原则,就是优先使用语义化标签,而不是用divonclick模拟一切交互。语义化标签不仅代码更简洁,还能免费获得键盘支持、辅助技术识别和行为一致性。

2.3 ARIA 是补充,不是替代

ARIA 全称是 Accessible Rich Internet Applications,它允许开发者通过rolearia-*属性为自定义组件补充无障碍语义。比如:

  • role="dialog"告诉辅助技术这是一个对话框。
  • aria-labelledby指定对话框的标题来自哪个元素。
  • aria-live让动态内容变化时主动播报。

但 ARIA 有一个非常重要的原则:不要让 ARIA 成为语义的替代品。如果能用原生 HTML 实现,就不要用 ARIA。因为 ARIA 只改变辅助技术读到的语义,不会自动改变键盘行为、焦点顺序和视觉表现,这些都需要开发者自己实现。

3. A11y.md:上下文系统的设计思路

A11y.md的定位,是把无障碍相关信息从开发者的脑子里、从零散的 issue 评论里、从测试表格里,统一收敛到一份与组件同级的文档中。它既是一份设计规范,也是一份验收清单。

3.1 为什么选择 Markdown

选择 Markdown 而不是 Confluence 或者在线文档,有几方面考虑:

  • 可以跟随代码仓库一起版本管理,代码改了,文档同步更新。
  • 开发者不用切换工具,在 IDE 里直接阅读。
  • 可以高效地在代码评审中引用具体条目,例如“这里不符合 A11y.md 中的 3.2 条”。
  • 格式简单,机器可解析,后续可以接入自动化检查。

3.2 A11y.md 里应该写什么

一份面向组件的 A11y.md 通常包含六个部分:功能概述、用户故事、键盘交互表、ARIA 使用说明、焦点管理规则、验收清单。以对话框组件为例,它的 A11y.md 可以这样设计:

# ModalDialog(确认对话框) ## 1. 功能概述 用于向用户确认高风险操作,例如删除、覆盖、提交。 包含标题、描述文本、确认按钮、取消按钮。 ## 2. 用户故事 - 作为键盘用户,我打开对话框后焦点应自动移动到对话框内部。 - 作为屏幕阅读器用户,我打开对话框后应听到标题和描述。 - 作为普通用户,我按 Esc 应能关闭对话框并返回触发按钮。 ## 3. 键盘交互表 | 按键 | 行为 | | --- | --- | | Tab | 在对话框内部循环移动焦点 | | Shift + Tab | 反向循环移动焦点 | | Esc | 关闭对话框,焦点返回触发按钮 | ## 4. ARIA 使用说明 - 容器节点使用 role="dialog" 和 aria-modal="true"。 - 标题通过 aria-labelledby 关联。 - 描述文本通过 aria-describedby 关联。 ## 5. 焦点管理规则 - 打开时:焦点移动到对话框内第一个可聚焦元素。 - 关闭时:焦点返回打开对话框的按钮。 - 打开期间:焦点不能移出对话框。 ## 6. 验收清单 - [ ] 全部键盘操作可完成 - [ ] 屏幕阅读器可读出标题和描述 - [ ] 焦点不会逃逸到背景内容 - [ ] 关闭后焦点正确归还

这份文件看起来简单,但它把“对话框应该怎么表现”这个模糊问题,变成了程序员可以逐条执行的规格说明。这就是上下文系统的意义所在。

3.3 上下文系统如何融入开发流程

建议把 A11y.md 放在组件目录下,与组件代码同级。比如:

src/ components/ ModalDialog/ ModalDialog.tsx ModalDialog.css A11y.md

开发新组件时,先写 A11y.md,再写代码;代码评审时,评审人对照 A11y.md 逐条检查;测试阶段,QA 直接拿 A11y.md 的验收清单作为手工测试用例。这样无障碍就不再是事后补救,而是整个开发流程中的一环。

4. 环境准备与检测工具

在动手写代码之前,先把环境准备好。搭建完整的无障碍开发环境,主要包含三类工具:浏览器扩展类、命令行类、辅助技术类。

4.1 自动化检测工具

自动化检测工具可以在不打开屏幕阅读器的情况下,快速发现明显的无障碍问题。常用工具包括:

工具类型主要作用
axe DevTools浏览器扩展扫描页面中的无障碍违规项,给出修复建议
LighthouseChrome 内置对页面进行无障碍评分与问题列表
WAVE浏览器扩展/网页可视化展示页面结构和 ARIA 问题
eslint-plugin-jsx-a11yESLint 插件在编码阶段拦截 JSX 中的无障碍问题
axe-corenpm 包可集成到自动化测试和 CI 流程中

需要注意的是,自动化检测只能覆盖大约 30% 到 50% 的无障碍问题。它擅长发现缺失的alt、对比度不足、重复的id这类规则明确的问题,但无法判断焦点顺序是否合理、屏幕阅读器播报是否符合预期。所以自动化检测不能替代手工测试。

4.2 辅助技术工具

辅助技术工具用于真实体验播报效果。常用的有:

  • Windows 平台:NVDA(免费)、JAWS(商业)。
  • macOS 平台:VoiceOver(系统内置)。
  • 移动端:iOS 的 VoiceOver 和 Android 的 TalkBack。

对于初学者,建议先在 macOS 上体验 VoiceOver,或者 Windows 上体验 NVDA。不需要一开始就用很复杂的快捷键,只要能打开页面、听一段播报、尝试 Tab 键走一遍页面,就能对无障碍现状有直观感受。

4.3 本文示例环境

版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路:

  • 操作系统:Windows 10 / macOS 均可。
  • 浏览器:Chrome 最新稳定版。
  • 工具:Chrome DevTools、axe DevTools、VoiceOver 或 NVDA。
  • 前端基础:原生 HTML / CSS / JavaScript。

示例项目不依赖框架,因此你可以在任意静态页面中直接运行。

5. 完整实战:用 A11y.md 驱动对话框组件改造

下面通过一个完整的“确认删除对话框”示例,演示 A11y.md 如何指导实际开发。先运行一个存在无障碍问题的版本,再对照 A11y.md 逐步修复。

5.1 创建项目结构

先创建示例项目目录:

a11y-mock/ index.html app.js style.css components/ ModalDialog/ A11y.md

其中index.html是页面入口,app.js处理交互逻辑,style.css负责样式,components/ModalDialog/A11y.md是对话框组件的无障碍上下文文档。

5.2 编写存在问题的初始版本

很多团队的第一版对话框是这样写的:用div模拟整个弹窗,点击阴影区域关闭,但没有任何语义。来看index.html

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>无障碍对话框示例</title> <link rel="stylesheet" href="style.css"> </head> <body> <main> <h1>文件管理</h1> <button id="deleteBtn">删除当前文件</button> </main> <!-- 初始版本:没有 role,没有 aria 关联 --> <div id="modal" class="modal" hidden> <div class="modal-box"> <div class="modal-title">确认删除</div> <div class="modal-desc">删除后数据无法恢复,是否继续?</div> <button id="confirmBtn">确认</button> <button id="cancelBtn">取消</button> </div> </div> <script src="app.js"></script> </body> </html>

对应的app.js

const deleteBtn = document.getElementById('deleteBtn'); const modal = document.getElementById('modal'); const confirmBtn = document.getElementById('confirmBtn'); const cancelBtn = document.getElementById('cancelBtn'); deleteBtn.addEventListener('click', () => { modal.hidden = false; }); cancelBtn.addEventListener('click', () => { modal.hidden = true; }); confirmBtn.addEventListener('click', () => { modal.hidden = true; alert('文件已删除'); }); modal.addEventListener('click', (event) => { // 点击遮罩层关闭 if (event.target === modal) { modal.hidden = true; } });

这段代码在视觉上“能用”,但屏幕阅读器用户会面临这些问题:

  • 打开对话框后,焦点仍然停留在“删除当前文件”按钮上,用户不知道出现了新内容。
  • 对话框里的标题和描述没有语义,读出来只是普通的文本。
  • div不是可聚焦元素,也没有role="dialog",辅助技术无法感知对话框的边界。
  • 无法通过 Esc 关闭。
  • 关闭后焦点没有归还到触发按钮。

这些问题的根源,正是缺少上下文系统。接下来我们按A11y.md的规格逐条修复。

5.3 编写对话框的 A11y.md

在修复代码之前,先为这个组件补齐上下文文档。这就是前面提到的那份components/ModalDialog/A11y.md。它充当开发过程的“契约”:代码必须满足文档中的所有条目。

5.4 按 A11y.md 修复 HTML 结构

修改后的index.html

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>无障碍对话框示例</title> <link rel="stylesheet" href="style.css"> </head> <body> <main> <h1>文件管理</h1> <button id="deleteBtn" aria-haspopup="dialog">删除当前文件</button> </main> <!-- 修复版本:补充 role、aria-modal、aria-labelledby、aria-describedby --> <div id="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-describedby="modalDesc" hidden> <div class="modal-box"> <h2 id="modalTitle" class="modal-title">确认删除</h2> <p id="modalDesc" class="modal-desc">删除后数据无法恢复,是否继续?</p> <button id="confirmBtn" class="btn-primary">确认</button> <button id="cancelBtn" class="btn-secondary">取消</button> </div> </div> <script src="app.js"></script> </body> </html>

这里有几个关键改动:

  • role="dialog"告诉辅助技术这是一个对话框。
  • aria-modal="true"表示背景内容处于不可交互状态。
  • aria-labelledby="modalTitle"将对话框标题指向h2,屏幕阅读器会先读出标题。
  • aria-describedby="modalDesc"将描述文本关联到p,更详细地说明对话框目的。
  • 触发按钮增加aria-haspopup="dialog",提示用户点击后会打开一个对话框。

5.5 重写交互逻辑

接下来重写app.js,重点解决焦点管理和键盘操作。

const deleteBtn = document.getElementById('deleteBtn'); const modal = document.getElementById('modal'); const confirmBtn = document.getElementById('confirmBtn'); const cancelBtn = document.getElementById('cancelBtn'); // 记录打开之前的焦点元素 let lastFocusedElement = null; function openModal() { lastFocusedElement = document.activeElement; modal.hidden = false; // 打开时,将焦点移动到对话框内部 confirmBtn.focus(); } function closeModal() { modal.hidden = true; // 关闭时,将焦点归还给触发按钮 if (lastFocusedElement) { lastFocusedElement.focus(); } } deleteBtn.addEventListener('click', openModal); cancelBtn.addEventListener('click', closeModal); confirmBtn.addEventListener('click', () => { closeModal(); alert('文件已删除'); }); // 点击遮罩层关闭 modal.addEventListener('click', (event) => { if (event.target === modal) { closeModal(); } }); // Esc 关闭 document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !modal.hidden) { closeModal(); } }); // 焦点约束:焦点不能移出对话框 modal.addEventListener('keydown', (event) => { if (event.key !== 'Tab') { return; } const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } else if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } });

这段代码对应A11y.md中的键盘交互表和焦点管理规则:

  • 打开对话框时,confirmBtn.focus()确保焦点移入对话框。
  • 关闭对话框时,lastFocusedElement.focus()归还焦点。
  • Escape键关闭对话框。
  • Tab 循环逻辑保证焦点不会逃逸到背景内容。

需要注意的是,完整版的焦点陷阱还要考虑对话框自身滚动的情况,这里是一个最小示例,重点展示思路。

5.6 添加样式与运行验证

样式文件style.css负责基本视觉表现和焦点可见性:

.modal { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; } .modal[hidden] { display: none; } .modal-box { background: #fff; padding: 24px; border-radius: 8px; width: 360px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); } .btn-primary:focus-visible, .btn-secondary:focus-visible { outline: 2px solid #2d6cdf; outline-offset: 2px; }

运行方式很简单:直接用浏览器打开index.html即可。在 Chrome 中打开页面后,可以执行以下验证步骤:

  1. 点击“删除当前文件”,确认焦点落在“确认”按钮上。
  2. 连续按 Tab,观察焦点是否在对话框内部循环。
  3. 按 Esc,确认对话框关闭且焦点回到“删除当前文件”按钮。
  4. 用键盘全程操作:Tab、Enter、Esc 都应该正常工作。

如果安装了 axe DevTools,可以打开扩展扫描页面,应该不会再报告对话框相关的严重违规项。

5.7 结果说明

修复前后的体验差异非常明显。修复前,屏幕阅读器用户打开对话框后,根本不知道页面上出现了新内容;修复后,对话框打开时会播报“确认删除对话框”以及描述“删除后数据无法恢复,是否继续?”。键盘用户也能依靠 Tab 和 Esc 高效操作。

更重要的是,所有修复点都能在A11y.md中找到对应条目。上下文系统让无障碍改造从“猜测”变成了“按规格执行”。

6. 常见问题与排查思路

无障碍开发中,很多问题反复出现。下面把高频问题整理成一张排查表,再展开讲两个最容易被忽略的细节。

问题现象常见原因解决思路
屏幕阅读器不读动态内容没有使用aria-live或对应的语义容器为动态区域添加aria-live="polite"
Tab 焦点顺序混乱DOM 顺序与视觉顺序不一致调整 DOM 顺序,避免滥用tabindex
图片读不出信息缺少alt,或alt写了文件名根据图片内容编写有效alt
表单校验没提示错误信息没有关联到输入框使用aria-describedby关联错误文本
对比度不足前景色与背景色差异过小调整颜色,使对比度达到 WCAG AA 标准
自定义组件无键盘支持div模拟交互控件改用原生标签,或补齐键盘事件

6.1 不要滥用 aria-live

aria-live是让动态区域自动播报的属性,但很多人把它理解为“加了就播报”,结果页面一加载,所有内容都争先恐后地读出来。实际上aria-live有三个关键原则:

  • 默认值应该是polite,表示辅助技术在完成当前任务后再播报。
  • 只在内容确实会变化且用户需要知道变化时使用。
  • 不要对高频变化区域使用assertive,它会打断用户当前操作。

拿上面的对话框为例,其实不需要额外的aria-live,因为焦点管理已经迫使屏幕阅读器读出了对话框内容。

6.2 tabindex 的正确用法

tabindex有三个值需要区分:

  • tabindex="0":元素可以聚焦,并按 DOM 顺序进入 Tab 序列。
  • tabindex="-1":元素可以编程聚焦(比如调用focus()),但不进入 Tab 序列。
  • tabindex="1"(或其他正数):手动指定 Tab 顺序,通常不推荐使用。

在实际开发中,几乎不需要正数tabindex。如果发现 Tab 顺序混乱,优先检查 DOM 顺序,而不是用正数tabindex“纠正”。

6.3 完整排查清单

遇到无障碍问题可以按以下顺序排查:

  1. 确认是否使用了语义化标签。能用button就不要用div
  2. 确认焦点顺序。用键盘从头到尾 Tab 一遍,观察顺序是否符合视觉顺序。
  3. 确认焦点是否可见。检查:focus:focus-visible样式是否被误删。
  4. 确认动态内容播报。新增内容是否能让辅助技术感知。
  5. 运行 axe DevTools 扫描,处理所有严重级和中级问题。
  6. 用屏幕阅读器真实走一遍核心流程。

7. 工程化落地与最佳实践

A11y.md的思路落地到真实团队中,还需要注意以下几个方面。

7.1 文档先行,代码后写

建议在组件设计阶段就产出A11y.md。理由很简单:代码评审时,关注点应该是“实现是否符合文档”,而不是“文档是否跟得上代码”。如果先写代码再补文档,文档大概率会缺失关键细节。

在评审时,可以把A11y.md作为评审清单:

  • 键盘交互是否完整覆盖所有用户路径?
  • 焦点管理是否包含打开、关闭、循环三种场景?
  • ARIA 属性是否有对应的可见文本?
  • 是否过度使用了 ARIA?

7.2 将自动化检查接入 CI

手动检查容易遗漏,建议把无障碍检查接入持续集成(CI)。最基础的方案是使用axe-core配合测试框架,在每次构建时自动扫描页面。

// 以 jest + axe-core 为例 import { axe } from 'jest-axe'; expect( await axe(document.body) ).toHaveNoViolations();

这个片段是核心思路,实际使用需要根据你的测试框架调整。自动化检查能够拦截大部分低级问题,但无法替代屏幕阅读器手工测试。所以建议把“核心用户流程屏幕阅读器走查”作为发版前的固定步骤。

7.3 不为通过检测而堆 ARIA

有一个容易走偏的做法:为了通过 axe 检测,给所有元素补rolearia-label。这反而可能制造更多问题。比如给一个可点击的divrole="button",检测确实能通过,但键盘用户依然无法用空格和 Enter 触发它,因为你没有实现键盘事件。

更好的做法是反过来:优先使用原生元素,只有在确实无法用原生元素实现时才引入 ARIA。ARIA 的使用应当始终服务于上下文清晰,而不是服务于检测分数。

7.4 定期做真实走查

A11y.md不是写一次就结束的。组件交互发生变化时,文档也要同步更新。建议每个迭代至少做一次“无障碍走查”,不需要覆盖全部页面,优先覆盖高频核心流程,例如:登录、搜索、提交表单、删除确认这类操作。

8. 总结

回到开头的问题:无障碍开发为什么总在“还债”?因为大多数团队缺乏一个把无障碍约束落到开发流程里的上下文系统。A11y.md提供了一种低成本、见效快的实践方式:用 Markdown 文档定义组件的无障碍行为,让开发、评审、测试都有据可依。

这篇文章从基础概念讲起,梳理了语义化 HTML、ARIA、焦点管理、实时区域等核心知识点;再通过一个对话框组件的完整实战,演示了如何从一份A11y.md出发,逐步修复真实的无障碍问题;最后给出了自动化检测与工程落地建议。

下一步,你可以做三件事:第一,把所有自定义组件补上A11y.md;第二,给项目接入 axe-core 自动化检查;第三,抽一个下午,用 NVDA 或 VoiceOver 完整走一遍自己负责的页面。只有亲自体验过一次屏幕阅读器的播报,你才会真正理解上下文系统在无障碍开发中的分量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询