无障碍访问 (a11y)
本文档介绍如何让 VitePress 站点对所有用户友好,包括使用屏幕阅读器、键盘导航和辅助技术的用户。
为什么无障碍很重要?
影响范围
全球约有 15% 的人口有某种形式的残疾,包括:
- 视觉障碍(盲人、低视力)
- 听觉障碍
- 运动障碍
- 认知障碍
法律要求
许多国家和地区有无障碍法律要求:
- 美国:ADA、Section 508
- 欧盟:EN 301 549
- 中国:信息无障碍相关法规
SEO 收益
良好的无障碍实践同时能提升 SEO:
- 语义化 HTML 有助于搜索引擎理解
- 替代文本提供图片上下文
- 清晰的导航改善用户体验
WCAG 原则
Web 内容无障碍指南(WCAG)遵循四个原则:
| 原则 | 说明 |
|---|---|
| 可感知 (Perceivable) | 信息必须能被用户感知 |
| 可操作 (Operable) | 界面组件必须可操作 |
| 可理解 (Understandable) | 信息和操作必须可理解 |
| 健壮性 (Robust) | 内容必须兼容各种技术 |
语义化 HTML
正确使用标题层级
markdown
<!-- ✅ 正确:按层级使用 -->
# 一级标题
## 二级标题
### 三级标题
<!-- ❌ 错误:跳过层级 -->
# 一级标题
### 三级标题(跳过了二级)使用语义化标签
vue
<template>
<!-- ✅ 正确:使用语义化标签 -->
<nav aria-label="主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/guide">指南</a></li>
</ul>
</nav>
<main>
<article>
<h1>文章标题</h1>
<p>文章内容...</p>
</article>
<aside>
<h2>相关链接</h2>
<ul>...</ul>
</aside>
</main>
<footer>
<p>版权信息</p>
</footer>
</template>HTML5 语义标签
| 标签 | 用途 |
|---|---|
<nav> | 导航区域 |
<main> | 主要内容 |
<article> | 独立内容块 |
<section> | 内容分组 |
<aside> | 侧边栏 |
<header> | 头部区域 |
<footer> | 底部区域 |
<figure> | 图片/图表容器 |
ARIA 标签
aria-label
为元素添加可访问标签:
vue
<template>
<!-- 为图标按钮添加标签 -->
<button aria-label="关闭菜单">
<XIcon />
</button>
<!-- 为导航添加标签 -->
<nav aria-label="面包屑导航">
...
</nav>
</template>aria-labelledby
关联现有元素作为标签:
vue
<template>
<div>
<h2 id="section-title">章节标题</h2>
<section aria-labelledby="section-title">
内容...
</section>
</div>
</template>aria-describedby
提供更详细的描述:
vue
<template>
<div>
<input
type="text"
aria-describedby="password-hint"
/>
<span id="password-hint">
密码需包含至少8个字符
</span>
</div>
</template>aria-hidden
隐藏装饰性元素:
vue
<template>
<!-- 图标仅为装饰,对屏幕阅读器隐藏 -->
<span aria-hidden="true">
<StarIcon />
</span>
</template>role 属性
定义元素角色:
vue
<template>
<!-- 自定义按钮 -->
<div
role="button"
tabindex="0"
@click="handleClick"
@keydown.enter="handleClick"
>
点击我
</div>
<!-- 标签页 -->
<div role="tablist">
<button role="tab" aria-selected="true">标签1</button>
<button role="tab" aria-selected="false">标签2</button>
</div>
</template>键盘导航
可聚焦元素
确保所有交互元素可通过键盘访问:
vue
<template>
<!-- ✅ 原生可聚焦元素 -->
<button>按钮</button>
<a href="/">链接</a>
<input type="text" />
<select>...</select>
<!-- ✅ 自定义元素添加 tabindex -->
<div tabindex="0" @click="handleClick">
自定义按钮
</div>
</template>tabindex 使用
| tabindex 值 | 说明 |
|---|---|
0 | 可通过 Tab 键聚焦 |
-1 | 仅可通过 JavaScript 聚焦 |
正数 | 不推荐,会打乱焦点顺序 |
焦点管理
vue
<script setup>
import { ref, nextTick } from 'vue'
const modalVisible = ref(false)
const modalRef = ref(null)
async function openModal() {
modalVisible.value = true
await nextTick()
// 将焦点移到模态框
modalRef.value?.focus()
}
function closeModal() {
modalVisible.value = false
// 将焦点返回触发元素
triggerButton.value?.focus()
}
</script>
<template>
<div
v-if="modalVisible"
ref="modalRef"
role="dialog"
aria-modal="true"
tabindex="-1"
>
模态框内容
</div>
</template>焦点陷阱
在模态框中捕获焦点:
vue
<script setup>
import { onMounted, onUnmounted } from 'vue'
const props = defineProps({
active: Boolean
})
function handleTabKey(e) {
if (!props.active) return
const focusableElements = modalRef.value.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (e.shiftKey && document.activeElement === firstElement) {
lastElement.focus()
e.preventDefault()
} else if (!e.shiftKey && document.activeElement === lastElement) {
firstElement.focus()
e.preventDefault()
}
}
onMounted(() => {
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') handleTabKey(e)
})
})
</script>跳过链接
允许用户跳过重复内容:
vue
<template>
<div>
<!-- 跳过导航链接 -->
<a href="#main-content" class="skip-link">
跳转到主要内容
</a>
<nav>导航内容...</nav>
<main id="main-content">
主要内容...
</main>
</div>
</template>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>图片无障碍
替代文本 (alt)
markdown
<!-- ✅ 有 alt 文本 -->

<!-- ✅ 装饰性图片使用空 alt -->

<!-- ✅ 复杂图片使用 aria-describedby -->
 aria-describedby="chart-desc"
<span id="chart-desc" class="sr-only">
图表显示2023年销售额增长30%...
</span>图片替代文本最佳实践
| 情况 | alt 文本 |
|---|---|
| 信息性图片 | 描述图片内容 |
| 功能性图片 | 描述功能(如"搜索") |
| 装饰性图片 | 空 alt="" |
| 复杂图表 | 简要描述 + 详细说明链接 |
颜色与对比度
对比度要求
WCAG 要求文本与背景的对比度:
| 文本类型 | 最小对比度 |
|---|---|
| 普通文本 | 4.5:1 |
| 大文本(18px+ 或 14px+ 粗体) | 3:1 |
| 非文本元素 | 3:1 |
检查对比度
使用工具检查:
- WebAIM Contrast Checker
- Chrome DevTools > Accessibility
VitePress 配置
css
/* 确保足够对比度 */
:root {
--vp-c-text-1: rgba(60, 60, 67); /* 确保对比度足够 */
--vp-c-text-2: rgba(60, 60, 67, 0.78);
}
/* 不要仅依赖颜色传达信息 */
.required::after {
content: " *";
color: #f00;
}不仅依赖颜色
vue
<template>
<!-- ❌ 错误:仅用颜色表示 -->
<span class="error">错误</span>
<!-- ✅ 正确:颜色 + 文字/图标 -->
<span class="error">
<ErrorIcon aria-hidden="true" />
错误
</span>
</template>表单无障碍
标签关联
vue
<template>
<!-- ✅ 正确:使用 label 包裹 -->
<label>
用户名
<input type="text" name="username" />
</label>
<!-- ✅ 正确:使用 for 关联 -->
<label for="email">邮箱</label>
<input type="email" id="email" name="email" />
<!-- ❌ 错误:没有标签 -->
<input type="text" placeholder="请输入用户名" />
</template>错误提示
vue
<template>
<div>
<label for="password">密码</label>
<input
type="password"
id="password"
aria-invalid="true"
aria-describedby="password-error"
/>
<span id="password-error" role="alert">
密码长度不足8位
</span>
</div>
</template>必填字段
vue
<template>
<label>
<span>用户名</span>
<span aria-hidden="true" class="required">*</span>
<span class="sr-only">(必填)</span>
<input required aria-required="true" />
</label>
</template>表格无障碍
表格标题
markdown
<table>
<caption>2023年销售数据</caption>
<thead>
<tr>
<th scope="col">产品名称</th>
<th scope="col">销售额</th>
<th scope="col">增长率</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">产品A</th>
<td>$100,000</td>
<td>+15%</td>
</tr>
</tbody>
</table>复杂表格
markdown
<table>
<caption>课程安排表</caption>
<thead>
<tr>
<th rowspan="2" scope="col">时间</th>
<th colspan="2" scope="colgroup">课程类型</th>
</tr>
<tr>
<th scope="col">理论课</th>
<th scope="col">实验课</th>
</tr>
</thead>
<tbody>
...
</tbody>
</table>媒体无障碍
视频
vue
<template>
<video controls>
<source src="video.mp4" type="video/mp4" />
<!-- 字幕 -->
<track
kind="subtitles"
src="subtitles-zh.vtt"
srclang="zh"
label="中文字幕"
/>
<!-- 音频描述 -->
<track
kind="descriptions"
src="descriptions.vtt"
srclang="zh"
label="音频描述"
/>
您的浏览器不支持视频播放。
</video>
</template>音频
vue
<template>
<audio controls>
<source src="audio.mp3" type="audio/mpeg" />
<!-- 文字稿链接 -->
<a href="transcript.html">查看文字稿</a>
</audio>
</template>动态内容
实时区域
vue
<template>
<!-- 状态更新 -->
<div aria-live="polite">
{{ statusMessage }}
</div>
<!-- 紧急通知 -->
<div aria-live="assertive" role="alert">
{{ errorMessage }}
</div>
</template>aria-live 类型
| 类型 | 用途 |
|---|---|
polite | 非紧急更新,等用户空闲时播报 |
assertive | 紧急更新,立即播报 |
off | 不播报(默认) |
加载状态
vue
<template>
<div
role="status"
aria-live="polite"
aria-busy="isLoading"
>
<span v-if="isLoading">加载中...</span>
<div v-else>内容</div>
</div>
</template>检查工具
自动化工具
Lighthouse
bashlighthouse https://yoursite.com --only-categories=accessibilityaxe DevTools
- Chrome/Firefox 扩展
- 检测 WCAG 违规
WAVE
- wave.webaim.org
- 浏览器扩展
手动测试
- [ ] 键盘导航测试(仅用 Tab 键)
- [ ] 屏幕阅读器测试
- [ ] 缩放测试(200% 放大)
- [ ] 颜色对比度检查
屏幕阅读器
| 平台 | 屏幕阅读器 |
|---|---|
| Windows | NVDA(免费)、JAWS |
| macOS | VoiceOver(内置) |
| iOS | VoiceOver(内置) |
| Android | TalkBack(内置) |
VitePress 特定优化
配置改进
ts
// docs/.vitepress/config.mts
export default defineConfig({
// 启用清晰的 URL
cleanUrls: true,
// 配置大纲级别
outline: {
level: [2, 3],
label: '页面导航'
},
// 本地化文本
returnToTopLabel: '返回顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
themeConfig: {
// 确保 logo 有替代文本
logo: {
src: '/logo.svg',
alt: 'VitePress Logo'
}
}
})自定义组件无障碍
vue
<!-- 自定义容器组件 -->
<template>
<div
:class="['custom-container', type]"
role="region"
:aria-label="title"
>
<div class="title">{{ title }}</div>
<div class="content">
<slot />
</div>
</div>
</template>无障碍检查清单
发布前检查:
- [ ] 所有图片有替代文本
- [ ] 颜色对比度达标
- [ ] 键盘可完全操作
- [ ] 表单有正确标签
- [ ] 标题层级正确
- [ ] 链接文本有意义
- [ ] 动态内容有通知
- [ ] 视频有字幕
- [ ] 焦点可见
- [ ] 可缩放到 200%
相关资源
下一步
学习 插件扩展 了解如何扩展 VitePress 功能。