Vue 基础体系 · 第 16/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 可访问性与交互质量:语义、键盘、焦点、ARIA 和动效

可访问性不是给页面“补几个 aria-* 属性”,而是让不同输入方式、不同感知能力和不同辅助技术下的用户,都能完成同一项任务。对一个 Vue 组件来说,至少要同时满足以下条件:

  1. 语义可理解:浏览器和辅助技术能知道这是按钮、链接、输入框、对话框还是导航区域。
  2. 操作可到达:不依赖鼠标,键盘用户也能发现并完成操作。
  3. 焦点可追踪:用户始终知道键盘输入将作用于哪里,打开、关闭、校验失败后焦点不会丢失。
  4. 状态可感知:展开、选中、忙碌、禁用和错误等状态能传递给辅助技术。
  5. 变化不过度打扰:动画、自动更新和提示不会造成眩晕、迷失或阅读中断。
  6. 功能与表现分离:减少动效或使用屏幕阅读器时,业务流程仍然成立。

这些条件相互关联。一个视觉上像按钮的 <div>,即使添加了 role="button",也不会自动获得 Enter、Space 处理、焦点进入能力和按钮的默认语义。一个打开后没有移动焦点的弹窗,也许视觉效果正确,但键盘用户仍然停留在背景页面。一个颜色变红的错误输入,如果没有文本错误和程序化关联,辅助技术可能完全无法知道错误原因。


一、先区分三个层次:语义、行为和表现

一个交互控件可以抽象为:

C=(S,O,P)C = (S, O, P)

其中:

  • SS 是语义:控件是什么,以及当前处于什么状态;
  • OO 是操作行为:键盘、指针和辅助技术如何触发状态变化;
  • PP 是表现:颜色、尺寸、动画和布局如何呈现状态。

可访问性交互要求三者一致。例如一个“展开筛选器”的控件:

  • 语义上应是按钮,而不是普通文本;
  • 行为上应能通过鼠标和键盘切换;
  • 状态上应能表达当前是否展开;
  • 表现上可以显示箭头旋转,但箭头不是唯一的状态信息。

如果只改变 PP,不改变 SSOO,就会出现“看起来能用、实际上不能用”的控件。

1. 原生元素优先

HTML 原生元素已经提供了一组经过浏览器和辅助技术约定的语义与行为:

需求 优先使用
页面跳转 <a href="...">
提交操作 <button type="submit">
复选状态 <input type="checkbox">
单选状态 <input type="radio">
文本输入 <label> + <input>
标题层级 <h1><h6>
页面区域 <nav><main><aside><footer>
数据表格 <table><caption><th scope="...">

原生元素的价值不仅在于屏幕阅读器能识别,还在于浏览器通常已经实现了焦点、键盘事件、表单提交和平台级交互。

下面两个元素的视觉效果可以完全相同,但行为不同:

<!-- 推荐:原生按钮 -->
<button type="button" @click="removeItem">
  删除
</button>

<!-- 高风险:只是一个普通元素 -->
<div class="button" @click="removeItem">
  删除
</div>

第二个元素缺少至少四件事:

  1. 默认不可通过 Tab 进入;
  2. 没有按钮语义;
  3. 不会自动响应 Enter 和 Space;
  4. 辅助技术无法把它当作按钮报告给用户。

如果确实无法使用原生元素,补齐行为至少需要:

<div
  role="button"
  tabindex="0"
  @click="removeItem"
  @keydown.enter="removeItem"
  @keydown.space.prevent="removeItem"
>
  删除
</div>

但这只是“补救”,并不等价于 <button>。例如还要考虑禁用状态、焦点样式、表单中的默认行为、不同浏览器对按键事件的细节,以及点击和键盘触发是否会重复执行。能使用原生按钮时,不应为了样式改用 div

2. 链接和按钮不是同一种操作

这是常见的语义错误:

  • 链接:把用户带到一个资源或 URL;
  • 按钮:改变当前页面状态,或执行某个动作。
<a href="/settings">设置</a>
<button type="button" @click="openSettingsPanel">打开设置面板</button>

把所有交互都写成按钮,会丢失浏览器打开新标签页、复制链接地址和历史导航等能力;把所有交互都写成链接,则会让本应是动作的控件产生错误的导航语义。


二、Vue 模板中的语义不是“组件名”自动产生的

Vue 组件名不会自动变成 HTML 语义。下面的 ActionButton 如果最终渲染成 div,浏览器看到的仍然是 div

<ActionButton>保存</ActionButton>

组件库需要明确自己的根元素和状态契约。例如一个按钮组件至少应传递:

<script setup lang="ts">
const props = withDefaults(defineProps<{
  type?: 'button' | 'submit' | 'reset'
  disabled?: boolean
  loading?: boolean
}>(), {
  type: 'button',
  disabled: false,
  loading: false
})
</script>

<template>
  <button
    :type="props.type"
    :disabled="props.disabled || props.loading"
  >
    <span v-if="props.loading" aria-hidden="true" class="spinner"></span>
    <span>{{ props.loading ? '保存中…' : '保存' }}</span>
  </button>
</template>

这里的关键点有三个:

  • type="button" 防止按钮位于表单中时意外提交;
  • 原生 disabled 会影响键盘焦点和表单行为;
  • 加载状态有可读文本,而不是只显示一个旋转图标。

aria-disabled="true" 与原生 disabled 不等价。前者通常仍然允许元素获得焦点,也不会自动阻止点击处理;它适用于需要保留焦点或自定义控件的场景,但必须自行阻止操作。能使用原生 disabled 时应优先使用它。


三、可访问名称:用户必须知道“这是什么”

可访问名称是辅助技术用来描述控件的名称。例如屏幕阅读器可能将按钮读成“保存,按钮”。名称通常来自:

  1. 元素内部可见文本;
  2. <label> 与表单控件的关联;
  3. aria-labelledby 指向的可见文本;
  4. aria-label 提供的文本。

优先使用可见文本或 <label>,因为它们同时服务于视觉用户、语音输入用户和辅助技术。

<label :for="emailId">邮箱地址</label>
<input
  :id="emailId"
  v-model="email"
  type="email"
  autocomplete="email"
/>
const emailId = 'account-email'

对只有图标的按钮,必须提供名称:

<button type="button" aria-label="关闭" @click="close">
  <svg aria-hidden="true" viewBox="0 0 24 24">
    <!-- 图标路径 -->
  </svg>
</button>

aria-hidden="true" 表示该图标本身不参与辅助技术的名称计算。按钮的 aria-label="关闭" 才是名称。

不要同时让多个来源表达冲突名称:

<!-- 不推荐:视觉文本和 aria-label 不一致 -->
<button aria-label="删除记录">移除</button>

可见文本是“移除”,程序名称却是“删除记录”,这会造成语音操作和视觉提示不一致。更好的做法是统一文本,或使用 aria-labelledby 指向同一段可见文本。


四、键盘可用性:从 Tab 顺序到控件内部按键

1. Tab 顺序是焦点导航图

键盘用户通常使用 Tab 在可聚焦元素之间移动。理想情况下,DOM 顺序、视觉顺序和任务顺序一致:

<header>...</header>
<main>
  <h1>订单</h1>
  <button>创建订单</button>
  <a href="/orders/1">查看第一笔订单</a>
</main>

不应依赖大量正数 tabindex 重排焦点顺序:

<!-- 不推荐 -->
<button tabindex="3">第三个</button>
<button tabindex="1">第一个</button>

正数 tabindex 会创建一套脱离 DOM 的焦点顺序,后续插入控件时很容易破坏整个页面。常用规则是:

  • 默认可聚焦元素使用 tabindex="0" 或不设置;
  • tabindex="-1" 表示可通过脚本聚焦,但不进入普通 Tab 顺序;
  • 避免正数 tabindex

tabindex="-1" 对标题、错误摘要和弹窗容器尤其有用,因为它们需要被脚本聚焦,但不应成为普通 Tab 停靠点。

2. 复合控件需要定义内部键盘模型

并不是所有控件都应该让每个子元素都进入 Tab 顺序。以选项卡为例:

  • Tab 进入当前选中的选项卡;
  • 左右方向键切换选项卡;
  • Home 跳到第一个;
  • End 跳到最后一个;
  • 面板与选项卡通过 ID 关联。

Vue 示例:

<script setup lang="ts">
import { ref } from 'vue'

const tabs = [
  { id: 'overview', label: '概览' },
  { id: 'activity', label: '活动' },
  { id: 'settings', label: '设置' }
] as const

const active = ref(0)

function move(delta: number) {
  active.value = (active.value + delta + tabs.length) % tabs.length
}

function onKeydown(event: KeyboardEvent) {
  switch (event.key) {
    case 'ArrowRight':
      event.preventDefault()
      move(1)
      break
    case 'ArrowLeft':
      event.preventDefault()
      move(-1)
      break
    case 'Home':
      event.preventDefault()
      active.value = 0
      break
    case 'End':
      event.preventDefault()
      active.value = tabs.length - 1
      break
  }
}
</script>

<template>
  <div class="tabs">
    <div role="tablist" aria-label="账户信息">
      <button
        v-for="(tab, index) in tabs"
        :id="`tab-${tab.id}`"
        :key="tab.id"
        type="button"
        role="tab"
        :aria-selected="active === index"
        :aria-controls="`panel-${tab.id}`"
        :tabindex="active === index ? 0 : -1"
        @click="active = index"
        @keydown="onKeydown"
      >
        {{ tab.label }}
      </button>
    </div>

    <section
      v-for="(tab, index) in tabs"
      v-show="active === index"
      :id="`panel-${tab.id}`"
      :key="`panel-${tab.id}`"
      role="tabpanel"
      :aria-labelledby="`tab-${tab.id}`"
      tabindex="0"
    >
      <h2>{{ tab.label }}</h2>
      <p>这里是 {{ tab.label }} 面板。</p>
    </section>
  </div>
</template>

这里使用的是 roving tabindex 模式:

  • 当前选项卡 tabindex="0"
  • 其他选项卡 tabindex="-1"
  • 方向键只在选项卡集合内部移动;
  • aria-selected 表示当前状态;
  • aria-controlsaria-labelledby 建立选项卡与面板关系。

如果产品要求“方向键切换后立即加载面板”,上例的 active 更新即可;如果采用“手动激活”,方向键只移动一个内部焦点索引,Enter 或 Space 才更新 active。这两种模式都可以,但必须与视觉提示和键盘说明保持一致,不能方向键移动了焦点而页面仍显示旧面板。


五、焦点管理:焦点必须跟随界面状态变化

焦点是当前接收键盘输入的元素。鼠标用户可以直接点击目标,键盘用户依赖焦点知道操作位置。因此,动态界面改变时,焦点移动不是装饰,而是状态转换的一部分。

典型状态转换包括:

关闭菜单 --Enter/Space--> 打开菜单,焦点进入菜单
菜单项 --选择--> 菜单关闭,焦点回到触发按钮
关闭弹窗 --Escape/关闭按钮--> 焦点回到打开弹窗的元素
提交失败 --> 焦点移动到错误摘要或第一个错误字段
路由切换 --> 焦点移动到新页面主标题或 main

1. Vue 的 DOM 更新是异步的

修改响应式状态后,Vue 通常会批量更新 DOM。下面的代码可能在 open = true 后立刻找不到弹窗内部元素:

open.value = true
dialogInput.value?.focus() // 可能仍然是旧 DOM

应等待下一次 DOM 更新:

import { nextTick, ref } from 'vue'

const open = ref(false)
const dialogInput = ref<HTMLInputElement | null>(null)

async function showDialog() {
  open.value = true
  await nextTick()
  dialogInput.value?.focus()
}

nextTick() 只保证 Vue 的更新已进入对应的 DOM 刷新阶段,并不保证异步组件、图片布局或浏览器绘制全部完成。大多数焦点迁移只需要它;不要用任意 setTimeout 代替,因为那会引入时序竞态。

2. 弹窗至少要处理三个焦点问题

一个模态弹窗通常需要:

  1. 打开后把焦点移入弹窗;
  2. 弹窗打开期间,背景内容不能被键盘继续操作;
  3. 关闭后把焦点还给触发弹窗的元素。

焦点路径可以表示为:

sequenceDiagram
    participant U as 用户
    participant T as 触发按钮
    participant D as 对话框
    participant B as 背景页面

    U->>T: Enter / Space
    T->>D: open = true
    D->>D: nextTick 后聚焦标题或第一个控件
    U->>D: Tab / Shift+Tab
    D->>D: 焦点限制在对话框内部
    U->>D: Escape 或点击关闭
    D->>D: open = false
    D->>T: 恢复触发按钮焦点

一个简化的 Vue 实现如下:

<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue'

const open = ref(false)
const trigger = ref<HTMLButtonElement | null>(null)
const dialog = ref<HTMLElement | null>(null)

function openDialog() {
  open.value = true
  void nextTick(() => {
    dialog.value
      ?.querySelector<HTMLElement>('[data-autofocus], button, input, [tabindex="0"]')
      ?.focus()
  })
}

function closeDialog() {
  open.value = false
  void nextTick(() => trigger.value?.focus())
}

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    event.preventDefault()
    closeDialog()
  }
}

function onBackdropClick(event: MouseEvent) {
  if (event.target === event.currentTarget) {
    closeDialog()
  }
}

function onFocusIn(event: FocusEvent) {
  const root = dialog.value
  if (!root || root.contains(event.target as Node)) return

  root.querySelector<HTMLElement>(
    '[data-autofocus], button, input, [tabindex="0"]'
  )?.focus()
}

function addListeners() {
  document.addEventListener('keydown', onKeydown)
  document.addEventListener('focusin', onFocusIn)
}

function removeListeners() {
  document.removeEventListener('keydown', onKeydown)
  document.removeEventListener('focusin', onFocusIn)
}

function onOpenChanged(value: boolean) {
  if (value) addListeners()
  else removeListeners()
}

onBeforeUnmount(removeListeners)
</script>

<template>
  <button ref="trigger" type="button" @click="openDialog">
    编辑个人资料
  </button>

  <div
    v-if="open"
    class="backdrop"
    @mousedown="onBackdropClick"
  >
    <section
      ref="dialog"
      class="dialog"
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      tabindex="-1"
      @focusin="onFocusIn"
    >
      <h2 id="dialog-title" tabindex="-1">编辑个人资料</h2>

      <label>
        昵称
        <input data-autofocus type="text" />
      </label>

      <div class="actions">
        <button type="button" @click="closeDialog">取消</button>
        <button type="button" @click="closeDialog">保存</button>
      </div>
    </section>
  </div>
</template>

这个示例展示了机制,但生产实现还要补充更完整的焦点循环:focusin 发现焦点离开后直接聚焦第一个元素,无法保持 Shift+Tab 到最后一个元素的自然行为。更严谨的实现应收集弹窗内所有可聚焦元素,在 Tab 到边界时循环:

function trapFocus(event: KeyboardEvent) {
  if (event.key !== 'Tab' || !dialog.value) return

  const elements = Array.from(
    dialog.value.querySelectorAll<HTMLElement>(
      'a[href], button:not([disabled]), input:not([disabled]), ' +
      'select:not([disabled]), textarea:not([disabled]), ' +
      '[tabindex]:not([tabindex="-1"])'
    )
  )

  if (elements.length === 0) {
    event.preventDefault()
    dialog.value.focus()
    return
  }

  const first = elements[0]
  const last = elements[elements.length - 1]

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

然后将 @keydown="onKeydown" 中的逻辑扩展为调用 trapFocus(event)。焦点陷阱的风险是:如果代码在弹窗关闭、组件卸载或路由切换后仍保留全局监听器,整个页面会无法操作。因此监听器必须与 open 状态绑定,并在 onBeforeUnmount 中清理。

3. 原生 <dialog> 的边界

现代浏览器提供了 <dialog>showModal(),它们可以提供部分模态行为:

const dialogEl = ref<HTMLDialogElement | null>(null)

function openNativeDialog() {
  dialogEl.value?.showModal()
}

但项目仍需验证浏览器版本、焦点初始位置、关闭后的焦点恢复、表单关闭值以及组件卸载时的清理行为。<dialog> 减少了手写模态逻辑,并不意味着可以忽略可访问名称、标题关联和键盘测试。若使用 aria-modal="true" 的自定义弹窗,则必须确保背景实际不可操作;属性本身不会替你阻止背景焦点。


六、ARIA:补充语义,不是替代 HTML

ARIA,即 Accessible Rich Internet Applications,提供角色、状态和属性,让自定义控件暴露给辅助技术。可以把它理解为:

原生 HTML 语义 = 已实现的语义 + 部分默认行为
ARIA          = 对语义树的补充或修正
JavaScript    = 自定义行为的实现

因此:

<div role="button">保存</div>

只补充了“它被声明为按钮”的语义,没有自动补充按钮行为。

1. 角色、属性和状态

以展开/折叠按钮为例:

<button
  type="button"
  :aria-expanded="expanded"
  aria-controls="filter-panel"
  @click="expanded = !expanded"
>
  筛选条件
</button>

<section
  v-show="expanded"
  id="filter-panel"
>
  ...
</section>

这里:

  • aria-expanded 表达当前是否展开;
  • aria-controls 指向受控制区域;
  • v-show 保留节点,只切换显示状态;
  • 真正的行为仍由 @click 实现。

如果使用 v-if,关闭时面板节点会被销毁。此时 aria-controls 指向的元素暂时不存在,通常仍可接受,但测试工具和自定义脚本需要正确处理这种生命周期差异。

常用状态包括:

<button aria-pressed="true">已收藏</button>
<div aria-busy="true">正在加载</div>
<input aria-invalid="true" aria-describedby="email-error">

不要把状态只编码在颜色或图标中。例如“已收藏”不能只靠黄色星标表示,还应有可读的按钮名称或 aria-pressed 状态。

2. 不要重复或错误使用 ARIA

这些写法有明显风险:

<!-- 没有必要:原生按钮已经有 button 语义 -->
<button role="button">保存</button>

<!-- 错误:把普通内容声明成按钮,却没有实现按钮行为 -->
<p role="button">保存</p>

<!-- 错误:aria-expanded 的值与实际显示状态不一致 -->
<button aria-expanded="false">收起</button>
<div>内容仍然可见</div>

ARIA 状态必须与真实 DOM 和交互状态保持一致。可以把一致性写成一个不变量:

ariaState(t)=renderedState(t)\text{ariaState}(t) = \text{renderedState}(t)

在任意时刻 tt,辅助技术读到的状态必须对应用户实际看到、实际能操作的状态。否则用户会得到错误的界面模型。

3. 动态提示与 live region

aria-live 用于通知辅助技术某个区域内容发生变化:

<p aria-live="polite">
  {{ statusMessage }}
</p>

polite 通常表示等当前语音内容结束后再播报;assertive 会更强地打断当前内容,应保留给真正紧急的信息。

不应把大段页面内容放在高频 aria-live 区域中:

<!-- 风险:每次输入都可能触发播报 -->
<div aria-live="assertive">
  {{ searchResults }}
</div>

搜索建议、计数器和校验提示应根据任务重要性选择更新频率,并避免每次按键都产生大量播报。aria-live 也不是错误处理的替代品,错误字段仍需要可见错误文本和焦点策略。


七、表单可访问性:标签、错误、忙碌和提交状态

表单是语义、焦点和状态最容易同时出错的场景。一个输入控件至少需要:

  • 可访问名称;
  • 必要时的帮助文本;
  • 错误状态;
  • 错误文本与字段的程序化关联;
  • 提交中状态;
  • 提交失败后的焦点路径。

下面是一个 Vue 3 + TypeScript 的最小示例:

<script setup lang="ts">
import { computed, ref } from 'vue'

const email = ref('')
const submitting = ref(false)
const submitted = ref(false)
const serverError = ref('')

const emailError = computed(() => {
  if (!email.value) return '请输入邮箱地址'
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
    return '请输入有效的邮箱地址'
  }
  return ''
})

async function submit() {
  submitted.value = true
  serverError.value = ''

  if (emailError.value) return

  submitting.value = true
  try {
    const response = await fetch('/api/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: email.value })
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }
  } catch {
    serverError.value = '提交失败,请稍后重试。'
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <form
    novalidate
    :aria-busy="submitting"
    @submit.prevent="submit"
  >
    <div
      v-if="serverError"
      id="form-error"
      role="alert"
    >
      {{ serverError }}
    </div>

    <label for="subscribe-email">邮箱地址</label>
    <input
      id="subscribe-email"
      v-model="email"
      type="email"
      autocomplete="email"
      :aria-invalid="submitted && Boolean(emailError)"
      :aria-describedby="submitted && emailError ? 'email-error' : undefined"
    >

    <p
      v-if="submitted && emailError"
      id="email-error"
      class="error"
    >
      {{ emailError }}
    </p>

    <button type="submit" :disabled="submitting">
      {{ submitting ? '提交中…' : '提交' }}
    </button>
  </form>
</template>

数据流是:

用户输入
  ↓
email 响应式状态
  ↓
emailError computed
  ↓
aria-invalid / aria-describedby / 错误文本
  ↓
辅助技术和视觉界面获得一致状态

这里没有在用户尚未提交时立即显示错误,避免用户刚聚焦字段就被错误打断。具体时机应由表单任务决定:实时校验适合密码强度等即时反馈,提交时校验适合必填字段和服务端规则。

生产代码通常还要在校验失败后把焦点移动到错误摘要或第一个错误字段:

const firstInvalid = document.querySelector<HTMLElement>(
  '[aria-invalid="true"]'
)
firstInvalid?.focus()

如果直接聚焦输入框,用户可能只听到“邮箱地址,编辑框,错误”,却不知道表单还有几个错误。错误摘要适合先说明整体结果,再通过链接跳转到对应字段;单字段错误则必须关联到字段本身。

异步提交还存在并发问题。用户连续点击、网络超时、组件卸载都可能导致旧请求覆盖新状态。至少应在提交期间禁用提交按钮,并在组件卸载或请求取消时避免更新已销毁组件。若允许重新提交,则应为请求建立顺序或取消机制,保证“最后一次有效操作”的状态不会被旧响应覆盖。


八、焦点样式:可见不是可选项

浏览器默认焦点轮廓通常比自定义样式可靠。不要直接写:

*:focus {
  outline: none;
}

这会让键盘用户失去位置线索。如果设计需要替换样式,应只在明确的键盘焦点场景下调整:

:where(button, a, input, select, textarea, [tabindex]):focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 3px;
}

:focus-visible 是现代浏览器提供的伪类,用来根据输入方式决定是否显示焦点指示。它比“所有焦点都显示”更符合视觉设计,但不能假设所有旧浏览器都支持;若项目有兼容范围要求,应使用回退规则并进行实际验证。

焦点指示器还必须满足几个实际条件:

  • 不被 overflow: hidden 裁剪;
  • 与背景有足够对比;
  • 不能只靠颜色变化;
  • 在按钮按下、禁用和高对比模式下仍可识别;
  • 自定义组件的焦点应落在真正可操作的元素上,而不是只给外层容器加轮廓。

九、动效:状态变化可以有动画,但不能依赖动画完成任务

Vue 的 <Transition> 只负责过渡类名和生命周期,不会自动解决可访问性问题:

<Transition name="fade">
  <div v-if="visible" class="notice">
    已保存
  </div>
</Transition>

CSS:

.fade-enter-active,
.fade-leave-active {
  transition: opacity 180ms ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .fade-enter-active,
  .fade-leave-active {
    transition-duration: 1ms;
  }
}

prefers-reduced-motion 是操作系统或用户代理暴露的偏好设置。它表达“减少非必要运动”,不是“禁止所有视觉变化”。因此:

  • 可以移除位移、缩放、视差和连续旋转;
  • 可以保留即时出现、颜色变化或短暂淡入;
  • 不应把重要内容只放在动画结束后才可访问;
  • 不应因为减少动效而取消状态反馈。

一个加载旋转器尤其需要注意:

<div v-if="loading" class="loading-status" aria-live="polite">
  <span class="spinner" aria-hidden="true"></span>
  正在加载
</div>
.spinner {
  animation: spin 800ms linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: none;
  }
}

文字“正在加载”承担信息传递,旋转器只是视觉表现。若只保留旋转动画,屏幕阅读器用户和关闭动效的用户都可能无法知道页面正在工作。

动效与焦点的时序

关闭一个带退出动画的弹窗时,不能过早销毁 DOM,也不能在用户仍看到弹窗时把焦点放到背景。可以按以下顺序处理:

  1. 标记逻辑状态为关闭;
  2. 保持退出过渡期间的焦点策略;
  3. 动画完成后卸载弹窗;
  4. 将焦点恢复到触发按钮;
  5. 如果触发按钮已被删除,则聚焦合理的备用目标。

如果使用 <Transition @after-leave="restoreFocus">,恢复焦点应放在 after-leave,而不是刚设置 open = false 时。否则焦点可能落到仍被视觉覆盖的背景元素,造成感知和实际焦点不一致。


十、隐藏、卸载和可操作性必须一致

Vue 中常见的显示方式有不同语义:

v-if

<div v-if="open">...</div>

节点不存在于 DOM。适合需要销毁内容、清理状态或避免隐藏内容被辅助技术访问的场景。

v-show

<div v-show="open">...</div>

节点仍在 DOM 中,通常通过 CSS 隐藏。适合频繁切换且希望保留内部状态的内容,但必须确认隐藏状态不会继续进入焦点顺序或被辅助技术读取。

CSS 透明

.panel {
  opacity: 0;
}

透明不代表不可操作。元素可能仍然占据布局、接收点击、获得焦点并被辅助技术读取。单纯把 opacity 设为 0 不是隐藏交互内容的方法。

可以从一致性角度判断:

visible=focusable=operable\text{visible} = \text{focusable} = \text{operable}

这不是每个场景都必须严格相等的字面规则,但至少要避免以下矛盾:

  • 视觉隐藏但仍可 Tab 到达;
  • 宣布已关闭但内容仍可点击;
  • 声明 aria-hidden="true",内部却存在当前焦点;
  • 声明弹窗为模态,但背景仍可获得焦点。

aria-hidden="true" 会从辅助技术语义树中隐藏内容,但不会自动移除键盘焦点和鼠标交互,因此不能用它代替真正的禁用或卸载。


十一、组件应把可访问性状态作为接口的一部分

可访问性不是组件内部的隐式副作用,而应进入组件设计契约。例如菜单组件需要明确:

type MenuProps = {
  id: string
  label: string
  open: boolean
  disabled?: boolean
}

触发按钮和菜单之间至少有以下状态关系:

<button
  :id="`${id}-trigger`"
  type="button"
  :aria-expanded="open"
  :aria-controls="`${id}-menu`"
  :disabled="disabled"
>
  {{ label }}
</button>

<ul
  v-show="open"
  :id="`${id}-menu`"
  role="menu"
  :aria-labelledby="`${id}-trigger`"
>
  ...
</ul>

组件库还必须规定:

  • 使用者是否传入稳定且唯一的 id
  • 默认焦点落在哪个元素;
  • Escape 是否关闭;
  • 关闭后焦点回到哪里;
  • 加载时是 disabled 还是 aria-disabled
  • 错误文本如何与字段关联;
  • label 是可见文本、aria-label 还是 aria-labelledby
  • 是否支持 prefers-reduced-motion

ID 不能在每次渲染时随机生成,否则 SSR 场景可能出现服务端和客户端 hydration 不一致。可以由父组件传入稳定 ID,或使用项目当前 Vue 版本提供的稳定 ID 能力;版本敏感的 API 应先对照对应 Vue API 文档确认,不应自行假设所有 Vue 3 小版本都有相同接口。


十二、测试:自动化检查只能发现一部分问题

可访问性测试应覆盖语义、键盘、焦点和动态状态,而不是只运行一次静态扫描。

1. 手工键盘测试路径

以弹窗为例,应逐步验证:

  1. 通过 Tab 到达打开按钮;
  2. 通过 Enter 或 Space 打开;
  3. 焦点进入弹窗;
  4. Tab 和 Shift+Tab 不会逃到背景;
  5. Escape 关闭;
  6. 焦点回到打开按钮;
  7. 按钮被删除或页面切换时,焦点有备用位置;
  8. 关闭减少动效后,功能和提示仍成立。

以表单为例:

  1. 每个输入框是否有明确名称;
  2. 错误出现时是否有文本;
  3. 错误文本是否通过 aria-describedby 关联;
  4. 提交中是否阻止重复提交;
  5. 服务端错误是否能被感知;
  6. 校验失败后用户能否快速到达第一个错误。

2. 浏览器和辅助技术检查

浏览器开发者工具通常可以查看:

  • Accessibility Tree;
  • 计算出的角色和名称;
  • 当前焦点元素;
  • aria-expandedaria-invalid 等状态;
  • 隐藏节点是否仍在语义树中。

自动化工具如 axe 类扫描器适合发现缺少标签、颜色对比、无效 ARIA 关系等结构问题,但它不能证明:

  • 键盘操作顺序符合任务;
  • 弹窗焦点被正确管理;
  • Escape 和关闭后的焦点行为正确;
  • 动画不会导致用户迷失;
  • 文案对真实用户足够清楚。

因此,自动检查是筛选器,不是可访问性结论。

3. Vue 组件测试

组件测试应把焦点和状态当作行为断言:

it('opens dialog and moves focus into it', async () => {
  const wrapper = mount(ProfileDialog)
  await wrapper.get('button').trigger('click')

  await nextTick()

  expect(document.activeElement).toBe(
    wrapper.get('[role="dialog"] input').element
  )
})

还应测试:

it('returns focus to trigger after close', async () => {
  const wrapper = mount(ProfileDialog)
  const trigger = wrapper.get('button').element

  await wrapper.get('button').trigger('click')
  await wrapper.get('[role="dialog"] button').trigger('click')

  expect(document.activeElement).toBe(trigger)
})

测试环境对真实布局、屏幕阅读器和浏览器焦点行为的模拟可能不完整,所以最终仍需要在目标浏览器中进行手工验证。


十三、常见失败表现与诊断顺序

失败一:点击正常,Tab 找不到控件

通常原因是用 divspan 模拟按钮,或设置了 tabindex="-1" 却没有脚本聚焦。

诊断顺序:

  1. 查看最终 DOM,而不是 Vue 组件名;
  2. 确认元素是否具有原生可聚焦语义;
  3. 检查计算后的 tabindex
  4. 使用键盘逐个按 Tab,观察焦点轮廓;
  5. 查看 Accessibility Tree 中的角色和名称。

失败二:弹窗打开了,但键盘仍能操作背景

通常原因是只做了遮罩,没有做焦点管理和背景隔离。aria-modal="true" 只是语义声明,不会替代焦点陷阱、inert 或节点管理。

诊断时检查:

  • 打开弹窗后 document.activeElement 是谁;
  • 连续按 Tab 是否能到背景;
  • 背景按钮是否仍响应 Enter;
  • 关闭后焦点是否回到触发元素;
  • 全局事件监听器是否在关闭和卸载时清理。

失败三:错误显示为红色,但屏幕阅读器不知道

原因通常是错误只写在 CSS、占位符或图标中,或者错误文本没有通过 foraria-describedby 等关系与字段关联。

应检查:

<input aria-invalid="true" aria-describedby="email-error">
<p id="email-error">请输入有效的邮箱地址</p>

placeholder 不能替代标签;它会在用户输入后消失,也不适合承载持续的字段名称。

失败四:动画结束后内容才出现,减少动效用户看不到变化

原因是把信息传递绑定到了动画本身。应将业务状态用文本、语义状态或 live region 表达,动画只负责表现过渡。

失败五:SSR 后焦点或 ID 行为异常

原因可能是随机 ID、服务端和客户端渲染结果不同,或组件在 hydration 前后执行了依赖浏览器对象的代码。涉及 windowdocumentdocument.activeElement 的逻辑应在客户端生命周期或事件处理器中执行,并确保服务端生成的结构稳定。


十四、把交互质量定义为可验证的不变量

对于一个可访问组件,可以为状态变化建立不变量,而不是只写“支持无障碍”。

以展开按钮为例:

expanded=true{aria-expanded="true"controlled panel visible=true\text{expanded} = \text{true} \Rightarrow \begin{cases} \text{aria-expanded} = "true" \\ \text{controlled panel visible} = \text{true} \end{cases}

以模态弹窗为例:

dialogOpen=true{focusdialog subtreebackground operable=falseEscapedialogOpen = false\text{dialogOpen} = \text{true} \Rightarrow \begin{cases} \text{focus} \in \text{dialog subtree} \\ \text{background operable} = \text{false} \\ \text{Escape} \to \text{dialogOpen = false} \end{cases}

以表单错误为例:

fieldInvalid=true{visible error text existsinput aria-invalid="true"input described by error text\text{fieldInvalid} = \text{true} \Rightarrow \begin{cases} \text{visible error text exists} \\ \text{input aria-invalid} = "true" \\ \text{input described by error text} \end{cases}

这些条件可以直接转化为组件测试、端到端测试和代码审查规则。这样,语义、键盘、焦点、ARIA 和动效就不再是互相独立的标签,而是同一个状态机的不同外部表现:

用户操作
  ↓
Vue 响应式状态变化
  ↓
DOM 结构、原生属性、ARIA 状态同步更新
  ↓
焦点和键盘路径同步调整
  ↓
视觉表现与动效表达变化
  ↓
用户和辅助技术获得同一份界面事实

Vue 负责响应式状态和 DOM 更新,但不会自动替组件决定语义、焦点归属、键盘模型或动画降级策略。工程质量的核心,是让这些决策显式存在,并通过原生 HTML、正确的 ARIA 关系、可验证的焦点路径和可关闭的动效共同实现。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。