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。好消息:每种框架都能做到。坏消息:每种框架以不同的方式打破规范,且故障模式不可互相套用。
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,以及大量内部设计系统的长尾)中提取模式,并按意图分组。
polite,错误用assertivepolite,与aria-describedby配合使用polite,role为statuspolite,role为log或status3. 各框架特有坑点,按出现频率排列
每个框架都有自己的协调器,而协调器正是aria-live区域的”葬身之所”。四行概括如下:
setState调用合并为单次提交,因此”打开toast然后更改其文本”可能作为单次DOM变更落地——屏幕阅读器将其视为未播报区域的初始挂载。flushSync或微任务延迟写入文本。await nextTick();或将区域由调度器不会去重的shallowRef来组合。$state读取编译为绕过任何框架级批处理的直接DOM写入。这听起来对aria-live很理想——直到你意识到编译器还会对连续相同的写入进行去重——因此”正在加载……”后跟”正在加载……”被折叠为单次DOM变更。untrack强制产生新的DOM变更。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 19 | Vue 3.5 | Svelte 5 | SolidJS 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. 各框架好坏代码对比
以下代码对展示了各框架中编写加载状态模式的错误方式与正确方式。我们选择加载状态,因为它是矩阵中红色单元格最多的模式——好坏实现之间的差距也最为显著。
function LoadingState({ isLoading, results }) {
return isLoading ? (
<div role="status" aria-live="polite">
Loading results...
</div>
) : (
<ResultsList items={results} />
);
}区域仅在加载时挂载。React的自动批处理可能在数据到达的同一帧内提交挂载和卸载——JAWS、NVDA和VoiceOver对此的处理意见不一。净效果是:“正在加载……”有时被朗读,有时不被朗读,且客户端看不出规律。
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的渲染器爱怎么批处理就怎么批处理——唯一变化的是现有区域内的文本,这正是规范所描述的情形。
<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。“正在加载……”的播报被静默丢失。
<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。屏幕阅读器看到两次不同的变更,逐一播报。
<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()第二次调用再次写入”正在加载……”,编译器不会产生变更——屏幕阅读器在第二次点击时什么也听不到。
<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变更。绕过去重是这一做法的全部意义所在。
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关闭——而对于快速缓存响应,“正在加载……”和”已加载……”可能在同一微任务中刷新。中间的播报丢失。
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. 跨框架实操手册
在应用启动时,为每个礼貌级别挂载一个全局实时区域
在任何路由渲染之前,在应用根节点渲染两个空div——一个带aria-live=“polite”,一个带aria-live=“assertive”。应用中的所有播报均写入这两个区域之一。这一做法消除了上述所有框架中的挂载竞态条件。
编写一个封装全局区域的小型播报服务
暴露一个函数——announce(message, politeness)——找到对应的全局区域并设置其textContent。框架可能给出指向区域的响应式ref,但播报器可以直接先调用el.textContent = ”,然后在下一个任务中调用el.textContent = message,这样即使对相同字符串也能强制产生变更。
将爆发性数据源节流至约每1500毫秒一条消息
如果数据源每秒可能触发多次——比分计数器、聊天流——无论使用哪个框架,屏幕阅读器的合成器都跟不上。在客户端合并更新,并发出单条摘要消息(“3条新消息”),而非三条连续播报。上述矩阵显示每个框架在”爆发”行都失败,因此修复必须在框架之上进行,而非在框架内部。
使用NVDA、JAWS和VoiceOver进行测试——三者缺一不可,每次均须测试
如果单个屏幕阅读器足够,就不会有这份矩阵了。JAWS对空区域的严格程度与VoiceOver的宽容程度方向相反;NVDA居于两者之间。一个仅在VoiceOver下正确播报的模式——这是Mac主导的前端团队的默认测试工具——对于使用屏幕阅读器人群中的大多数而言是有缺陷的。
停止有条件地挂载实时区域
四个框架中最常见的缺陷。在应用启动时将区域挂载为空,改变文本内容,永不卸载。
结论: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年规范编写时的方式发生变更。四个框架之后,没有一个以那种方式变更——而屏幕阅读器对此一无所知。“