12k
All articles

Как передавать данные в браузер потоком с помощью Fetch

Потоковая обработка ответов Fetch API через ReadableStream, TextDecoder и AbortController позволяет отображать данные до получения полного ответа.

OpenReplay Team
OpenReplay Team
Как передавать данные в браузер потоком с помощью 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) // уже строка
}

Это удобнее при объединении нескольких шагов преобразования в цепочку.

Практические аспекты потоковой передачи в браузере с 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-методами или только с GET-запросами?

Потоковая передача Fetch работает с любым HTTP-методом, возвращающим тело ответа, включая POST, PUT и PATCH. ReadableStream в response.body ведёт себя одинаково независимо от используемого метода. Серверу просто нужно отправлять фрагментированный или потоковый ответ, чтобы инкрементальное чтение имело смысл.

Как разобрать JSON с разделителями новой строки из потокового ответа Fetch?

Вам нужно поддерживать строковый буфер. Добавляйте каждый декодированный фрагмент в буфер, затем разделяйте по символам новой строки. Обрабатывайте каждую полную строку как JSON и сохраняйте завершающий неполный сегмент в буфере для следующего фрагмента. Это учитывает тот факт, что сетевые фрагменты могут разделить JSON-объект на два чтения.

Можно ли использовать потоковую передачу Fetch с server-sent events вместо EventSource?

Да. Вы можете использовать конечную точку SSE через потоковую передачу Fetch, вручную разбирая формат text/event-stream из фрагментов. Это даёт вам больше контроля над заголовками, аутентификацией и методами запросов по сравнению с EventSource API, который поддерживает только GET-запросы и предлагает ограниченную настройку заголовков.

Что происходит, если сервер неожиданно закрывает соединение во время потока?

Если соединение прерывается или поток выдаёт ошибку, промис, возвращаемый reader.read(), будет отклонён. Оберните ваш цикл чтения в блок try-catch, чтобы ваше приложение могло корректно обработать сбой, уведомить пользователя или повторить запрос при необходимости.

Open-source session replay

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.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.