可访问性不是「为少数人做额外工作」,而是「让产品能被尽可能多的人使用」。 全球约有 15% 的人口有某种形式的残障,而且他们不是你的「边缘用户」——他们是你的父母、你的朋友、你的客户。Web 可访问性既是技术要求,也是道德责任,在很多国家更是法律义务。
一、WCAG 标准解读 1.1 WCAG 2.1 四大原则(POUR) 原则 英文 核心要求 示例 感知性 Perceivable 信息和组件必须可被用户感知 图片有 alt 文本、视频有字幕、色彩不是唯一信息通道 可操作性 Operable 界面组件必须可操作 全部功能可键盘操作、不限制时间、无闪烁 可理解性 Understandable 信息和操作必须可理解 页面有标题、错误有说明、语言可识别 健壮性 Robust 内容必须兼容辅助技术 有效的 HTML、正确的 ARIA、符合标准
1.2 合规等级 等级 要求 适用场景 A 最低要求,无障碍的门槛 必须满足 AA 常见要求,大多数组织目标 网站/应用的推荐标准 AAA 最高要求,更难实现 政府、教育、金融(部分)
2025 年欧洲《无障碍法案》(EAA)生效,要求数字产品必须符合 EN 301 549(等同于 WCAG 2.1 AA)。中国《无障碍环境建设法》2023 年生效,对公共服务网站有明确要求。
二、语义化 HTML:可访问性的基石 2.1 不要只用 div <!-- ❌ 错误:纯 div,屏幕阅读器完全无法理解 -->
< div class = "header" >
< div class = "nav" >
< div class = "item" onclick = "goHome()" > 首页</ div >
< div class = "item" onclick = "goAbout()" > 关于</ div >
</ div >
</ div >
<!-- ✅ 正确:语义化标签自带可访问性 -->
< header >
< nav aria-label = "主导航" >
< ul >
< li >< a href = "/" > 首页</ a ></ li >
< li >< a href = "/about" > 关于</ a ></ li >
</ ul >
</ nav >
</ header >
< main >
< article >
< h1 > 文章标题</ h1 >
< p > 文章内容...</ p >
</ article >
</ main >
< aside aria-label = "相关推荐" >
< h2 > 推荐阅读</ h2 >
<!-- ... -->
</ aside >
< footer >
< p > © 2026 My Company</ p >
</ footer >
2.2 标题层级 <!-- ✅ 正确的标题层级 —— 像书的目录 -->
< h1 > 网站标题</ h1 >
< h2 > 章节一</ h2 >
< h3 > 小节 A</ h3 >
< h3 > 小节 B</ h3 >
< h2 > 章节二</ h2 >
< h3 > 小节 C</ h3 >
< h4 > 细节 1</ h4 >
<!-- ❌ 错误:跳过层级、用样式代替语义 -->
< p class = "h1-style" > 看起来像标题但不是</ p >
< h1 > 一级标题</ h1 >
< h3 > 直接跳到三级</ h3 >
三、ARIA:语义化 HTML 的补充 3.1 ARIA 使用原则 「第一原则:如果能用原生 HTML 元素实现,就不要用 ARIA。」
<!-- ❌ 过度使用 ARIA -->
< div role = "button" tabindex = "0" aria-pressed = "false" > 提交</ div >
<!-- ✅ 原生元素自带全部 ARIA 属性 -->
< button type = "submit" > 提交</ button >
<!-- ✅ 只有在原生元素无法表达时才用 ARIA -->
< div role = "dialog" aria-modal = "true" aria-labelledby = "dialog-title" >
< h2 id = "dialog-title" > 确认删除</ h2 >
< p > 确定要删除这条记录吗?此操作不可撤销。</ p >
< button > 取消</ button >
< button > 删除</ button >
</ div >
3.2 常用 ARIA 属性 属性 用途 示例 aria-label为元素提供文本标签 <nav aria-label="主导航">aria-labelledby引用另一个元素作为标签 <div role="dialog" aria-labelledby="title">aria-describedby补充描述 <input aria-describedby="hint">aria-expanded控件是否展开 <button aria-expanded="false">菜单</button>aria-hidden对辅助技术隐藏 <svg aria-hidden="true">aria-live动态内容通知 <div aria-live="polite">加载中...</div>role定义元素角色 role="alert"、role="tablist"
3.3 动态内容:aria-live <!-- 状态变化自动播报 -->
< div id = "status" aria-live = "polite" aria-atomic = "true" ></ div >
< script >
// 当表单提交后更新状态
function showStatus ( message ) {
document . getElementById ( 'status' ). textContent = message ;
// 屏幕阅读器会自动播报:"保存成功"
}
showStatus ( '保存成功!' );
</ script >
<!-- aria-live 取值:
off — 不播报(默认)
polite — 礼貌:当前说完后播报
assertive — 打断:立即播报(紧急错误用)
-->
四、键盘导航与焦点管理 4.1 可交互元素必须可聚焦 <!-- ✅ 原生可交互元素自动可聚焦 -->
< a href = "/" > 链接</ a >
< button > 按钮</ button >
< input type = "text" >
< select >
< textarea >
<!-- ❌ 虚假按钮 —— 无法键盘聚焦和触发 -->
< div class = "btn" onclick = "submit()" > 提交</ div >
<!-- ✅ 无 href 的链接也需要 focus -->
< a href = "javascript:void(0)" role = "button" tabindex = "0"
@ keydown . enter = "submit" > 更多</ a >
<!-- ✅ 或用真正的按钮 -->
< button type = "button" @ click = "submit" > 更多</ button >
4.2 焦点顺序与可见性 /* 确保焦点可见 */
: focus-visible {
outline : 2 px solid #3b82f6 ;
outline-offset : 2 px ;
}
/* 不要隐藏焦点! */
/* ❌ 错误 */
* : focus { outline : none ; }
/* 不要用 tabindex > 0 强行改变顺序 —— 让 DOM 顺序反映视觉顺序 */
4.3 焦点陷阱(Focus Trap) // 模态框必须困住焦点
function trapFocus ( element : HTMLElement ) {
const focusable = element . querySelectorAll (
'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusable [ 0 ] as HTMLElement ;
const last = focusable [ focusable . length - 1 ] as HTMLElement ;
element . addEventListener ( 'keydown' , ( e ) => {
if ( e . key !== 'Tab' ) return ;
if ( e . shiftKey && document . activeElement === first ) {
e . preventDefault ();
last . focus ();
} else if ( ! e . shiftKey && document . activeElement === last ) {
e . preventDefault ();
first . focus ();
}
});
first . focus (); // 打开时聚焦第一个元素
}
// 关闭时恢复焦点
let previousFocus : HTMLElement | null = null ;
function openModal() {
previousFocus = document . activeElement as HTMLElement ;
modal . showModal ();
trapFocus ( modal );
}
function closeModal() {
modal . close ();
previousFocus ? . focus (); // 回到触发元素
}
五、色彩与对比度 5.1 WCAG 对比度要求 等级 正文(< 18pt) 大文本(≥ 18pt / 14pt bold) AA 4.5:1 3:1 AAA 7:1 4.5:1
/* ✅ 对比度足够 */
. text-primary {
color : #1e3a5f ; /* 深蓝色 */
background : #ffffff ; /* 对比度 12.5:1 ✅ */
}
/* ❌ 对比度不足 */
. text-muted {
color : #cccccc ; /* 浅灰 */
background : #ffffff ; /* 对比度 1.6:1 ❌ */
}
5.2 不要只用颜色传递信息 <!-- ❌ 色觉障碍用户无法区分红绿 -->
< span class = "status-red" > 错误</ span >
< span class = "status-green" > 成功</ span >
<!-- ✅ 颜色 + 图标 + 文字 -->
< span class = "text-red-600" >
< svg aria-hidden = "true" > <!-- 错误图标 --> </ svg >
错误:请检查输入
</ span >
< span class = "text-green-600" >
< svg aria-hidden = "true" > <!-- 成功图标 --> </ svg >
成功:保存完成
</ span >
<!-- ✅ 表单错误同时用多种方式 -->
< input aria-invalid = "true" aria-describedby = "error-name" >
< span id = "error-name" class = "text-red-600" > 姓名不能为空</ span >
5.3 prefers-reduced-motion /* 尊重用户的减少动画偏好 */
@ media ( prefers-reduced-motion : reduce ) {
*,
* :: before ,
* :: after {
animation-duration : 0.01 ms !important ;
animation-iteration-count : 1 !important ;
transition-duration : 0.01 ms !important ;
}
}
/* 或在 Vue/React 中动态判断 */
六、表单可访问性 6.1 标签关联 <!-- ✅ 显式关联 -->
< label for = "email" > 邮箱地址</ label >
< input type = "email" id = "email" name = "email" required
aria-required = "true"
aria-describedby = "email-hint email-error" >
< p id = "email-hint" > 我们将向您发送验证邮件</ p >
<!-- ✅ 隐式关联 -->
< label >
用户名
< input type = "text" name = "username" >
</ label >
<!-- ❌ 无关联:屏幕阅读器无法知道 input 的用途 -->
< span > 邮箱</ span >
< input type = "email" >
6.2 错误提示 < form >
< label for = "password" > 密码</ label >
< input
type = "password"
id = "password"
aria-invalid = "true"
aria-describedby = "password-error"
>
< span id = "password-error" role = "alert" class = "error" >
密码至少需要 8 位字符
</ span >
</ form >
七、Vue / React 可访问性 7.1 Vue 3 中的 a11y < script setup lang = "ts" >
import { ref , watch } from 'vue' ;
const isOpen = ref ( false );
const modalRef = ref < HTMLElement >();
// 打开时聚焦模态框,关闭时恢复
watch ( isOpen , ( open ) => {
if ( open ) {
setTimeout (() => modalRef . value ? . focus (), 0 );
}
});
</ script >
< template >
< button @click ="isOpen = true" aria -haspopup =" dialog " >
打开设置
</ button >
< div
v-if = "isOpen"
ref = "modalRef"
role = "dialog"
aria -modal =" true "
aria -labelledby =" modal -title "
tabindex = "-1"
@keydown.esc ="isOpen = false"
>
< h2 id = "modal-title" > 设置 </ h2 >
<!-- 内容 -->
< button @click ="isOpen = false" > 关闭 </ button >
</ div >
</ template >
7.2 React 中的 a11y import { useRef , useEffect } from 'react' ;
function Modal ({ isOpen , onClose , title , children }) {
const modalRef = useRef < HTMLDivElement >( null );
const previousFocus = useRef < HTMLElement | null >( null );
useEffect (() => {
if ( isOpen ) {
previousFocus . current = document . activeElement as HTMLElement ;
modalRef . current ? . focus ();
// 焦点陷阱
const handleTab = ( e : KeyboardEvent ) => {
if ( e . key !== 'Tab' ) return ;
const focusable = modalRef . current ? . querySelectorAll (
'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
);
if ( ! focusable ? . length ) return ;
const first = focusable [ 0 ] as HTMLElement ;
const last = focusable [ focusable . length - 1 ] as HTMLElement ;
if ( e . shiftKey && document . activeElement === first ) {
e . preventDefault ();
last . focus ();
} else if ( ! e . shiftKey && document . activeElement === last ) {
e . preventDefault ();
first . focus ();
}
};
document . addEventListener ( 'keydown' , handleTab );
return () => {
document . removeEventListener ( 'keydown' , handleTab );
previousFocus . current ? . focus ();
};
}
}, [ isOpen ]);
if ( ! isOpen ) return null ;
return (
< div
ref = { modalRef }
role = "dialog"
aria-modal = "true"
aria-labelledby = "modal-title"
tabIndex = { - 1 }
onKeyDown = {( e ) => e . key === 'Escape' && onClose ()}
>
< h2 id = "modal-title" >{ title }</ h2 >
{ children }
</ div >
);
}
八、可访问性测试工具 8.1 自动化测试 # axe-core(最权威的 a11y 测试引擎)
npm install -D @axe-core/cli
# CLI 扫描
npx axe https://example.com --tags wcag2aa
# 集成 Playwright
npm install -D @axe-core/playwright
// e2e/a11y.spec.ts
import { test , expect } from '@playwright/test' ;
import AxeBuilder from '@axe-core/playwright' ;
test ( 'homepage should not have accessibility violations' , async ({ page }) => {
await page . goto ( '/' );
const accessibilityScanResults = await new AxeBuilder ({ page })
. withTags ([ 'wcag2a' , 'wcag2aa' , 'wcag21aa' ])
. analyze ();
expect ( accessibilityScanResults . violations ). toEqual ([]);
});
8.2 浏览器插件 工具 功能 安装 axe DevTools 实时扫描、智能修复建议 Chrome 扩展商店 WAVE 可视化标注问题 wave.webaim.org Lighthouse 内置 a11y 审计 Chrome DevTools
8.3 手动测试 键盘测试清单:
□ Tab 键可以遍历所有可交互元素
□ Shift+Tab 反向遍历
□ Enter/Space 可以激活按钮和链接
□ Esc 可以关闭模态框/下拉菜单
□ 焦点顺序符合视觉顺序
□ 焦点始终可见
屏幕阅读器测试:
□ 页面有标题(<title>)
□ 图片有替代文本
□ 表单有标签关联
□ 错误信息被播报
□ 数据表格有表头
九、法律合规 法规 地区 要求 ADA Title III 美国 公共场所网站必须无障碍 Section 508 美国(政府) 联邦电子和信息技术可访问性 EAA (EU) 欧洲 2025 年 6 月生效,WCAG 2.1 AA EN 301 549 欧洲 公共采购的 ICT 产品标准 中国无障碍环境建设法 中国 2023 年 9 月生效 AODA 加拿大(安大略) 公共服务网站 WCAG 2.0 AA
十、可访问性 Checklist 检查项 等级 验证方式 页面有
A
自动
图片有 alt 文本
A
自动 + 人工
表单有 label 关联
A
自动
全部功能可键盘操作
A
手动
焦点可见
A
手动
颜色对比度 ≥ 4.5:1
AA
自动(axe)
文本可缩放至 200%
AA
手动
无自动播放音频
A
自动
错误有文本说明
A
手动
支持 prefers-reduced-motion
AAA
手动
参考与延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。