
在做无障碍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 本身自带一套语义系统比如button、nav、main、heading、form。这些标签在辅助技术中有默认的角色Role和行为。例如button天然支持键盘 Enter 和空格触发天然会被读作“按钮”。无障碍开发的第一原则就是优先使用语义化标签而不是用div加onclick模拟一切交互。语义化标签不仅代码更简洁还能免费获得键盘支持、辅助技术识别和行为一致性。2.3 ARIA 是补充不是替代ARIA 全称是 Accessible Rich Internet Applications它允许开发者通过role、aria-*属性为自定义组件补充无障碍语义。比如roledialog告诉辅助技术这是一个对话框。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 使用说明 - 容器节点使用 roledialog 和 aria-modaltrue。 - 标题通过 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 langzh-CN head meta charsetUTF-8 title无障碍对话框示例/title link relstylesheet hrefstyle.css /head body main h1文件管理/h1 button iddeleteBtn删除当前文件/button /main !-- 初始版本没有 role没有 aria 关联 -- div idmodal classmodal hidden div classmodal-box div classmodal-title确认删除/div div classmodal-desc删除后数据无法恢复是否继续/div button idconfirmBtn确认/button button idcancelBtn取消/button /div /div script srcapp.js/script /body /html对应的app.jsconst 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不是可聚焦元素也没有roledialog辅助技术无法感知对话框的边界。无法通过 Esc 关闭。关闭后焦点没有归还到触发按钮。这些问题的根源正是缺少上下文系统。接下来我们按A11y.md的规格逐条修复。5.3 编写对话框的 A11y.md在修复代码之前先为这个组件补齐上下文文档。这就是前面提到的那份components/ModalDialog/A11y.md。它充当开发过程的“契约”代码必须满足文档中的所有条目。5.4 按 A11y.md 修复 HTML 结构修改后的index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 title无障碍对话框示例/title link relstylesheet hrefstyle.css /head body main h1文件管理/h1 button iddeleteBtn aria-haspopupdialog删除当前文件/button /main !-- 修复版本补充 role、aria-modal、aria-labelledby、aria-describedby -- div idmodal roledialog aria-modaltrue aria-labelledbymodalTitle aria-describedbymodalDesc hidden div classmodal-box h2 idmodalTitle classmodal-title确认删除/h2 p idmodalDesc classmodal-desc删除后数据无法恢复是否继续/p button idconfirmBtn classbtn-primary确认/button button idcancelBtn classbtn-secondary取消/button /div /div script srcapp.js/script /body /html这里有几个关键改动roledialog告诉辅助技术这是一个对话框。aria-modaltrue表示背景内容处于不可交互状态。aria-labelledbymodalTitle将对话框标题指向h2屏幕阅读器会先读出标题。aria-describedbymodalDesc将描述文本关联到p更详细地说明对话框目的。触发按钮增加aria-haspopupdialog提示用户点击后会打开一个对话框。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 中打开页面后可以执行以下验证步骤点击“删除当前文件”确认焦点落在“确认”按钮上。连续按 Tab观察焦点是否在对话框内部循环。按 Esc确认对话框关闭且焦点回到“删除当前文件”按钮。用键盘全程操作Tab、Enter、Esc 都应该正常工作。如果安装了 axe DevTools可以打开扩展扫描页面应该不会再报告对话框相关的严重违规项。5.7 结果说明修复前后的体验差异非常明显。修复前屏幕阅读器用户打开对话框后根本不知道页面上出现了新内容修复后对话框打开时会播报“确认删除对话框”以及描述“删除后数据无法恢复是否继续”。键盘用户也能依靠 Tab 和 Esc 高效操作。更重要的是所有修复点都能在A11y.md中找到对应条目。上下文系统让无障碍改造从“猜测”变成了“按规格执行”。6. 常见问题与排查思路无障碍开发中很多问题反复出现。下面把高频问题整理成一张排查表再展开讲两个最容易被忽略的细节。问题现象常见原因解决思路屏幕阅读器不读动态内容没有使用aria-live或对应的语义容器为动态区域添加aria-livepoliteTab 焦点顺序混乱DOM 顺序与视觉顺序不一致调整 DOM 顺序避免滥用tabindex图片读不出信息缺少alt或alt写了文件名根据图片内容编写有效alt表单校验没提示错误信息没有关联到输入框使用aria-describedby关联错误文本对比度不足前景色与背景色差异过小调整颜色使对比度达到 WCAG AA 标准自定义组件无键盘支持用div模拟交互控件改用原生标签或补齐键盘事件6.1 不要滥用 aria-livearia-live是让动态区域自动播报的属性但很多人把它理解为“加了就播报”结果页面一加载所有内容都争先恐后地读出来。实际上aria-live有三个关键原则默认值应该是polite表示辅助技术在完成当前任务后再播报。只在内容确实会变化且用户需要知道变化时使用。不要对高频变化区域使用assertive它会打断用户当前操作。拿上面的对话框为例其实不需要额外的aria-live因为焦点管理已经迫使屏幕阅读器读出了对话框内容。6.2 tabindex 的正确用法tabindex有三个值需要区分tabindex0元素可以聚焦并按 DOM 顺序进入 Tab 序列。tabindex-1元素可以编程聚焦比如调用focus()但不进入 Tab 序列。tabindex1或其他正数手动指定 Tab 顺序通常不推荐使用。在实际开发中几乎不需要正数tabindex。如果发现 Tab 顺序混乱优先检查 DOM 顺序而不是用正数tabindex“纠正”。6.3 完整排查清单遇到无障碍问题可以按以下顺序排查确认是否使用了语义化标签。能用button就不要用div。确认焦点顺序。用键盘从头到尾 Tab 一遍观察顺序是否符合视觉顺序。确认焦点是否可见。检查:focus或:focus-visible样式是否被误删。确认动态内容播报。新增内容是否能让辅助技术感知。运行 axe DevTools 扫描处理所有严重级和中级问题。用屏幕阅读器真实走一遍核心流程。7. 工程化落地与最佳实践把A11y.md的思路落地到真实团队中还需要注意以下几个方面。7.1 文档先行代码后写建议在组件设计阶段就产出A11y.md。理由很简单代码评审时关注点应该是“实现是否符合文档”而不是“文档是否跟得上代码”。如果先写代码再补文档文档大概率会缺失关键细节。在评审时可以把A11y.md作为评审清单键盘交互是否完整覆盖所有用户路径焦点管理是否包含打开、关闭、循环三种场景ARIA 属性是否有对应的可见文本是否过度使用了 ARIA7.2 将自动化检查接入 CI手动检查容易遗漏建议把无障碍检查接入持续集成CI。最基础的方案是使用axe-core配合测试框架在每次构建时自动扫描页面。// 以 jest axe-core 为例 import { axe } from jest-axe; expect( await axe(document.body) ).toHaveNoViolations();这个片段是核心思路实际使用需要根据你的测试框架调整。自动化检查能够拦截大部分低级问题但无法替代屏幕阅读器手工测试。所以建议把“核心用户流程屏幕阅读器走查”作为发版前的固定步骤。7.3 不为通过检测而堆 ARIA有一个容易走偏的做法为了通过 axe 检测给所有元素补role和aria-label。这反而可能制造更多问题。比如给一个可点击的div加rolebutton检测确实能通过但键盘用户依然无法用空格和 Enter 触发它因为你没有实现键盘事件。更好的做法是反过来优先使用原生元素只有在确实无法用原生元素实现时才引入 ARIA。ARIA 的使用应当始终服务于上下文清晰而不是服务于检测分数。7.4 定期做真实走查A11y.md不是写一次就结束的。组件交互发生变化时文档也要同步更新。建议每个迭代至少做一次“无障碍走查”不需要覆盖全部页面优先覆盖高频核心流程例如登录、搜索、提交表单、删除确认这类操作。8. 总结回到开头的问题无障碍开发为什么总在“还债”因为大多数团队缺乏一个把无障碍约束落到开发流程里的上下文系统。A11y.md提供了一种低成本、见效快的实践方式用 Markdown 文档定义组件的无障碍行为让开发、评审、测试都有据可依。这篇文章从基础概念讲起梳理了语义化 HTML、ARIA、焦点管理、实时区域等核心知识点再通过一个对话框组件的完整实战演示了如何从一份A11y.md出发逐步修复真实的无障碍问题最后给出了自动化检测与工程落地建议。下一步你可以做三件事第一把所有自定义组件补上A11y.md第二给项目接入 axe-core 自动化检查第三抽一个下午用 NVDA 或 VoiceOver 完整走一遍自己负责的页面。只有亲自体验过一次屏幕阅读器的播报你才会真正理解上下文系统在无障碍开发中的分量。