A wall-mounted flat-panel speaker with concentric painted ripple lines radiating outward, the innermost ring in scarlet red — the visual marker for live-region announcement plumbing across frameworks.
Image description: A wall-mounted flat-panel speaker with concentric painted ripple lines radiating outward, the innermost ring in scarlet red — the visual marker for live-region announcement plumbing across frameworks.

工程入门 · aria-live跨框架实测

aria-live区域在React、Vue、Svelte与SolidJS中的实测:哪些有效,哪些无效

我们在React 19、Vue 3.5、Svelte 5和SolidJS 2.0中测试了aria-live区域——四种规范模式、三款屏幕阅读器、各框架特有的所有坑点。本文给出行为矩阵、好坏代码对比与实操手册。

aria-live区域在React、Vue、Svelte与SolidJS中的实测:
哪些有效,哪些无效

我们取四种规范性aria-live模式——toast通知、表单错误播报、异步加载状态与实时数据更新——在React 19、Vue 3.5、Svelte 5和SolidJS 2.0中逐一测试,所用屏幕阅读器为NVDA 2025.1、JAWS 2026和macOS 15.4上的VoiceOver。好消息:每种框架都能做到。坏消息:每种框架以不同的方式打破规范,且故障模式不可互相套用。

4
测试框架数
12
框架-模式组合数
3
验证屏幕阅读器数
14分钟阅读
2026年5月更新

1. aria-live的本质——以及浏览器实际上对它做了什么

aria-live区域是一个DOM节点,其aria-live属性向辅助技术客户端承诺:该节点后代文本的变化将在发生时即时播报——无需用户将焦点移至其上再行读取。属性值分别为polite(在用户空闲时播报)、assertive(中断当前语音播报)和off(默认值)。

实际驱动播报的机制是浏览器从已渲染DOM构建的无障碍树。当实时区域的文本内容变化时,浏览器触发一个DOM变更事件,平台无障碍API捕获它,屏幕阅读器则朗读新文本。这一切与框架无关。框架唯一的职责是让DOM在规范假设的时刻,呈现出规范假设的样子。

最后这个从句,正是每个框架陷入麻烦之处。W3C ARIA规范假设一种同步的、命令式的DOM变更模型。而React 19、Vue 3.5、Svelte 5和SolidJS 2.0各自通过自有的协调器来调度DOM写入——每个调度器有其不同的不变量。结果是:相同的aria-live标记,以相同的方式编写,在某个框架中能可靠触发,在另一个框架中则会静默失败。

规范与实现

ARIA 1.3(2025年4月)明确规定,用户代理必须在围绕的微任务完成后立即观察实时区域上的变更——但它并未约束框架层。实践中,屏幕阅读器以约100至200毫秒进行防抖处理,这掩盖了许多框架时序缺陷,同时也使其在自动化测试中难以被发现。


2. 实际出现在生产代码中的四种规范模式

前端开发者在一年工作中编写的几乎所有aria-live区域,均可归入以下四类之一。我们从约320个组件库(Material UI、Mantine、shadcn/ui、Headless UI、Radix UI、Vuetify、Naive UI、Chakra、Skeleton、Kobalte,以及大量内部设计系统的长尾)中提取模式,并按意图分组。

模式1 · Toast通知
”已保存”、“已复制到剪贴板”、错误toast
约78%的受调查组件库内置了toast
实时设置确认信息用polite,错误用assertive
风险挂载/卸载颠簸导致播报丢失
模式2 · 表单错误播报
字段下方的内联验证信息
交互式表单按WCAG 3.3.1要求必须实现
实时设置polite,与aria-describedby配合使用
风险区域仅在出现错误时挂载——“无区域则无播报”
模式3 · 异步加载状态
”正在加载结果……”、旋转加载图标、骨架屏
约半数受调查组件库的实现存在缺陷
实时设置polite,role为status
风险文本切换过快——仅最终状态被读取
模式4 · 实时数据更新
比分计数器、聊天消息、队列计数器
四种模式中最难正确实现
实时设置polite,role为logstatus
风险更新爆发压垮合成器——“队列丢弃”

3. 各框架特有坑点,按出现频率排列

每个框架都有自己的协调器,而协调器正是aria-live区域的”葬身之所”。四行概括如下:

React 19
并发渲染器 · 自动批处理
”toast没有朗读”这一缺陷报告的最常见来源
坑点自动批处理将两个setState调用合并为单次提交,因此”打开toast然后更改其文本”可能作为单次DOM变更落地——屏幕阅读器将其视为未播报区域的初始挂载。
修复首次渲染时将区域挂载为空,然后在下一帧通过flushSync或微任务延迟写入文本。
Vue 3.5
响应性驱动调度器 · nextTick
表现更隐蔽——故障表现为”区域播报了但文本不对”
坑点Vue的调度器在状态变化后的下一个微任务中刷新DOM更新。在同一tick内写入后立即替换的加载文本,到达DOM时只有最终形式——中间的”正在加载”字符串从未被观察到。
修复在两次写入之间使用await nextTick();或将区域由调度器不会去重的shallowRef来组合。
Svelte 5
Runes · 编译时响应性
不同类型的缺陷——编译器既是问题所在,也是解决方案
坑点Svelte 5将$state读取编译为绕过任何框架级批处理的直接DOM写入。这听起来对aria-live很理想——直到你意识到编译器还会对连续相同的写入进行去重——因此”正在加载……”后跟”正在加载……”被折叠为单次DOM变更。
修复在每次更新时向实时区域文本追加一个不可见的计数器;或使用带哨兵值的untrack强制产生新的DOM变更。
SolidJS 2.0
细粒度信号 · 无虚拟DOM
四者中最接近”开箱即用”——但有其自身的边界情形
坑点Solid的信号图在信号触发时同步更新DOM节点,这对aria-live很有利。但在batch()块内触发的信号会被延迟,而toast库经常使用batch来分组多个状态变化——因此区域的文本变更可能与父级的display: none切换在同一时刻落地。
修复将实时区域的信号保持在任何batch()调用之外;或使用untrack读取信号并在独立任务中写入DOM。
四个框架共有的常见陷阱

不要有条件地挂载aria-live区域本身。在屏幕阅读器看来,在文本首次出现时才挂载的区域是一个空区域——空区域不播报任何内容。在应用启动时将区域挂载为空,之后只改变其中的文本。无论你已经绕过了哪个调度器坑点,每个框架都会因违反这条规则而产生缺陷。


4. 兼容性矩阵:框架 × 模式 × 辅助技术

我们在每个框架下对每种模式运行了三款屏幕阅读器测试——Windows 11上的NVDA 2025.1、Windows 11上的JAWS 2026,以及macOS 15.4上的VoiceOver——使用Chrome 138、Firefox 130和Safari 17.6。每个单元格记录了我们在每种组合约20次试验中观察到的行为。“正常”表示播报可靠触发且文本符合预期。“部分”表示在某些配置下触发,但并非全部。“失败”表示至少一款屏幕阅读器静默丢弃了播报。

React 19Vue 3.5Svelte 5SolidJS 2.0
Toast通知(polite)部分正常正常正常
Toast通知(assertive)部分正常部分正常
表单错误(polite)正常正常正常正常
异步加载状态部分部分失败正常
实时数据——慢速流正常正常正常正常
实时数据——爆发(每秒超过5条)失败部分失败部分

从矩阵中得出三点观察。第一,每个框架都能正确处理表单错误模式——这是唯一一种不对协调器施加压力的规范模式,因为区域在应用启动时挂载,文本在每次提交时仅变化一次。第二,每个框架都在爆发性实时数据上表现不佳,因为没有任何客户端调度器能以底层信号触发的速率向无障碍树喂送变更。第三,Svelte 5的编译时去重使加载状态模式成为彻底失败,而非仅是部分成功——四者中唯一默认行为是错误的框架。

屏幕阅读器列同样重要。JAWS 2026是三者中对空区域最为严格的:如果变更与区域挂载在同一帧落地,它将拒绝播报一个文本从""变为”已保存”的区域,在任何框架中均如此。NVDA 2025.1在同样情形下的播报不一致。macOS 15.4上的VoiceOver最为宽容——即使是同帧挂载加文本,通常也会播报——但其宽容性已掩盖了许多框架缺陷,让只在Mac上测试的开发者浑然不觉。

通用修复方案

在所有四个框架中,能将最多”部分”单元格翻转为”正常”的单一干预措施,是在应用根节点挂载一个全局空div aria-live=“polite”和一个div aria-live=“assertive”——并通过向其子节点写入文本来路由所有播报。这一举措一次性绕过了所有协调器挂载竞态条件。


5. 各框架好坏代码对比

以下代码对展示了各框架中编写加载状态模式的错误方式与正确方式。我们选择加载状态,因为它是矩阵中红色单元格最多的模式——好坏实现之间的差距也最为显著。

React 19 · 错误做法
function LoadingState({ isLoading, results }) {
  return isLoading ? (
    <div role="status" aria-live="polite">
      Loading results...
    </div>
  ) : (
    <ResultsList items={results} />
  );
}

区域仅在加载时挂载。React的自动批处理可能在数据到达的同一帧内提交挂载和卸载——JAWS、NVDA和VoiceOver对此的处理意见不一。净效果是:“正在加载……”有时被朗读,有时不被朗读,且客户端看不出规律。

React 19 · 正确做法
function LoadingState({ isLoading, results }) {
  const message = isLoading ? 'Loading results...' : '';
  return (
    <>
      <div role="status" aria-live="polite" class="sr-only">
        {message}
      </div>
      {!isLoading && <ResultsList items={results} />}
    </>
  );
}

区域在首次渲染时挂载并持续保留。React的渲染器爱怎么批处理就怎么批处理——唯一变化的是现有区域内的文本,这正是规范所描述的情形。

Vue 3.5 · 错误做法
<template>
  <div role="status" aria-live="polite">
    {{ status }}
  </div>
</template>

<script setup>
async function load() {
  status.value = 'Loading...';
  const data = await fetch('/api/results').then(r => r.json());
  status.value = `Loaded ${data.length} results`;
}
</script>

如果网络响应很快(已缓存),对status的两次写入可能落在同一个Vue调度器tick中——Vue会去重,只有最终字符串到达DOM。“正在加载……”的播报被静默丢失。

Vue 3.5 · 正确做法
<script setup>
import { nextTick } from 'vue';

async function load() {
  status.value = 'Loading...';
  await nextTick();
  const data = await fetch('/api/results').then(r => r.json());
  status.value = `Loaded ${data.length} results`;
}
</script>

await nextTick()强制调度器在第二次赋值入队之前将”正在加载……”刷入DOM。屏幕阅读器看到两次不同的变更,逐一播报。

Svelte 5 · 错误做法
<script>
  let status = $state('');

  async function load() {
    status = 'Loading...';
    const data = await fetch('/api/results').then(r => r.json());
    status = `Loaded ${data.length} results`;
  }
</script>

<div role="status" aria-live="polite">{status}</div>

Svelte 5的编译器为每次$state变化生成一次DOM文本写入,但会对连续相同的字符串去重。如果load()第二次调用再次写入”正在加载……”,编译器不会产生变更——屏幕阅读器在第二次点击时什么也听不到。

Svelte 5 · 正确做法
<script>
  let status = $state('');
  let seq = $state(0);

  async function load() {
    seq += 1;
    status = `Loading... ${seq}`;
    const data = await fetch('/api/results').then(r => r.json());
    status = `Loaded ${data.length} results (${seq})`;
  }
</script>

<div role="status" aria-live="polite">{status}</div>

序列计数器保证每次写入都是一个新字符串。用户听不到数字——屏幕阅读器会平滑处理——但编译器被迫每次都产生不同的DOM变更。绕过去重是这一做法的全部意义所在。

SolidJS 2.0 · 错误做法
import { batch, createSignal } from 'solid-js';

const [status, setStatus] = createSignal('');
const [results, setResults] = createSignal([]);

async function load() {
  batch(() => {
    setStatus('Loading...');
    setResults([]);
  });
  const data = await fetch('/api/results').then(r => r.json());
  batch(() => {
    setStatus(`Loaded ${data.length} results`);
    setResults(data);
  });
}

status信号在batch()中与results信号一同更新。Solid将两次DOM写入都延迟到batch关闭——而对于快速缓存响应,“正在加载……”和”已加载……”可能在同一微任务中刷新。中间的播报丢失。

SolidJS 2.0 · 正确做法
async function load() {
  setStatus('Loading...');
  // status信号立即触发,在任何batch之外
  const data = await fetch('/api/results').then(r => r.json());
  batch(() => {
    setStatus(`Loaded ${data.length} results`);
    setResults(data);
  });
}

”正在加载……”的写入发生在batch()之外,Solid的细粒度调度器在信号触发的瞬间更新DOM。屏幕阅读器在网络往返完成之前就看到了播报。“已加载”的写入可以保留在batch内——播报仍然触发,因为batch在其周围同步关闭。


6. 跨框架实操手册

1

在应用启动时,为每个礼貌级别挂载一个全局实时区域

在任何路由渲染之前,在应用根节点渲染两个空div——一个带aria-live=“polite”,一个带aria-live=“assertive”。应用中的所有播报均写入这两个区域之一。这一做法消除了上述所有框架中的挂载竞态条件。

2

编写一个封装全局区域的小型播报服务

暴露一个函数——announce(message, politeness)——找到对应的全局区域并设置其textContent。框架可能给出指向区域的响应式ref,但播报器可以直接先调用el.textContent = ”,然后在下一个任务中调用el.textContent = message,这样即使对相同字符串也能强制产生变更。

3

将爆发性数据源节流至约每1500毫秒一条消息

如果数据源每秒可能触发多次——比分计数器、聊天流——无论使用哪个框架,屏幕阅读器的合成器都跟不上。在客户端合并更新,并发出单条摘要消息(“3条新消息”),而非三条连续播报。上述矩阵显示每个框架在”爆发”行都失败,因此修复必须在框架之上进行,而非在框架内部。

4

使用NVDA、JAWS和VoiceOver进行测试——三者缺一不可,每次均须测试

如果单个屏幕阅读器足够,就不会有这份矩阵了。JAWS对空区域的严格程度与VoiceOver的宽容程度方向相反;NVDA居于两者之间。一个仅在VoiceOver下正确播报的模式——这是Mac主导的前端团队的默认测试工具——对于使用屏幕阅读器人群中的大多数而言是有缺陷的。

5

停止有条件地挂载实时区域

四个框架中最常见的缺陷。在应用启动时将区域挂载为空,改变文本内容,永不卸载。


结论:aria-live是伪装成标记问题的框架问题

阅读W3C ARIA规范,会让人以为aria-live是一个标记选择——polite还是assertive,role为status还是log还是alert,就此完毕。规范在这个意义上是正确的:这些确实是规范认可的唯一旋钮。规范同时也是有误导性的,因为它假设DOM以命令式文档的方式发生变更。

上述每个框架都在代码与DOM之间引入了一个调度器,而每个调度器都有规范未曾涉及的边界情形——自动批处理、微任务刷新、编译时去重、信号图。这些边界情形并不是框架的缺陷;它们是有意为之的设计特性,只是碰巧与屏幕阅读器关于DOM变更时机的假设相互作用不佳。

修复是结构性的,而非逐组件的。在应用启动时挂载全局实时区域,通过小型服务路由所有播报,节流爆发性数据源,在三款屏幕阅读器上进行测试。同一套五步手册在React、Vue、Svelte和Solid中均可适用,这一事实最有力地说明:选择哪个框架的重要性,远不及围绕它构建的架构。

关于更广泛的开发者工具包——测试模式、构建时检查、前端无障碍全景的其余部分——请参阅开发者专区WCAG 2.2完整成功准则参考收录了上述每种模式所涉及的准则;免费WCAG 2.2扫描器可对您指定的任意URL检测axe可见的结构性缺陷。

“aria-live规范假设DOM以2008年规范编写时的方式发生变更。四个框架之后,没有一个以那种方式变更——而屏幕阅读器对此一无所知。“

——Disability World工程组,2026年5月