Как передавать данные в браузер потоком с помощью Fetch
Большинство руководств по Fetch API демонстрируют один и тот же паттерн: вызов fetch(), ожидание ответа, вызов .json() или .text(), готово. Это отлично работает для небольших объёмов данных. Но когда ваш сервер генерирует данные постепенно — будь то ответы AI, логи в реальном времени или большие наборы данных — ожидание полного ответа перед обработкой хотя бы одного байта становится реальной проблемой.
Хорошая новость: Fetch API уже поддерживает инкрементальную потоковую передачу данных в браузере. Вот как это использовать.
Ключевые выводы
response.bodyв Fetch API предоставляетReadableStream, позволяя обрабатывать данные по частям по мере их поступления, а не ждать полной загрузки.- Используйте
response.body.getReader()сTextDecoderдля максимальной совместимости с браузерами при чтении потоковых ответов. - Сетевые фрагменты не учитывают границы сообщений — вы должны самостоятельно буферизовать и разделять неполные строки при разборе структурированных форматов, таких как JSON с разделителями новой строки.
- Всегда используйте
AbortControllerдля долгоживущих потоков, чтобы корректно отменять запросы, когда пользователи покидают страницу.
Почему потоковые ответы с Fetch API важны
Когда вы вызываете response.json() или response.text(), браузер должен получить всё тело ответа перед тем, как разрешить промис. Для лог-файла размером 50 МБ или медленной конечной точки AI это означает, что ваше приложение не может обработать или отобразить какую-либо часть ответа до получения последнего байта.
Потоковая передача позволяет обрабатывать данные по мере их поступления — отображая первый фрагмент пользователям, пока остальная часть всё ещё передаётся. Это существенное улучшение воспринимаемой производительности.
Как работает ReadableStream в Fetch API
Каждый ответ fetch() предоставляет ReadableStream через response.body. Вместо ожидания полной загрузки вы подключаете читатель и извлекаете фрагменты по мере их поступления из сети.
Наиболее совместимый подход — использование response.body.getReader():
const response = await fetch('/api/stream')
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { value, done } = await reader.read()
if (done) break
console.log(decoder.decode(value, { stream: true }))
}
Каждое value — это Uint8Array необработанных байтов. TextDecoder преобразует эти байты в строку. Передайте { stream: true }, чтобы декодер корректно обрабатывал многобайтовые символы, которые могут быть разделены между границами фрагментов.
Примечание об асинхронной итерации: Возможно, вы встречали
for await (const chunk of response.body). Этот синтаксис чище, но не поддерживается в Safari начиная с версии 18.x, поэтому цикл сgetReader()выше — более безопасный выбор для продакшена. Текущую поддержку браузерами смотрите на https://caniuse.com/wf-async-iterable-streams.
Декодирование текстовых потоков с помощью TextDecoderStream
Если вы предпочитаете подход в стиле конвейера, TextDecoderStream автоматически обрабатывает декодирование:
const response = await fetch('/api/stream')
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader()
while (true) {
const { value, done } = await reader.read()
if (done) break
console.log(value) // уже строка
}
Это удобнее при объединении нескольких шагов преобразования в цепочку.
Discover how at OpenReplay.com.
Практические аспекты потоковой передачи в браузере с Fetch
Границы фрагментов произвольны. Сетевые фрагменты не выравниваются по строкам или сообщениям. Если вы разбираете JSON с разделителями новой строки или события SSE, вам нужно буферизовать неполные строки и разделять их по \n самостоятельно.
Потоки можно использовать только один раз. Подключение читателя через getReader() блокирует поток для этого читателя, и после чтения любых данных тело становится использованным и не может быть прочитано снова. Если вам нужно тело в двух местах, вызовите response.clone() перед чтением:
const response = await fetch('/api/data')
const clone = response.clone()
// Читаем оригинал как поток
const reader = response.body.getReader()
// Используем клон обычным образом в другом месте
const text = await clone.text()
Отменяйте потоки с помощью AbortController. Долгоживущие потоки должны быть отменяемыми — особенно когда пользователи покидают страницу:
const controller = new AbortController()
const response = await fetch('/api/stream', {
signal: controller.signal
})
// Отменить при необходимости
controller.abort()
Это предотвращает продолжение получения данных браузером, которые никто не читает.
Заключение
Потоковая передача в браузере с Fetch хорошо поддерживается и практична сегодня. Основной паттерн прост: получите читатель из response.body, организуйте цикл с reader.read(), декодируйте байты с помощью TextDecoder и обрабатывайте границы фрагментов в собственном буфере. Добавьте AbortController для очистки и помните, что тела ответов можно использовать только один раз, когда вам нужны данные в нескольких местах. Это всё, что вам нужно для создания отзывчивых, инкрементальных пользовательских интерфейсов с данными в браузере.
Часто задаваемые вопросы
Потоковая передача Fetch работает с любым HTTP-методом, возвращающим тело ответа, включая POST, PUT и PATCH. ReadableStream в response.body ведёт себя одинаково независимо от используемого метода. Серверу просто нужно отправлять фрагментированный или потоковый ответ, чтобы инкрементальное чтение имело смысл.
Вам нужно поддерживать строковый буфер. Добавляйте каждый декодированный фрагмент в буфер, затем разделяйте по символам новой строки. Обрабатывайте каждую полную строку как JSON и сохраняйте завершающий неполный сегмент в буфере для следующего фрагмента. Это учитывает тот факт, что сетевые фрагменты могут разделить JSON-объект на два чтения.
Да. Вы можете использовать конечную точку SSE через потоковую передачу Fetch, вручную разбирая формат text/event-stream из фрагментов. Это даёт вам больше контроля над заголовками, аутентификацией и методами запросов по сравнению с EventSource API, который поддерживает только GET-запросы и предлагает ограниченную настройку заголовков.
Если соединение прерывается или поток выдаёт ошибку, промис, возвращаемый reader.read(), будет отклонён. Оберните ваш цикл чтения в блок try-catch, чтобы ваше приложение могло корректно обработать сбой, уведомить пользователя или повторить запрос при необходимости.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.