Стриминговый UI: как проектировать стабильные интерфейсы для чатов, логов и транскрипций

Современные интерфейсы всё чаще начинают отображать данные до завершения формирования ответа, что можно наблюдать в чат-приложениях, при просмотре логов и системах транскрипции

Основная проблема заключается в том, что интерфейс ведёт себя непредсказуемо — он постоянно меняется с поступлением новых данных. Контент расширяется: строки растут в длину, появляются новые блоки, и то, что было чуть ниже экрана, неожиданно сдвигается, что затрудняет прокрутку. В момент, когда пользователь начинает взаимодействовать с элементами интерфейса, некоторые из них могут стать недоступными

Как выглядит стриминговый UI на практике

Я создал три демо, которые стримят контент разными способами: чат-пузырь, лента логов и вид транскрипции. На поверхности они выглядят по-разному, но все сталкиваются с одними и теми же тремя проблемами

Первая — прокрутка. Когда контент стримится, большинство интерфейсов удерживают область просмотра прикреплённой к низу. Это работает, если вы просто наблюдаете, но как только вы прокручиваете вверх, чтобы что-то прочитать, страница резко возвращается вниз. Вы этого не просили. Интерфейс принял решение за вас, и теперь вы боретесь с ним вместо того, чтобы читать

Вторая проблема — сдвиг макета. Контейнеры стримингового контента постоянно растут, и всё, что ниже, перемещается вниз. Кнопка, которую вы собирались нажать, оказывается не в том месте. Строка, которую вы читали, сдвинулась. Страница не сломана — ничего не остается на месте достаточно долго, чтобы с этим можно было удобно работать

Третья проблема — частота отрисовки. Браузеры обновляют экран около 60 раз в секунду, тогда как потоки данных могут поступать гораздо быстрее. Это значит, что внутреннее представление браузера о странице обновляется для кадров, которые пользователь никогда не увидит. Каждое обновление имеет свою цену, и эта цена непомітно накапливается, что в итоге может сказаться на производительности

Проходя через каждое демо, обращайте внимание на моменты, когда что-то начинает ощущаться неправильно. Тот небольшой момент трения, когда интерфейс начинает мешать вам. Именно эти дефекты мы здесь и разбираем

По этой теме полезно отдельно посмотреть Как я создал безопасные Firebase Cloud Functions с правами администратора и ограничением частоты запросов, чтобы расширить контекст и сравнить подходы

Пример 1: стриминг ответов AI-чата

Это наиболее типичный случай: вы начинаете стрим, и сообщение наполняется новыми данными по мере поступления токенов, что характерно для интерфейсов AI-чата

Вот что я предлагаю попробовать:

Нажмите кнопку Stream

Попробуйте прокрутить вверх, пока сообщение стримится

Увеличьте скорость (например, до 10 мс)

Обратите внимание на важный момент: интерфейс постоянно пытается вернуть ваше внимание вниз. Он сам решает, куда должно быть направлено ваше внимание, что может отвлекать от читательского опыта

Пример 2: живая обработка в просмотрщике логов

Хотя этот интерфейс выглядит иначе, проблема остаётся такой же, как и в предыдущем случае. Вместо того чтобы получать одно сообщение, которое постепенно удлиняется, новые строки добавляются без перерыва — как в терминале или в системах отслеживания логов

Интересная часть здесь — переключатель tail. Он очень наглядно демонстрирует компромисс между взаимодействием и стабильностью интерфейса:

Нажмите кнопку Start

Дайте логам стримиться за пределы высоты контейнера

Прокрутите вверх до начала

Остановите поток и отключите опцию «tail»

Заметьте: когда функция tail включена, пользовательский интерфейс следует за поступающим контентом. Однако вы не можете прокрутить вверх и оставаться на месте; для того чтобы прочесть контент, необходимо остановить поток или отключить функцию 'tail'

Пример 3: дашборд с метриками в реальном времени

В этом случае UI обновляется на месте:

Числа меняются,

Графики сдвигаются,

Значения обновляются непрерывно

На этот раз нет напряжения с прокруткой, но появляется другая проблема. Именно об этом мы поговорим далее

Почему UI нестабилен при потоковой обработке данных

Если вы попробовали демо чата и прокручивали страницу вверх, пока приходили ответы, вы, вероятно, сразу заметили первую проблему: интерфейс постоянно тянет вас обратно вниз к последнему потоковому контенту по мере его обновления. Это выбивает вас из контекста и не даёт времени полностью усвоить содержимое, которое уже прошло

Ту же самую проблему мы видим во втором примере — просмотрщике логов. Без переключателя слежения потоковый контент перекрывает вашу позицию прокрутки

Это не баги в традиционном смысле, когда они порождают ошибки в коде; скорее, это проблемы доступности, затрагивающие всех пользователей. Тем не менее их можно исправить и предотвратить, если тщательно продумать UX при планировании и тестировании работы

Обеспечьте предсказуемое поведение прокрутки

Вот цель:

Включать автопрокрутку, когда обнаруживается, что пользователь находится в нижней части потока

Останавливать автопрокрутку, когда пользователь прокрутил страницу вверх

Возобновлять автопрокрутку, если пользователь прокрутил страницу обратно вниз к концу потока

Для этого нам нужно знать, намеренно ли пользователь ушёл от нижней части, что можно считать истиной, когда позиция прокрутки изменяется вручную. Мы можем отслеживать это поведение с помощью флага

let userScrolled = false;
chatEl.addEventListener('scroll', () => { const gap = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight; userScrolled = gap > 60;
});

Порог в 60px важен. Без него небольшие изменения макета (например, новая строка) ненадолго создавали бы зазор и ломали автопрокрутку, даже если пользователь на самом деле не прокручивал страницу

Теперь убедимся, что автопрокрутка включается только тогда, когда позиция прокрутки пользователя равна высоте прокрутки потока, то есть пользователь находится в нижней части потока:

function autoScroll() { if (!userScrolled) { chatEl.scrollTop = chatEl.scrollHeight; }
}

Одна небольшая деталь, которую легко упустить: нужно сбрасывать userScrolled при начале нового потока. Иначе одна прокрутка из предыдущего сообщения может незаметно отключить автопрокрутку для следующего

Стабилизируйте макет

Мы видели это и в первом примере. По мере поступления нового контента макет прыгает или смещается, выбивая вас из текущего контекста. Если говорить точнее о том, что именно смещается: это не макет страницы в широком смысле, а контент непосредственно под пузырьком чата

Есть ещё один более тонкий артефакт, на который стоит обратить внимание перед тем, как смотреть на код: мерцание курсора. Поскольку мы очищаем innerHTML и пересоздаём каждый элемент при каждом тике, курсор постоянно уничтожается и добавляется заново — до 80 раз в секунду при высокой скорости

На нормальной скорости это легко не заметить, но если замедлить ползунок примерно до 30 мс, вы увидите слабое, но постоянное мерцание в конце текста. Как только мы исправим паттерн пересборки, мерцание исчезнет полностью

Этот паттерн пересборки находится прямо здесь; именно это выполняется при каждом входящем символе:

bubble.innerHTML = '';
fullText.split('\n').forEach(line => { const p = document.createElement('p'); p.textContent = line || '\u00A0'; bubble.appendChild(p);
});
bubble.appendChild(cursorEl);

Это работает, но дорого обходится. Каждое обновление очищает DOM и пересобирает его, каждый раз принудительно пересчитывая макет

Теперь мы пишем напрямую в живой узел:

let currentP = null;

function initBubble(bubble, cursor) { currentP = document.createElement('p'); currentP.appendChild(document.createTextNode('')); bubble.insertBefore(currentP, cursor);
}

Далее мы можем создать один абзац с пустым текстовым узлом и вставить его перед курсором. Это даёт нам живой узел, в который можно писать напрямую

Затем для каждого поступающего символа:

function appendChar(char, bubble, cursor) { if (char === '\n') { currentP = document.createElement('p'); currentP.appendChild(document.createTextNode('')); bubble.insertBefore(currentP, cursor); } else { currentP.firstChild.textContent += char; }
}

Для обычного символа мы расширяем текстовый узел на один символ. Браузеру не нужно пересчитывать макет для этого; текст вырос, но ничего не сдвинулось. Для символа новой строки мы создаём новый абзац и перемещаем currentP вперёд. Макет пересчитывается один раз для этого нового абзаца — и всё

Частота рендеринга

Это наиболее заметно в первом примере — интерфейсе чата. Даже с исправленной прокруткой и макетом мы всё ещё пишем в DOM при каждом входящем символе

Когда поток движется быстро, вы в итоге бомбардируете DOM обновлениями, которые на самом деле не имеют значения. Исправление простое: держите входящий текст в буфере вместо того, чтобы записывать его немедленно. Как только накопится достаточно, запишите всё в DOM за один раз — это и называется сбросом (flush)

Чтобы это реализовать, мы держим простой буфер и следим за тем, чтобы одновременно планировалось только одно обновление. Когда оно срабатывает, requestAnimationFrame берёт всё накопленное и записывает в DOM за один проход

let pending = '';
let rafQueued = false;

Когда поступает новый символ, мы добавляем его в буфер. Если сброс ещё не запланирован, ставим его в очередь:

function onChar(char) { pending += char; if (!rafQueued) { rafQueued = true; requestAnimationFrame(flush); }
}

Флаг rafQueued важен. Без него каждый символ планировал бы собственный кадр, и в итоге получились бы десятки лишних сбросов

Когда срабатывает сброс, он за один проход опустошает весь буфер:

function flush() { for (const char of pending) { appendChar(char); } pending = ''; rafQueued = false; autoScroll();
}

Все символы, поступившие после последнего кадра, рендерятся вместе, прямо перед тем как браузер их отрисует. Затем мы очищаем буфер, сбрасываем флаг и один раз запускаем автопрокрутку

function autoScroll() { if (!userScrolled) { chatEl.scrollTop = chatEl.scrollHeight; }
}

Если зазор небольшой, мы продолжаем автопрокрутку. Если он увеличивается, мы предполагаем, что пользователь прокрутил вверх, и останавливаемся. Небольшой порог помогает избежать дёрганья, когда новые строки незначительно меняют высоту. Также не забудьте сбросить userScrolled при начале нового потока

Как только прокрутка под контролем, становится очевидной другая проблема. По мере роста сообщения оно продолжает смещаться:

  • начинается как одна строка;
  • расширяется по мере поступления токенов;
  • сдвигает всё, что находится ниже.

Технически ничего не сломано, но это не ощущается стабильным. Распространённый подход — пересобирать всё сообщение при каждом обновлении:

Это работает, но делает слишком много работы. Каждое обновление уничтожает и пересобирает DOM, каждый раз принудительно пересчитывая макет. Именно поэтому всё продолжает смещаться. Более устойчивый вариант — писать в текущий абзац и создавать новый только тогда, когда мы действительно встречаем перенос строки

Теперь мы больше не пересобираем всё. Большинство обновлений просто расширяют текстовый узел, что дёшево и не вызывает больших сдвигов макета. Это также исправляет небольшое мерцание курсора, которое вы могли заметить ранее, поскольку мы больше не удаляем и не добавляем его заново

На этом этапе интерфейс уже ощущается лучше, но всё ещё происходит кое-что тонкое. Мы по-прежнему обновляем DOM при каждом символе. На более высоких скоростях это превращается в множество мелких обновлений, многие из которых вы никогда не увидите

Вместо немедленного рендеринга мы можем буферизировать входящие символы и применять их один раз за кадр

function onChar(char) { pending += char; if (!rafQueued) { rafQueued = true; requestAnimationFrame(flush); }
}

На этом этапе мы ещё не трогаем DOM, а только собираем символы по мере их поступления. Затем, прямо перед отрисовкой следующего кадра, мы сбрасываем всё сразу:

Это разделяет две вещи, которые раньше были связаны: скорость поступления данных и момент обновления интерфейса. Результат выглядит одинаково, но браузер делает меньше работы, в результате чего интерфейс ощущается более плавным, особенно когда поток установлен на более высокую скорость

Ни одно из этих изменений само по себе не требует больших усилий. Но как только они внедрены, интерфейс перестаёт слепо реагировать на каждое обновление. Его становится легче читать, легче контролировать, и он гораздо меньше отвлекает, даже несмотря на то что контент продолжает поступать непрерывно

Есть ещё больше соображений, которые нужно учитывать для обеспечения стабильного, предсказуемого и хорошего пользовательского опыта. Например, что происходит, если поток прерывается на середине? И что мы можем сделать, чтобы учитывались пользовательские предпочтения — такие как уменьшение движения, навигация с клавиатуры и доступность для программ чтения с экрана? Перейдём к этому далее

Обработка прерванных потоков данных

В большинстве потоковых интерфейсов есть способ остановить или отменить поток. Мы видели это в демонстрациях. Но остановка нередко оставляет UI в неловком состоянии. Курсор может продолжать мигать, кнопки не обновляются, а сообщение просто зависает на середине потока без какого-либо чёткого указания на то, что оно не завершилось

Проблема в том, что остановка обычно настроена делать одно: отменять таймер. Этого недостаточно. Нужно также (1) очистить ожидающий буфер, (2) убрать курсор, (3) пометить ответ как незавершённый и (4) сбросить кнопки. Вот как мы это реализуем

Остановить поток корректно

Вот что должна делать функция stopStream по порядку:

Отменить таймер и переключить флаг isStreaming, чтобы больше не выполнялись тики

Очистить буфер requestAnimationFrame (RAF), чтобы ничего из поставленного в очередь не было записано на следующем кадре

function stopStream() { clearTimeout(streamTimer); isStreaming = false; pending = ''; rafQueued = false;
}

Очистка свойства pending важна, потому что в буфере могут оставаться символы из последнего экземпляра потока, которые ещё не были сброшены. Если не очистить его, следующий requestAnimationFrame сработает, опустошит буфер и запишет эти символы в DOM уже после того, как поток официально остановился

Теперь переходим к удалению курсора, вызывая markStopped на пузырьке:

if (cursorEl && cursorEl.parentNode) cursorEl.remove();
markStopped(aiBubble);
stopBtn.style.display = 'none';
retryBtn.style.display = '';
playBtn.style.display = '';
setStatus('Stopped', 'stopped');
chat.removeEventListener('scroll', onScroll);

Проверка cursorEl.parentNode нужна потому, что stopStream также вызывается внутренне, когда новое сообщение запускается в середине потока, — в этот момент курсор может уже исчезнуть. Вызов remove() на отсоединённом узле выбрасывает исключение, поэтому мы сначала проверяем

markStopped добавляет небольшую метку в нижнюю часть пузырька, чтобы пользователь знал, что ответ не завершился:

function markStopped(bubble) { if (!bubble) return; bubble.classList.add('stopped'); const label = document.createElement('span'); label.className = 'stopped-label'; label.textContent = 'response stopped'; bubble.appendChild(label);
}

Проверка на null для bubble обрабатывает граничный случай, когда остановка срабатывает до того, как элемент AI-сообщения был инициализирован, — это может произойти, если пользователь нажимает «Стоп» в течение 300 мс задержки до появления пузырька

Предоставить возможность повтора

Если поток просто останавливается — возможно, из-за сетевой проблемы или какой-то другой непредвиденной ошибки — следует предоставить пользователю путь для повторной попытки. По сути, это означает избавление UI от дорогостоящей работы: прокрутки обратно наверх, повторного чтения запроса и его перепечатки. С опцией повтора пользователю нужно лишь нажать кнопку, и поток перезапускается с текущей позиции

Чтобы это работало, нужно сохранять вопрос при запуске потока:

let lastQuestion = '';

function startStream(question, answer) { lastQuestion = question; // остальная настройка...
}

Затем, когда выполняется попытка повтора, мы сбрасываем всё и начинаем заново:

function retryStream() { if (currentMsgEl && currentMsgEl.parentNode) { currentMsgEl.remove(); } charIndex = 0; userScrolled = false; pending = ''; rafQueued = false; isStreaming = true; retryBtn.style.display = 'none'; stopBtn.style.display = ''; setStatus('Streaming...', 'streaming'); chat.addEventListener('scroll', onScroll, { passive: true }); setTimeout(() => { initAIMsg(); tick(lastAnswer); }, 200);
}

Сброс критически важен. Каждый фрагмент состояния должен вернуться к своему начальному значению, как при совершенно новом потоке

Примечание: мы удаляем всю строку сообщения (currentMsgEl), а не только пузырёк. Если удалить только пузырёк, обёртка макета и аватар остаются и ломают структуру

Отправить новое сообщение в середине потока

Есть ещё один граничный случай, который легко упустить. Если пользователь отправляет новое сообщение, пока поток ещё выполняется, в итоге получаются два цикла, одновременно пишущих в DOM. Результат беспорядочный: символы из разных ответов перемешиваются

Вот что нужно сделать: остановить текущий поток перед запуском нового

function startStream(question, answer) { if (isStreaming) { clearTimeout(streamTimer); isStreaming = false; pending = ''; rafQueued = false; if (cursorEl && cursorEl.parentNode) cursorEl.remove(); chat.removeEventListener('scroll', onScroll); } // теперь сбрасываем и начинаем заново charIndex = 0; userScrolled = false; isStreaming = true; lastQuestion = question; // ...
}

Здесь мы встраиваем очистку напрямую, а не вызываем stopStream, потому что stopStream также вызывает markStopped и сбрасывает кнопки. Следующая демонстрация включает все три поведения. Можно запустить поток, нажать «Стоп» в середине — и курсор исчезнет, появится метка «response stopped», а также отобразится кнопка «Retry»

Доступность

Потоковые интерфейсы часто создаются и тестируются с помощью мыши, поэтому в браузере они могут ощущаться вполне нормально, но разваливаться в других ситуациях, которые могли не быть приняты во внимание, — например, объявляет ли программа чтения с экрана новый контент вообще. Или навигация с клавиатуры может застревать или терять фокус по мере обновления содержимого

И, конечно, движущийся текст может быть некомфортным — или даже недоступным — для людей с чувствительностью к движению

Хорошая новость в том, что вам не нужно перестраивать всё с нуля, чтобы учесть эти вещи; их можно исправить с помощью решений, которые накладываются поверх уже существующего

Поддержка вспомогательных технологий с помощью живых регионов

Программы чтения с экрана не объявляют автоматически контент, который появляется сам по себе. Обычно они читают содержимое, когда пользователь переходит к нему. Поэтому в потоковом UI, где текст накапливается со временем, ничего не объявляется. Контент есть, но пользователь ничего не слышит

Решение — aria-live. Оно указывает браузеру следить за контейнером и объявлять обновления по мере их появления, без необходимости перемещать фокус пользователем

role="log" сообщает вспомогательным технологиям, что это поток обновлений, наподобие текущей стенограммы. Некоторые инструменты обрабатывают это автоматически, но лучше быть явным, чтобы поведение оставалось согласованным

aria-atomic="false" гарантирует, что объявляется только новый контент. Без этого некоторые программы чтения с экрана пытаются перечитывать всё сообщение при каждом обновлении, что быстро становится неудобным

aria-live="polite" ставит обновления в очередь вместо того, чтобы прерывать. Используйте assertive только для вещей, которые действительно требуют немедленного внимания, например для ошибок

Обработка незавершённых состояний

Ранее мы вставляли метку «Response Stopped» в сообщение, когда поток останавливается на середине. Визуально этого достаточно. Но для программы чтения с экрана это изменение должно быть объявлено

Поскольку сообщение находится внутри живого региона с aria-live="polite", метка будет автоматически объявлена как новый контент при добавлении в DOM. Живой регион уже обрабатывает объявление, поэтому дополнительный ARIA на самой метке не нужен

Кнопка Retry, которая появляется следом, также нуждается в контексте. Если программа чтения с экрана просто говорит «Retry, button», непонятно, к какому действию это относится. Это можно исправить, добавив aria-label, включающий исходный вопрос:

retryBtn.setAttribute('aria-label', `Retry: ${lastQuestion.slice(0, 60)}`);

Здесь можно установить эту метку в момент появления кнопки, а не при загрузке страницы:

retryBtn.style.display = 'inline-block';
retryBtn.setAttribute('aria-label', `Retry: ${lastQuestion.slice(0, 60)}`);

Мы также вызываем retryBtn.focus() после остановки. Таким образом, пользователям клавиатуры не нужно нажимать Tab, чтобы найти следующее действие

Тестирование со вспомогательными технологиями: не полагайтесь на предположения о том, как программы чтения с экрана объявляют это. Тестируйте с реальными инструментами, такими как NVDA (Windows), JAWS (Windows) или VoiceOver (Mac/iOS). Browser DevTools может показать, что доступно в дереве доступности, но не может сказать вам, как звучит контент. Реальная программа чтения с экрана покажет, происходит ли объявление в нужное время и нужным образом

Учёт навигации с клавиатуры

Элементы управления должны работать с клавиатурой, пока UI активен, поэтому кнопка Stop должна быть доступна. Для тех, кто не использует мышь, Tab + Enter — единственный способ отменить выполняющийся поток

Использование display: none вполне подходит для скрытия кнопок; оно удаляет их из порядка табуляции. Проблема возникает при использовании таких вещей, как opacity: 0 или visibility: hidden. Они скрывают элементы визуально, но те всё равно могут получать фокус, поэтому пользователи в итоге переходят на что-то, чего не видят

Используйте :focus-visible, чтобы кольцо фокуса отображалось при навигации с клавиатуры, но не при кликах мышью:

btn:focus-visible { outline: 2px solid #1d9e75; outline-offset: 2px;
}

Курсор внутри сообщения должен иметь aria-hidden="true". Он только визуальный. Без этого некоторые программы чтения с экрана пытаются читать его как текст, что отвлекает

Чувствительность к движению

Эффект печатной машинки, который мы видим практически в каждом AI-интерфейсе, создаёт постоянное движение. Как мы уже обсуждали, определённое количество движения может быть недоступным. К счастью, браузеры предоставляют prefers-reduced-motion, который определяет предпочтения пользователя по движению на уровне операционной системы

Для потоковой передачи лучший подход прост: пропустить анимацию и отрисовать полный ответ сразу. Контент остаётся тем же, только без движения

const reducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)'
).matches;

if (reducedMotion) { initAIMsg(); for (const char of text) appendChar(char); if (cursorEl && cursorEl.parentNode) cursorEl.remove(); done(); return;
}

tick(text); // обычная анимация

В CSS мигание курсора также должно прекратиться. Несмотря на то что это незначительная деталь, мигающий элемент курсора считается мигающим контентом

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

Демо ниже объединяет всё из этой статьи, чтобы вы могли увидеть, как эти паттерны работают на практике. Оно также включает переключатель уменьшенного движения, чтобы вы могли легко протестировать версию с мгновенной отрисовкой

Итог: что даёт каждый из паттернов

Сама потоковая передача в основном решена. Передача данных с сервера на клиент больше не является сложной частью. Ломается UI поверх неё

Когда контент обновляется непрерывно, начинают иметь значение мелкие вещи — поведение прокрутки, стабильность макета, время отрисовки и то, как интерфейс реагирует на действия пользователя. Если с этим не справляться должным образом, UI ощущается нестабильным и неудобным в использовании

На мой взгляд, именно здесь большинство команд теряют время: они отлаживают транспортный уровень, тогда как настоящая проблема — в том, как браузер обрабатывает непрерывный поток изменений DOM

Паттерны в этой статье исправляют это следующим образом:

Удерживают позицию прокрутки под контролем пользователя,

Обновляют только то, что изменилось,

Группируют отрисовку по кадрам,

Обрабатывают действия остановки и повтора, и

Делают интерфейс доступным

Вам не нужно всё это каждый раз. Но когда задействована потоковая передача, именно в этих местах обычно что-то идёт не так

Дополнительное чтение

Using Server-Sent Events — Как открыть соединение, обрабатывать события и переподключаться при необходимости. Это транспортный уровень, на котором строится всё здесь

Streams API — Потоковая передача данных напрямую из fetch. Полезно, когда вам нужно больше контроля, чем даёт SSE

Chrome DevTools Performance panel — Помогает увидеть пересчёты макета и затраты на отрисовку, чтобы вы могли проверить улучшения производительности

«How Large DOM Sizes Affect Interactivity, And What You Can Do About It», Jeremy Wagner — Почему большие деревья DOM замедляют работу и как держать их под контролем в длинных сессиях потоковой передачи

Ответы на эти вопросы могут быть для вас полезными

Почему автопрокрутка ломается, когда пользователь немного прокрутил страницу вверх?

Без порогового значения любое изменение высоты контейнера — например, появление новой строки — создаёт кратковременный зазор между позицией прокрутки и нижней границей. Флаг userScrolled срабатывает, и автопрокрутка отключается, хотя пользователь ничего не делал. Порог в 60px отсекает эти случайные срабатывания

Зачем буферизировать символы через requestAnimationFrame, если браузер и так обновляет экран 60 раз в секунду?

Поток данных может поступать значительно быстрее, чем 60 кадров в секунду. Без буферизации каждый символ вызывает отдельную запись в DOM — большинство из них браузер всё равно не успевает отрисовать. Буфер собирает все символы между кадрами и применяет их за один проход, снижая нагрузку на рендеринг

Что произойдёт, если не очистить буфер pending при остановке потока?

Следующий запланированный requestAnimationFrame всё равно сработает и запишет оставшиеся символы в DOM уже после официальной остановки. В результате в пузырьке появится лишний текст, а метка «response stopped» окажется не в конце сообщения

Почему opacity: 0 хуже display: none для скрытия кнопок управления?

display: none полностью убирает элемент из порядка табуляции. opacity: 0 и visibility: hidden скрывают элемент визуально, но он остаётся доступным для фокуса с клавиатуры. Пользователь, навигирующий через Tab, попадает на невидимую кнопку — это прямая проблема доступности

Нужно ли добавлять aria-live к каждому потоковому контейнеру?

Нет. Достаточно одного живого региона на поток. Если добавить aria-live к нескольким вложенным элементам, программа чтения с экрана может объявлять одно и то же обновление несколько раз. Оптимальный вариант — один контейнер с aria-live="polite" и aria-atomic="false" на уровне пузырька сообщения

Оцените статью
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x