前端无障碍以语义化HTML为地基,配合ARIA、键盘导航、对比度与屏幕阅读器测试,让残障及各类受限情境下的用户都能顺畅使用网页。
前端无障碍(Accessibility)指南:ARIA、键盘导航与对比度那些事
前端无障碍(Accessibility)指南:ARIA、键盘导航与对比度那些事
先讲一句扎心的话:你花两周打磨的精致页面,对一群用户来说可能等于一堵墙。这群人里,有视力表最后三行都看不清的视障者,有手腕受伤只能靠键盘操作的人,有在刺眼阳光下勉强眯着眼看手机的路人,也有单手抱娃腾不出另一只手的普通用户。无障碍(Accessibility,常缩写成 a11y)不是锦上添花的慈善项,它决定你的产品到底能不能被所有人用。这篇文章不堆定义,从"机器怎么读你的页面"这条主线出发,把语义化标签、ARIA、键盘导航、对比度、alt 文本和屏幕阅读器测试一条线讲透。如果你正准备过一轮无障碍合规,或者单纯想让自己的代码对人和机器都更友好,这篇能帮你少绕弯路。
先抛核心结论:无障碍的地基永远是语义化的原生 HTML,ARIA 只是给动态组件打的补丁,键盘可达和对比度是硬门槛,而最终的验收必须上机用屏幕阅读器走一遍。下面展开讲,每一节都配可抄的代码。
一、先想清楚:无障碍到底在帮谁
很多人把无障碍想成"为盲人做网站",这个理解太窄了。真实世界里需要无障碍的人分好几类,而且彼此重叠。
第一类是永久性的身体障碍。视障用户可能完全看不见(靠屏幕阅读器把页面读出来),也可能视力很差需要高对比度;听障用户看不了视频里的声音,需要字幕;运动障碍用户没法用鼠标,只能靠键盘、开关设备甚至眼动仪操作页面。
第二类是暂时性的状况。你手腕崴了打不了字、耳朵里塞着耳机听不清提示音、手机摔了触屏失灵,这些"临时残障"每个人都经历过,而无障碍设计正好能兜底。
第三类是"环境受限"。大太阳底下屏幕反光看不清、地铁里单手刷手机、开会时把声音关掉只敢看画面——这些情境下,高对比度的文字、能纯键盘操作的流程、清晰的焦点指示,全都能救命。
所以无障碍从来不是小众需求,它是"让产品在更多条件下可用"的通用设计。国际上衡量无障碍有一套叫 WCAG 的规范,它把所有要求归成四个容易记的字母:可感知(Perceivable)、可操作(Operable)、可理解(Understandable)、健壮(Robust),简称 POUR。后面几节其实就是在分别落实这四个字——对比度对应"可感知",键盘导航对应"可操作",清晰的报错对应"可理解",语义化和 ARIA 对应"健壮"。理解了这层关系,你就不用死记硬背一堆零散规则了。
顺带说一句现实压力:在欧盟、美国等地,无障碍已经从"建议你做"变成"法律要求你做",相关的诉讼和罚款是真金白银的。即便不考虑合规,一个键盘走不通、读屏读不明白的页面,损失的也是实打实的用户和口碑。把它当成需求而不是负担,心态会轻松很多。
二、地基:语义化 HTML 才是无障碍的第一道关
这是整篇文章最重要的一节,因为后面所有高级技巧都建立在它之上。一句话:能用原生语义标签解决的问题,永远优先于用 ARIA 硬凑。
浏览器原生提供的那些标签——header、nav、main、article、button、a、input——之所以珍贵,是因为它们不是"长得像某个角色",而是"天生就是那个角色"。一个原生<button>,浏览器自动给它三样东西:正确的角色(读屏会说"按钮")、默认的键盘支持(Tab 能聚焦,回车空格能触发)、以及一套焦点样式。你什么都不用写,白拿。
反过来,如果你用<div class="btn" onclick="..."> 来模拟按钮,读屏会把它当成一个普通的没有意义的盒子,键盘也走不到它,用户根本不知道这是个能点的东西。这种"div 裸奔"是新人最容易犯的错,也是无障碍审计里最常被点名的问题。
下面是一份能直接跑的语义化骨架,注意看每个标签承担的"角色":
html<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <!-- lang 声明语言,屏幕阅读器据此选发音引擎,中文页面务必写 zh-CN --> <title>无障碍示例页</title></head><body> <!-- header:页面级页头,放 logo 与站点信息,全站一般只出现一次 --> <header> <h1>站点标题</h1> <!-- nav:成组的主要导航,多 nav 时用 aria-label 区分 --> <nav aria-label="主导航"> <ul> <li><a href="/">首页</a></li> <li><a href="/about">关于</a></li> </ul> </nav> </header> <!-- main:全页唯一主内容区,读屏用户一键"跳到主内容"就落在这里 --> <main> <article> <h2>一篇独立可分发的内容</h2> <p>正文段落……</p> </article> </main> <!-- footer:版权、备案等收尾信息 --> <footer> <p>© 2024 示例站点</p> </footer></body></html>

把这段结构在浏览器里打开,你会发现去掉样式它仍然是一份"有条理的文档"——这正是语义化的价值:它不靠视觉,靠标签本身描述角色。读屏软件读到nav会念"导航",读到main知道这是主区,读到button 知道能按。这套信息是你用 div 怎么堆都堆不出来的。
这里有个关键点要反复强调:语义标签的默认样式和 div 几乎一样,都是普通块级元素,没有任何自带布局。所以千万别指望换了标签页面就自动变高级,视觉该写 CSS 还是得写。我们做布局分区时,可以配合 CSS Grid 网格布局完全指南:从网格线到 auto-fit 响应式里讲的区域划分思路,把header、nav、main、aside、footer摆成清晰的版面,既好看又对机器友好。
还有两个原生交互元素必须单独拎出来说。第一是<button>,凡是"点了会触发动作"的东西都该用它,而不是 div 或 span,因为它自带键盘和读屏支持。第二是 <a href>,凡是"跳转到别处"的都该用链接,链接和按钮角色不能混——把按钮写成 div、把链接写成 div,都是把用户的键盘和读屏拒之门外。
三、ARIA:给动态组件补上"机器能懂的说明书"
语义化解决的是"静态结构",但现代页面有大量动态组件:手风琴、标签页、模态框、自动补全、实时通知……这些原生 HTML 没有现成对应的角色,这时候才轮到 ARIA 出场。ARIA 全称是 Accessible Rich Internet Applications,它是一组加在标签上的属性,作用是"告诉读屏软件这个元素是什么角色、处于什么状态、和谁有关联"。
但是请记住 ARIA 的第一铁律:能不用就不用,原生 HTML 够用时绝不上 ARIA。原因很现实——ARIA 只改"语义描述",不改"行为"。你给一个 div 加上 role="button",读屏确实会念它是按钮,但它依然聚焦不了、回车也触发不了,你还得自己补tabindex和键盘事件。而原生<button> 这些全白送。所以 ARIA 是补丁,不是地基。
ARIA 里最常打交道的有三类东西。其一是角色(role),比如 role="dialog"、role="tab"、role="tablist"、role="listbox",告诉读屏"我是什么"。其二是状态与属性(states & properties),比如 aria-expanded(是否展开)、aria-pressed(开关是否按下)、aria-selected(是否选中)、aria-disabled(是否禁用),用来同步组件当前的状态。其三是实时区域(live region),用 aria-live="polite"让动态变化被读屏主动播报,不用用户手动去翻。
下面用一个最常见的模态框,演示 ARIA 怎么配合焦点管理把状态讲清楚:
html<!-- 模态框:role 声明对话框,aria-modal 告诉读屏"背后内容暂时无关" --><div role="dialog" aria-modal="true" aria-labelledby="dlgTitle" aria-describedby="dlgDesc" id="myDialog"> <!-- aria-labelledby 指向标题,读屏会念出"对话框:删除确认" --> <h2 id="dlgTitle">删除确认</h2> <!-- aria-describedby 指向描述,补充念出这段说明文字 --> <p id="dlgDesc">此操作不可恢复,确定要删除吗?</p> <!-- 用原生 button,白拿键盘与读屏支持 --> <button id="confirmBtn">确定</button> <button id="cancelBtn">取消</button></div><script> // 打开模态时,把焦点主动移到对话框内的确认按钮 function openDialog() { document.getElementById('myDialog').style.display = 'block'; document.getElementById('confirmBtn').focus(); // 焦点进模态,键盘用户不会迷失 } // 关闭时,把焦点还给当初触发它的按钮,避免焦点丢回页面顶部 function closeDialog() { document.getElementById('myDialog').style.display = 'none'; document.getElementById('triggerBtn').focus(); // 焦点归位,体验连续 }</script>
注意aria-expanded和aria-pressed这种状态属性一定要和真实状态同步。比如一个"展开/收起"的折叠面板,展开时aria-expanded="true"、收起时改成"false",否则读屏用户听到的状态和眼睛看到的对不上,比没有还糟。
实时区域aria-live在做"提交成功""搜索结果更新""加入购物车"这类异步反馈时特别好用,它可以让读屏主动把新出现的文字念出来,而不是等用户自己发现。一个最小实现:
html<!-- polite 表示等读屏念完当前内容再播报,不抢话;assertive 则立即打断 --><div aria-live="polite" id="toast"></div><script> // 往这个区域塞文字,读屏会自动朗读,无需用户聚焦 function showToast(msg) { document.getElementById('toast').textContent = msg; } // 例如:showToast('已成功加入购物车')</script>
如果你在做组件库、自己封装可复用控件,那这部分尤其重要。用 L1 里的方式封装出来的自定义元素,浏览器并不天然知道它"是什么角色、怎么操作",你必须亲手给它补上 role、tabindex、键盘事件和状态属性,否则封装得再漂亮,屏幕阅读器用户也用不了。ARIA 在自定义元素这里不是可选项,是必答题。
四、键盘可达:把鼠标"摘掉"也能走完全程
无障碍里最容易被低估、却最容易被用户感知的一关,是键盘可达。请做一个小实验:把鼠标放到一边,只用 Tab、Shift+Tab、回车、空格、方向键、Esc,看能不能走完你页面的核心流程。很多页面鼠标点得欢,键盘一走就卡死——焦点不知道跑哪去了,或者根本走不到某些按钮,或者模态框关掉后焦点丢回页面顶部。
先说最基础也最容易被误伤的一点:可见焦点。浏览器默认给聚焦元素画一圈焦点环,那是键盘用户的"我在哪"指示。无数项目中有人为了"好看"写了outline: none把焦点环去掉,却没提供任何替代样式,结果键盘用户完全不知道当前聚焦在哪。正确做法是保留焦点指示,或者用:focus-visible做更聪明的处理——它只在键盘操作时显示焦点环,鼠标点击时不显示,兼顾美观和可达。
cssbutton:focus { outline: none; }button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px;}
再说"跳转链接"(skip link)。页面顶部往往有一长串导航,读屏和键盘用户每次进页面都要从头 Tab 一遍才能到正文,极其折磨。解决办法是在 <body>最开头放一个"跳到主内容"的链接,平时藏起来,聚焦时才出现,一点就跳到<main>:
html<!-- 跳转链接:键盘用户第一个就能 Tab 到,跳过整段导航直达正文 --><a class="skip-link" href="#mainContent">跳到主内容</a><nav>...一大串导航...</nav><main id="mainContent">正文</main><style> .skip-link { position: absolute; left: -999px; } .skip-link:focus { left: 8px; }</style>
tabindex 是另一个重灾区。规则很简单:绝大多数情况你不该手写 tabindex;原生可交互元素已经能被 Tab 走到。如果非写,只用两个值——tabindex="0" 让本来不可聚焦的元素(比如 div)进入自然 Tab 顺序,tabindex="-1"让元素可被脚本focus() 聚焦但不出现在 Tab 顺序里(常用于模态里临时接管的焦点)。永远不要写正数 tabindex(如 1、2、3),那会破坏整个页面的自然焦点顺序,比没有还乱。
焦点管理在动态场景是必做的。至少覆盖这几种:模态打开时焦点移进模态、关闭时焦点还给触发按钮;路由切换后焦点移到新内容而不是停在旧链接上;表单提交出错时焦点移到第一个无效的输入框;异步加载完新内容时把焦点导过去。模态框还需要"焦点陷阱"——Tab 和 Shift+Tab 都在模态内循环,不会跑到背后页面去,Esc 关闭:
javascript// 极简焦点陷阱:拦截 Tab,让焦点在模态内循环,避免跑出去function trapFocus(container, event) { const focusable = container.querySelectorAll( 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const first = focusable[0]; const last = focusable[focusable.length - 1]; // _shift_ 加 Tab 到第一个时,绕回最后一个 if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } // 单纯 Tab 到最后一个时,绕回第一个 if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }}
顺带提醒懒加载场景:用Intersection Observer API 实战:高性能滚动触发动画与懒加载 里的方法做图片懒加载时,图片进入视野再加载没问题,但务必给每张图配好 alt 文本,并且不要为了"入场动画"把内容设成 aria-hidden或长期visibility: hidden,否则读屏会读不到。动画是给眼睛的,无障碍是给所有人的,两者要兼顾。
五、颜色对比度:别让"浅灰字"成为隐形人
这一节讲的是"可感知"。WCAG 对文字对比度有明确要求:正文(普通字号)和背景的对比度至少 4.5:1,大字号(粗体 18px 以上或常规 24px 以上)至少 3:1。这些是最低线,能更高就更高。很多设计稿里那种"浅灰字配白底"看起来高级,实际对比度可能只有 2:1,对弱视用户等于隐形。
更隐蔽的坑是"只用颜色传递信息"。比如表单报错只把边框变红,对红绿色盲用户完全没有意义;图表里只用三种深浅蓝区分数据,色盲用户根本分不清。正确做法永远是"多重线索":报错除了红框,还要有文字说明和图标;图表除了颜色,还要有图案或标签。颜色是辅助,文字和结构才是主信号。
暗黑模式下的对比度更要小心,很多人切到深色背景后文字对比度反而塌了。最稳的工程做法是把颜色抽成设计 token,用 CSS 变量统一管理,再配合 prefers-color-scheme做系统级切换,从源头保证两套主题都达标:
css:root { --text-color: #1f2937; --bg-color: #ffffff; --link-color: #1d4ed8; }@media (prefers-color-scheme: dark) { :root { --text-color: #f3f4f6; --bg-color: #111827; --link-color: #93c5fd; }}body { color: var(--text-color); background: var(--bg-color);}
关于用 CSS 变量统一管理主题配色、实现一键切换的更多玩法,可以看 CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换,把颜色 token 抽出来之后,不仅主题切换方便,无障碍对比度的合规性也更容易集中把控。
检测对比度不需要肉眼估。Chrome 开发者工具的颜色选择器会直接显示当前颜色对 WCAG 的对比度比值,Firefox 的无障碍检查器能自动高亮对比度不达标的元素,设计师还有 Stark 这类插件可以一键扫。把这些工具当成日常开发的标配,而不是上线前的突击审计,问题会在最早阶段被发现。
六、alt 文本与表单 label:最容易被忽略的两块
这两块单独拎出来讲,是因为它们简单、见效快,却偏偏是审计里出错率最高的。先说图片的 alt 文本。它的作用是在图片无法显示、或用户用读屏时,用文字把图片"说"出来。
写法分三种情况。装饰性图片(纯美化、不传递信息,比如分隔线、背景花纹)直接alt=""或加aria-hidden="true",让读屏跳过,别念出无意义的描述。传递信息的图片(比如一张展示数据趋势的图)要把关键信息写进 alt,让读屏用户也能拿到核心内容。功能性的图片按钮(比如用图标当提交按钮)则要在 alt 或 aria-label 里说明它的作用,比如"搜索"。一个常见反面教材是给所有图片都写 alt="图片"或者留空到有信息的图,那等于告诉读屏用户"这里有个东西,但我不知道是什么"。
表单这一块更关键。每个输入框都必须有可见的<label> 关联到它,不能只靠 placeholder 当标签——因为 placeholder 在用户输入后就消失了,读屏在空字段时也念不出任何有用信息。如果还有补充说明或错误提示,用 aria-describedby把它和输入框绑起来,读屏聚焦时会一并念出:
html<!-- label 的 for 指向输入框 id,二者关联,读屏聚焦时会念出"邮箱地址" --><form> <label for="email">邮箱地址</label> <input id="email" type="email" aria-describedby="emailHint" <!-- 关联下方的说明与错误提示 --> aria-required="true" > <!-- 这段说明会被读屏在聚焦邮箱框时一并朗读 --> <p id="emailHint" class="hint">我们不会公开你的邮箱</p> <!-- 校验报错也用 describedby 关联,错误能被"听见"而非只靠红框 --> <p id="emailError" class="error" role="alert">邮箱格式不正确</p> <button type="submit">提交</button></form>
注意role="alert"自带aria-live="assertive"的语义,出错信息会立刻被读屏播报,比弹窗更友好,也更符合"可理解"原则——错误信息要说人话,告诉用户错在哪、怎么改,而不是冷冰冰地甩一句"错误"。
七、屏幕阅读器与测试:光写不算,得上机走查
最后一节是验收。无障碍领域有个残酷的事实:自动化工具只能抓出大约三成到四成的问题。它能可靠发现缺失的 alt、对比度不达标、表单缺 label、ARIA 用错这类"静态"问题,但抓不出"焦点管理乱不乱""朗读顺序通不通""动态变化有没有被播报"这类需要真实交互才能暴露的毛病。
所以一套靠谱的测试组合应该是这样的。第一层是自动化扫描,在每页跑 axe 或 Lighthouse,把明显的低级错误在本地就拦下来;更进一步,把 axe-core 接进单元测试,让回归时自动报警。下面是一个用 jest-axe 的最小集成:
javascript// 把 axe-core 接进测试,页面一有违规就让它飘红,挡在合并之前import { axe } from 'jest-axe';test('页面不应有无障碍违规', async () => { const { container } = render(<MyPage />); // toHaveNoViolations 会自动比对 WCAG 规则,有问题测试直接失败 expect(await axe(container)).toHaveNoViolations();});
第二层是键盘走查,前面说过,把鼠标放一边纯用键盘走核心流程,五分钟就能发现自动化工具抓不到的大问题:能不能走到每个可交互元素、焦点在哪看不看得见、每个控件能不能操作。
第三层是屏幕阅读器实测。至少用一款读屏走核心路径:macOS 自带的 VoiceOver(Cmd+F5 开启)、Windows 上免费的 NVDA、移动端有 TalkBack 和 VoiceOver。重点看朗读顺序合不合理、可交互元素有没有带角色和状态、动态变化有没有被播报。这一步没有捷径,但收益最大。
第四层是缩放测试,把浏览器放大到 200%,看布局是否还撑得住、文字还能不能读、控件还能不能点。很多页面在 100% 下完美,一放大就崩,这同样是无障碍的一部分。
把这四层串起来,你的无障碍质量才有底气。自动化是起点不是终点,手动走查才是真正替用户把关的那一步。
八、写在最后:无障碍是主干,不是分支
把前面七节串起来,无障碍的脉络其实很清晰:语义化 HTML 打地基,原生交互元素白送键盘和读屏支持;ARIA 只在动态组件补语义,且永远记住"能原生就别 ARIA";键盘可达和可见焦点是硬门槛,焦点管理在动态场景下必做;对比度达标且别只用颜色传信息;alt 和 label 这种小事最易出错却最易修;最后必须上机用读屏走查,自动化只覆盖三成。
给你一份贴墙用的上线前自检清单,五条足够日常用:其一,页面里承担导航、页眉、页脚、侧栏、正文角色的块,用的是语义标签还是清一色 div;其二,键盘纯 Tab 能不能走完核心流程,焦点看得见吗;其三,颜色对比度够 4.5:1 吗,有没有只用颜色传信息;其四,图片有没有写对 alt,表单有没有关联 label;其五,有没有用读屏真机走一遍核心路径。五条都过,这页的无障碍才算合格。
说到底,无障碍不是"feature 分支"里的事后补丁,它是产品主干的一部分。今天多在结构、对比度、键盘上花一点功夫,明天省下的是被用户投诉、被合规打回、被搜索引擎降权之后的高额返工。别等出问题才想起来改,那时候的成本比一开始就写对高得多。从下一个组件开始,顺手把语义、焦点和对比度做对,积少成多,你的产品自然就对所有人更友好。

发表评论发布评论后审核成功,即可显示在列表中...