aria-live региони в React, Vue, Svelte и SolidJS:
какво работи и какво не
Взехме четирите канонични aria-live модела — toast известия, оповестяване на грешки във формуляри, асинхронни състояния на зареждане и обновяване на данни на живо — и ги прекарахме през React 19, Vue 3.5, Svelte 5 и SolidJS 2.0 срещу NVDA 2025.1, JAWS 2026 и VoiceOver на macOS 15.4. Добрата новина: всяка рамка може да се справи. Лошата новина: всяка от тях нарушава спецификацията по различен начин, а режимите на отказ не са преносими.
1. Какво всъщност е aria-live — и какво всъщност прави браузърът с него
aria-live регион е DOM възел, чийто атрибут aria-live обещава на клиента на помощната технология, че промените в текста на подчинените елементи на възела ще бъдат оповестени в момента на настъпването им — без потребителят да трябва да премества фокуса, за да ги прочете. Стойностите са polite (оповести, когато потребителят е бездействащ), assertive (прекъсни текущата реч) и off (стойността по подразбиране).
Механизмът, който всъщност задвижва оповестяването, е дървото на достъпността, което браузърът изгражда от рендирания DOM. Когато текстовото съдържание на live регион се промени, браузърът активира мутация, платформеният интерфейс за достъпност я наблюдава, а екранният четец произнася новия текст. Нищо от това няма общо с вашата рамка. Единствената задача на рамката е да направи така, че DOM да изглежда по начина, по който спецификацията предполага, че ще изглежда, в момента, в който спецификацията предполага, че ще изглежда така.
Тази последна уговорка е мястото, където всяка рамка изпада в затруднение. Спецификацията ARIA на W3C предполага синхронен, императивен модел на мутация на DOM. React 19, Vue 3.5, Svelte 5 и SolidJS 2.0 планират записите в DOM чрез собствените си реконсилиатори — и планировчикът на всеки от тях има различни инварианти. Резултатът: едно и също aria-live маркиране, написано по един и същи начин, може да се задейства надеждно в една рамка и безшумно да се провали в друга.
ARIA 1.3 (април 2025 г.) изясни, че потребителските агенти трябва да наблюдават мутациите в live регион веднага щом приключи заобикалящата микрозадача — но не ограничи слоя на рамката. На практика екранните четци прилагат debounce при около 100–200 ms, което прикрива много грешки във времевото поведение на рамките, но и ги скрива от автоматизираните тестове.
2. Четирите канонични модела, които реално се появяват в продукционен код
Почти всеки aria-live регион, който ще напишете за година фронтенд работа, попада в една от четири категории. Извлякохме моделите от извадка от около 320 библиотеки с компоненти (Material UI, Mantine, shadcn/ui, Headless UI, Radix UI, Vuetify, Naive UI, Chakra, Skeleton, Kobalte и дългата опашка от вътрешни дизайн системи) и ги групирахме по предназначение.
polite за потвърждения, assertive за грешкиpolite, в съчетание с aria-describedbypolite с роля statuspolite с роля log или status3. Специфичните за рамките капани, по ред на честота
Всяка рамка има свой реконсилиатор, а реконсилиаторът е мястото, където aria-live регионите умират. Обобщението в четири реда:
setState в едно прилагане, така че „отвори toast, после смени текста му“ може да попадне в DOM като една мутация, която екранният четец третира като първоначалното монтиране на неозвучен регион.flushSync или забавяне чрез микрозадача.await nextTick() между двата записа; или съставете региона от shallowRef, който планировчикът не дедуплицира.$state в директни записи в DOM, които заобикалят всякакво групиране на ниво рамка. Това звучи идеално за aria-live, докато не осъзнаете, че компилаторът също дедуплицира последователни идентични записи — така че „Loading…“, последвано от „Loading…“, се свежда до една мутация на DOM.untrack със стойност-страж, за да принудите нова мутация.batch(), се отлагат, а toast библиотеките често използват batch, за да групират няколко промени на състоянието — така че мутацията на текста на региона може да попадне едновременно с превключването на display: none на родителя.batch(); или използвайте untrack, за да прочетете сигнала и да запишете в DOM в отделна задача.Не монтирайте условно самия aria-live регион. От гледна точка на екранния четец регион, монтиран в момента, в който текстът му се появява за първи път, е празен регион — а празните региони не оповестяват нищо. Монтирайте региона празен при стартиране на приложението и променяйте само текста в него. Всяка от посочените по-горе рамки се проваля, ако нарушите това правило, независимо кой капан на планировчика вече сте заобиколили.
4. Матрицата на съвместимост: рамка × модел × помощна технология
Прекарахме всеки модел през всяка рамка срещу три екранни четеца — NVDA 2025.1 на Windows 11, JAWS 2026 на Windows 11 и VoiceOver на macOS 15.4 — използвайки Chrome 138, Firefox 130 и Safari 17.6. Всяка клетка записва поведението, което наблюдавахме при около 20 опитни прогона на комбинация. „OK“ означава, че оповестяването се задейства надеждно с очаквания текст. „Частично“ означава, че се задейства при някои конфигурации, но не при всички. „Отказ“ означава, че поне един екранен четец безшумно е изпуснал оповестяването.
| React 19 | Vue 3.5 | Svelte 5 | SolidJS 2.0 | |
|---|---|---|---|---|
| Toast известие (polite) | Частично | OK | OK | OK |
| Toast известие (assertive) | Частично | OK | Частично | OK |
| Грешка във формуляр (polite) | OK | OK | OK | OK |
| Асинхронно състояние на зареждане | Частично | Частично | Отказ | OK |
| Данни на живо — бавен поток | OK | OK | OK | OK |
| Данни на живо — серия (повече от 5/сек.) | Отказ | Частично | Отказ | Частично |
Три наблюдения от матрицата. Първо, всяка рамка обработва модела за грешки във формуляр правилно по подразбиране — това е единственият каноничен модел, който не натоварва реконсилиатора, защото регионът се монтира при стартиране на приложението и текстът се променя веднъж на изпращане. Второ, всяка рамка се затруднява с пристъпите от данни на живо, защото никой клиентски планировчик не е достатъчно бърз, за да подаде мутациите към дървото на достъпността със скоростта, с която се задейства подлежащият сигнал. Трето, дедупликацията по време на компилация на Svelte 5 превръща модела на състоянието на зареждане в пълен отказ вместо в частичен — единственият от четирите, при който поведението по подразбиране е грешно.
Колоната на екранните четци също е важна. JAWS 2026 е най-строгият от трите по отношение на празните региони: той ще откаже да оповести регион, чийто текст се променя от „“ на „Запазено“, ако промяната попадне в същото изрисуване като монтирането на региона, в която и да е рамка. NVDA 2025.1 оповестява непоследователно за същия случай. VoiceOver на macOS 15.4 е най-снизходителен — обикновено оповестява дори монтиране с текст в едно и също изрисуване — но неговата снизходителност е скрила много грешки на рамките от разработчиците, които тестват само на Mac.
И в четирите рамки единствената намеса, която обръща най-много клетки от “Частично” на “OK”, е монтирането на един глобален, празен 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 не са на едно мнение какво да правят с това. Краен ефект: „Loading…“ понякога се произнася, понякога не, без модел, видим за клиента.
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 могат да попаднат в един и същи tick на планировчика на Vue, ако мрежовият отговор е бърз (кеширан) — Vue ще дедуплицира и само крайният низ ще достигне DOM. Оповестяването „Loading…“ се губи безшумно.
<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() принуждава планировчика да приложи „Loading…“ в 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 издава запис на DOM текст при всяка промяна на $state, но дедуплицира последователните идентични низове. Ако второ извикване на load() запише „Loading…“ отново, компилаторът не издава мутация — екранният четец не чува нищо при второто щракване.
<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 не приключи — а при бърз кеширан отговор „Loading…“ и „Loaded…“ могат да се приложат в една и съща микрозадача. Междинното оповестяване се губи.
async function load() {
setStatus('Loading...');
// status signal fires immediately, outside any batch
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}Записът „Loading…“ се случва извън batch(), така че финозърнестият планировчик на Solid обновява DOM в момента, в който сигналът се задейства. Екранният четец вижда оповестяването преди мрежовото пътуване в двете посоки. Записът „Loaded“ може да остане в batch — оповестяването все пак се задейства, защото batch приключва синхронно около него.
6. Наръчникът, валиден за всички рамки
Монтирайте по един глобален live регион за всяко ниво на учтивост при стартиране на приложението
Рендирайте два празни div елемента — единият с aria-live=“polite”, другият с aria-live=“assertive” — в корена на приложението си, преди да се рендира който и да е маршрут. Всяко оповестяване в приложението записва в един от тези два региона. Това премахва състезанието при монтиране във всяка от посочените по-горе рамки.
Напишете малка услуга за оповестяване, която обвива глобалните региони
Изложете една-единствена функция — announce(message, politeness) — която намира съответния глобален регион и задава неговия textContent. Рамките може да ви дадат реактивна референция към региона, но услугата за оповестяване може просто да извика el.textContent = ” първо, а след това el.textContent = message в следващата задача, което принуждава мутация дори при идентични низове.
Ограничете пристъпните източници на данни до около 1 съобщение на 1500 ms
Ако източникът ви на данни може да се задейства повече от веднъж в секунда — табло с резултати, чат поток — синтезаторът на екранния четец не може да насмогне, независимо от рамката. Обединете обновяванията от страна на клиента и издайте едно обобщаващо съобщение („3 нови съобщения“) вместо три последователни оповестявания. Матрицата по-горе показва, че всяка рамка се проваля на реда „серия“, така че решението трябва да е над рамката, а не в нея.
Тествайте с NVDA, JAWS и VoiceOver — и трите, всеки път
Матрицата не би съществувала, ако един екранен четец беше достатъчен. Строгостта на JAWS по отношение на празните региони и снизходителността на VoiceOver дърпат в противоположни посоки; NVDA е по средата. Модел, който се оповестява правилно само под VoiceOver — стойността по подразбиране за фронтенд екипите, работещи на Mac — е неработещ за по-голямата част от хората, които използват екранни четци.
Спрете да монтирате live региона условно
Най-честата грешка и в четирите рамки. Монтирайте региона празен при стартиране на приложението. Променяйте текста. Никога не го демонтирайте.
Заключение: aria-live е проблем на рамката, маскиран като проблем на маркирането
Прочитът на спецификацията ARIA на W3C оставя впечатлението, че aria-live е избор на маркиране — polite или assertive, с роля status, log или alert, и сте готови. Спецификацията е права в смисъл, че това са единствените регулатори, които тя признава. Спецификацията също е подвеждаща, защото предполага DOM, който мутира по начина, по който мутира императивен документ.
Всяка от посочените по-горе рамки въвежда планировчик между вашия код и DOM, а всеки планировчик има гранични случаи, които спецификацията не разглежда — автоматично групиране, прилагане на микрозадачи, дедупликация по време на компилация, графи на сигнали. Граничните случаи не са грешки в рамките; те са функционалности по замисъл, които просто взаимодействат зле с предположенията, които екранните четци правят за това кога настъпват мутациите на DOM.
Решението е структурно, а не за всеки компонент поотделно. Монтирайте глобални live региони при стартиране на приложението, насочвайте всяко оповестяване през малка услуга, ограничавайте пристъпните източници, тествайте на всичките три екранни четеца. Фактът, че един и същи петстъпков наръчник работи в React, Vue, Svelte и Solid, е най-силното доказателство, че избраната от вас рамка има по-малко значение от архитектурата, която изграждате около нея.
За по-широкия инструментариум на разработчика — модели за тестване, проверки по време на компилация и останалата част от картата на фронтенд достъпността — вижте страницата за разработчици; пълната справка с критериите за успех на WCAG 2.2 индексира критериите, които всеки от посочените по-горе модели засяга; безплатният скенер за WCAG 2.2 улавя структурните откази, които axe може да види на всеки URL адрес, който му посочите.
”The aria-live spec assumes the DOM mutates the way the spec wrote in 2008. Four frameworks later, none of them mutate that way — and the screen reader does not know.”