导读:本期聚焦于会飞的猪创作的《Vue 3 中怎样做好无障碍开发?ARIA 属性与键盘导航实践》,敬请观看详情。Vue 3 项目里弹窗、菜单、选项卡等交互组件如果只靠鼠标点击,键盘用户和读屏软件用户很难操作。要让自定义控件达到可用水平,需要同时补齐 ARIA 语义和键盘导航能力,二者缺一不可。ARIA 属性负责告诉辅助技术当前是展开还是选中,例如 aria-expanded、aria-selected、aria-controls;键盘导航则要处理 Tab 顺序、方向键切换、Esc 关闭以及模态框的焦点陷阱。本文从组合式 API 和自定义指令的角度,介绍如何把这些逻辑封装成可复用函数,避免每个组件重复实现。文中会给出折叠面板、菜单、焦点陷阱和 aria-live 通知等典型代码示例,并说明使用原生语义优先、动态绑定布尔属性、焦点返回触发元素等关键细节。最后补充 eslint 插件、axe-core 和手动测试清单,帮助团队在提交前发现无障碍问题。这套实践不依赖特殊库,适合直接迁移到现有 Vue 3 项目。

Vue 3 的响应式模板让界面状态管理变得简单,但无障碍支持不会自动出现。一个由 <div> 拼出来的下拉菜单即使视觉上和原生 <select> 相似,读屏软件也可能只读出一堆文本,键盘用户也无法用方向键切换选项。要解决这个问题需要同时完成两件事:用 ARIA 属性补充控件语义,用键盘事件和焦点管理还原操作路径。本文围绕组合式 API 给出可落地的实践,重点覆盖属性绑定、焦点陷阱、可复用函数和测试验证。

Vue 3 中怎样做好无障碍开发?ARIA 属性与键盘导航实践

一、ARIA 属性在 Vue 3 模板中的动态绑定

ARIA 属性本质上是普通 HTML 属性,因此 Vue 3 允许静态或动态绑定。动态绑定的关键在布尔值处理。比如展开状态应当写成 :aria-expanded="isOpen",当 isOpen 为 false 时,Vue 会移除该属性;如果误写成 :aria-expanded="'false'",渲染结果会变成字符串 false,辅助技术可能理解错误。所以必须绑定真正的布尔表达式,而不是带引号的字符串。

折叠面板是最常见的例子。触发按钮使用原生 <button>,它自带键盘可操作性和 button 语义,不需要额外 role="button"。按钮上绑定 aria-expanded 和 aria-controls,内容区设置 id 与 aria-controls 对应。内容区不建议随意加 role="region",除非确实希望读屏软件把它作为独立区域并在导航中暴露。下面是一个使用 v-bind 合并属性的例子。

<script setup>
import { computed, ref } from 'vue'

const isOpen = ref(false)
const panelId = 'collapse-panel-1'

function toggle() {
  isOpen.value = !isOpen.value
}

const buttonAttrs = computed(() => ({
  'aria-expanded': isOpen.value,
  'aria-controls': panelId
}))
</script>

<template>
  <button
    type="button"
    v-bind="buttonAttrs"
    @click="toggle"
  >
    更多设置
  </button>
  <section :id="panelId" v-show="isOpen">
    这里是展开的内容区域。
  </section>
</template>

另一个常见需要是列表选择。菜单或选项列表不要用 <div> 再加一堆 role 去伪装。如果必须自定义列表交互,应使用 role="listbox"、role="option"、aria-selected 等,并显式维护焦点。模板中可以把多个 ARIA 属性放进一个 computed 对象,这样逻辑集中、不易拼错。同时要遵守规则:ARIA 只负责描述,不能改变键盘行为;如果状态变化了却不更新属性,读屏用户会听到错误信息。

二、键盘导航与焦点管理:ARIA 之外的关键实现

给一个 <div> 添加 role="menu" 并不会让它自动响应方向键。ARIA 表达的是角色和状态,真正让控件可用的是浏览器原生行为或我们写的 JavaScript。对于菜单、选项卡、树形控件等复合组件,建议使用 WAI-ARIA 设计模式中约定的键盘交互:方向键在选项间移动焦点,Home 和 End 跳到首尾,Esc 关闭并返回触发元素。在 Vue 3 中,这些逻辑适合放到 @keydown 事件处理函数里,并配合 ref 获取 DOM 节点。

以菜单为例,触发按钮打开菜单后,应把焦点移动到第一个或当前高亮项,而不是让焦点留在容器上。每次 activeIndex 变化后,通过 nextTick 等待 DOM 更新,再对对应项调用 focus()。这样可以确保读屏软件和视觉焦点同步。下面是一段方向键处理的核心代码。

import { ref, nextTick } from 'vue'

const items = ref([])
const activeIndex = ref(-1)
const listRef = ref(null)

async function focusItem(index) {
  await nextTick()
  const target = listRef.value?.querySelectorAll('[role="menuitem"]')[index]
  target?.focus()
}

function onKeydown(event) {
  const current = activeIndex.value
  if (event.key === 'ArrowDown') {
    event.preventDefault()
    const next = (current + 1) % items.value.length
    activeIndex.value = next
    focusItem(next)
  } else if (event.key === 'ArrowUp') {
    event.preventDefault()
    const next = current <= 0 ? items.value.length - 1 : current - 1
    activeIndex.value = next
    focusItem(next)
  } else if (event.key === 'Escape') {
    closeMenu()
  }
}

如果焦点需要保持在某个输入框或容器上,可以使用 aria-activedescendant 替代真实焦点移动。容器设置 role="listbox",每个选项设置唯一 id,容器绑定 :aria-activedescendant="activeId"。读屏软件会读取当前活跃项,但键盘焦点始终在容器上。这种模式适合组合框和自动补全,因为它避免焦点在输入框与列表之间频繁跳动。

模态框还需要焦点陷阱。焦点陷阱的意思是当用户连续按 Tab 时,焦点只能在对话框、弹窗或抽屉内部循环,不能跑到背景页面。实现时需要拿到容器内所有可聚焦元素,并在第一个和最后一个元素上判断 Tab 与 Shift+Tab。下面给出一个可复用的 useFocusTrap 函数,它返回事件处理器,模板中绑定到容器即可。

function useFocusTrap(containerRef, isActive) {
  function getFocusable() {
    if (!containerRef.value) return []
    return Array.from(
      containerRef.value.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      )
    ).filter(el => !el.disabled)
  }

  function onKeydown(event) {
    if (event.key !== 'Tab') return
    const focusable = getFocusable()
    if (focusable.length === 0) return
    const first = focusable[0]
    const last = focusable[focusable.length - 1]
    const active = document.activeElement

    if (event.shiftKey && active === first) {
      event.preventDefault()
      last.focus()
    } else if (!event.shiftKey && active === last) {
      event.preventDefault()
      first.focus()
    }
  }

  return { onKeydown, getFocusable }
}

除了焦点陷阱,关闭浮层后必须把焦点还给触发按钮。如果触发按钮已不在 DOM 中,则应把焦点移到下一个合理位置。忽略焦点返回会让键盘用户迷失位置,这是移动端和桌面端都常见的无障碍问题。

三、用 composable 沉淀无障碍逻辑

无障碍逻辑如果分散在每个组件里,容易出现复制粘贴后行为不一致。Vue 3 的组合式函数可以很好地解决这个问题。比如折叠面板、菜单、弹窗都需要 Esc 关闭和焦点返回,可以抽成 useDisclosure;模态框需要焦点陷阱,可以抽成 useFocusTrap;列表组件需要方向键和 Home/End,可以抽成 useListNavigation。组件里只绑定返回值,业务代码和无障碍代码互不干扰。

下面是一个 useDisclosure 的实现。它接收面板 id,维护 isOpen 状态,并自动在 keydown 中处理 Esc。调用 close() 时会把焦点恢复到触发器,这是很多设计系统容易忽略的一步。

import { ref, onMounted, onBeforeUnmount } from 'vue'

export function useDisclosure(id, defaultOpen = false) {
  const isOpen = ref(defaultOpen)
  const triggerRef = ref(null)
  const panelRef = ref(null)

  function open() {
    isOpen.value = true
  }

  function close() {
    isOpen.value = false
    triggerRef.value?.focus()
  }

  function toggle() {
    isOpen.value = !isOpen.value
  }

  function onDocumentKeydown(event) {
    if (event.key === 'Escape' && isOpen.value) {
      close()
    }
  }

  onMounted(() => document.addEventListener('keydown', onDocumentKeydown))
  onBeforeUnmount(() => document.removeEventListener('keydown', onDocumentKeydown))

  return { isOpen, open, close, toggle, triggerRef, panelRef, panelId: id }
}

在模板中使用这个 composable 时,只需要把 triggerRef 绑定到按钮,把 panelRef 绑定到面板,再把 isOpen 和事件处理器同步到模板。属性如 aria-expanded、aria-controls 可以直接使用返回值。这样不同组件只要调用同一个函数,就能获得一致的键盘行为。复杂组件树还可以通过 provide/inject 共享同一个状态,例如标签页的 Tab、TabPanel 兄弟组件之间需要知道当前激活项,用 Symbol key 传递 context 比散落的 props 更清晰。

自定义指令是另一种封装方式,适合操作原生 DOM 的焦点陷阱、点击外部关闭等场景。指令能在元素挂载和卸载时自动绑定与清理事件,不过要注意在指令内部访问响应式状态不如 composable 方便。一般建议优先使用 composable,只有在需要直接操作 DOM 且不依赖组件状态时再使用自定义指令。

四、动态通知、测试与常见误区

单页应用经常通过异步请求更新数据,读屏软件不会自动感知 DOM 变化。这时需要在页面中放置一个 aria-live 区域,当有需要通知的消息时更新其文本。礼貌级别 polite 会等待当前朗读结束,assertive 会立即打断,只用于紧急情况,如表单严重错误或会话即将过期。Vue 3 中可以创建一个全局消息 store,所有组件调用 notify() 更新 live region,使用 requestAnimationFrame 或两次赋值技巧,确保同一消息连续触发也能被读屏软件捕获。

<template>
  <div class="sr-only" aria-live="polite" aria-atomic="true">
    {{ liveMessage }}
  </div>
</template>

<script setup>
import { ref } from 'vue'
const liveMessage = ref('')

function notify(message) {
  liveMessage.value = ''
  requestAnimationFrame(() => {
    liveMessage.value = message
  })
}
</script>

自动化测试是保障手段,但不能完全替代手动验证。推荐在 ESLint 配置中加入 eslint-plugin-vuejs-accessibility,它能在开发阶段提示缺少 alt、误用 click、角色错误等问题。集成 axe-core 到单元测试或端到端测试,可以扫描渲染后的 DOM 中的常见 ARIA 误用。浏览器扩展 Accessibility Insights 和 Lighthouse 也能提供快速检查。手动测试清单至少包括:Tab 能否按顺序到达所有可操作元素;焦点是否有明显样式;方向键在复合组件中是否按预期移动;Esc 是否能关闭浮层并恢复焦点;打开读屏软件后元素名称、角色、状态是否朗读正确。

实际开发中,最常见的误区是把 @click 放在 <div> 上,再加一个 role="button" 就认为它是按钮。其实原生的 <button> 已经提供 Enter 和 Space 支持,除非有非常特殊的原因,否则应当直接使用原生元素。同样,不要给 aria-hidden="true" 的容器内部保留可聚焦元素,否则键盘用户会聚焦到看不见的内容。动态内容变化时只改 DOM 而不设置 aria-live,会让读屏软件用户错失状态变化。最后,ARIA 属性名和状态值必须与真实交互一致,例如菜单打开后 aria-expanded 还保持 false,比不加 ARIA 更糟糕。Vue 3 的双向绑定和响应式系统可以很好地同步这些状态,关键是要在开发流程中把无障碍作为正常功能来验收,而不是事后补救。

Vue 3ARIA键盘导航修改时间:2026-09-29 13:48:01

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0929/63423.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。