为了在 Vue 项目中如何优雅地适配无障碍功能,以下是完整的实现方案:
1. 创建无障碍指令
a11y.js 指令文件
javascript
// 无障碍常见属性常量定义
const A11Y_ATTRS = {
ROLE: 'role',
TABINDEX: 'tabindex',
LABEL: 'aria-label',
LABELLEDBY: 'aria-labelledby',
DESCRIBEDBY: 'aria-describedby',
LIVE: 'aria-live',
HIDDEN: 'aria-hidden',
DISABLED: 'aria-disabled'
}
const A11Y_ROLES = {
BUTTON: 'button',
LINK: 'link',
IMAGE: 'img',
HEADING: 'heading'
}
/**
* 设置元素的 ARIA 属性
* @param {HTMLElement} el - DOM 元素
* @param {Object} binding - 指令绑定对象
*/
function setAriaAttributes (el, binding) {
const { value } = binding
if (value.tabIndex !== undefined) {
el.setAttribute(A11Y_ATTRS.TABINDEX, value.tabIndex)
}
// 基础 ARIA 属性设置
if (value.role) {
el.setAttribute(A11Y_ATTRS.ROLE, value.role)
}
if (value.label) {
el.setAttribute(A11Y_ATTRS.LABEL, value.label)
}
if (value.labelledBy) {
el.setAttribute(A11Y_ATTRS.LABELLEDBY, value.labelledBy)
}
if (value.describedBy) {
el.setAttribute(A11Y_ATTRS.DESCRIBEDBY, value.describedBy)
}
if (value.live) {
el.setAttribute(A11Y_ATTRS.LIVE, value.live)
}
if (value.hidden !== undefined) {
el.setAttribute(A11Y_ATTRS.HIDDEN, value.hidden)
}
if (value.disabled !== undefined) {
el.setAttribute(A11Y_ATTRS.DISABLED, value.disabled)
}
// 特殊角色处理
if (value.role === A11Y_ROLES.HEADING && value.level) {
el.setAttribute('aria-level', value.level)
}
// 确保按钮和链接添加 tabindex
if (value.role === A11Y_ROLES.BUTTON || value.role === A11Y_ROLES.LINK) {
if (!el.hasAttribute('tabindex')) {
el.setAttribute('tabindex', '0')
}
}
// 图片必须有 alt 文本
if (value.role === A11Y_ROLES.IMAGE && !el.hasAttribute('alt')) {
console.warn('Accessibility warning: Image elements should have alt text', el)
}
}
/**
* 动态更新 ARIA 属性
* @param {HTMLElement} el - DOM 元素
* @param {Object} value - 新值
*/
function updateAriaAttributes (el, value) {
// 移除旧属性
Object.values(A11Y_ATTRS).forEach(attr => {
el.removeAttribute(attr)
})
// 设置新属性
setAriaAttributes(el, { value })
}
export default {
install (Vue, options = {}) {
Vue.directive('a11y', {
inserted (el, binding) {
setAriaAttributes(el, binding)
},
update (el, binding) {
if (binding.value !== binding.oldValue) {
updateAriaAttributes(el, binding.value)
}
},
componentUpdated (el, binding) {
if (binding.value !== binding.oldValue) {
updateAriaAttributes(el, binding.value)
}
}
})
}
}
2. 注册指令
在 main.js 中注册指令:
javascript
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import a11yDirective from './directives/a11y'
Vue.directive('a11y', a11yDirective)
new Vue({
render: h => h(App)
}).$mount('#app')
3. 使用指令
基本用法
HTML
<template>
<!-- 按钮 -->
<button v-a11y="{ role: 'button', label: '提交表单' }">提交</button>
<!-- 标题 -->
<h1 v-a11y="{ role: 'heading', level: 1 }">页面标题</h1>
<!-- 图片 -->
<img
v-a11y="{ role: 'img', label: '公司Logo' }"
src="logo.png"
alt="公司Logo"
>
<!-- 自定义交互元素 -->
<div
v-a11y="{ role: 'button', label: '关闭弹窗' }"
@click="closeModal"
>
×
</div>
</template>
动态属性
HTML
<template>
<div>
<button
v-a11y="{
role: 'button',
label: buttonLabel,
disabled: isDisabled
}"
:disabled="isDisabled"
@click="handleClick"
>
{{ buttonText }}
</button>
</div>
</template>
<script>
export default {
data() {
return {
isDisabled: false,
buttonText: '点击我',
buttonLabel: '这是一个可点击的按钮'
}
},
methods: {
handleClick() {
this.isDisabled = true
this.buttonLabel = '按钮已被点击,请等待'
}
}
}
</script>
4. 高级功能扩展
焦点管理指令
javascript
// src/directives/focus-manager.js
export default {
inserted(el, binding) {
if (binding.value) {
el.focus()
}
},
update(el, binding) {
if (binding.value && !binding.oldValue) {
// 使用 setTimeout 确保在 DOM 更新后执行
setTimeout(() => {
el.focus()
}, 0)
}
}
}
实时区域更新
HTML
<template>
<div
v-a11y="{ live: 'polite' }"
aria-live="polite"
>
{{ notificationText }}
</div>
</template>
<script>
export default {
data() {
return {
notificationText: ''
}
},
methods: {
showNotification(message) {
this.notificationText = message
}
}
}
</script>
5. 最佳实践
-
语义化 HTML :优先使用原生语义化元素(如
<button>而不是<div>) -
标签关联:
html<template> <div> <label id="username-label">用户名</label> <input v-a11y="{ labelledBy: 'username-label' }" type="text" aria-labelledby="username-label" > </div> </template> -
状态管理:
html<template> <button v-a11y="{ role: 'button', label: expanded ? '收起菜单' : '展开菜单', expanded: expanded }" :aria-expanded="expanded" @click="toggleMenu" > {{ expanded ? '▼' : '►' }} </button> </template> -
测试验证:
- 使用 Chrome 开发者工具的 Lighthouse 进行无障碍测试
- 在 Android 设备上实际测试 TalkBack 功能
6. 全局混入常用方法
javascript
// src/mixins/a11y.js
export default {
methods: {
// 为动态内容提供无障碍通知
a11yNotify(message, priority = 'polite') {
const liveRegion = document.getElementById('a11y-live-region')
if (liveRegion) {
liveRegion.setAttribute('aria-live', priority)
liveRegion.textContent = message
} else {
console.warn('无障碍实时区域未找到')
}
},
// 管理焦点
a11yFocus(elementId) {
const el = document.getElementById(elementId)
if (el) {
el.focus()
}
}
}
}
总结
通过封装 v-a11y 指令,我们可以:
- 统一管理所有无障碍属性
- 提供动态更新能力
- 内置常见模式的最佳实践
- 方便地扩展新功能
- 保持代码整洁和可维护性
这种方案既满足了 TalkBack 的基本需求,又能灵活应对各种复杂场景,是 Vue 项目中实现无障碍功能的优雅解决方案。