Пошук уроків, статей та іншого контенту
Обробите потокову відповідь через ReadableStream і відображатимете великі дані частинами без очікування всього тіла.
Звичайний виклик fetch() часто використовують так:
const response = await fetch("/api/report");
const data = await response.json();У цьому випадку програма чекає, доки буде отримане й розібране все тіло відповіді. Для великих звітів, логів, експортів або потоків подій це може спричинити:
довге очікування першого результату;
значне споживання пам’яті;
блокування інтерфейсу під час оброблення великих даних;
неможливість показувати прогрес або частковий результат.
Fetch API надає доступ до тіла відповіді як до ReadableStream. Це дає змогу отримувати дані частинами й обробляти їх одразу після надходження.
const response = await fetch("/api/data");
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// value — Uint8Array із черговою частиною байтів
console.log(value);
}
reader.releaseLock();Важливо: частина, отримана від read(), не обов’язково відповідає одному повідомленню, одному рядку чи одному JSON-об’єкту.
response.body має тип ReadableStream<Uint8Array>. Потік складається з байтових частин, які надходять у міру передавання даних.
Метод:
const reader = response.body.getReader();отримує ексклюзивний читач потоку. Після цього інший код не може одночасно читати той самий потік.
Метод reader.read() повертає проміс із об’єктом:
{
value: Uint8Array | undefined,
done: boolean
}Можливі два стани:
done: false — отримано чергову частину даних;
done: true — потік завершено.
Типовий цикл читання:
const reader = response.body.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
processChunk(value);
}
} finally {
reader.releaseLock();
}releaseLock() звільняє читача. Він не скасовує потік і не видаляє вже отримані дані.
Щоб припинити передавання, потрібно викликати:
await reader.cancel();Або скасувати сам запит за допомогою AbortController.
ReadableStream відповідає за байти, а не за рядки. Для перетворення байтів у текст використовується TextDecoder.
const decoder = new TextDecoder("utf-8");
const text = decoder.decode(value, {
stream: true
});Параметр stream: true має принципове значення. Символ UTF-8 може складатися з кількох байтів, і ці байти можуть опинитися в різних частинах потоку. Декодер зберігає незавершену послідовність до наступного виклику.
Після завершення потоку потрібно виконати фінальне декодування:
const remainingText = decoder.decode();Якщо цього не зробити, останній незавершений фрагмент може бути втрачений або оброблений некоректно.
Один із практичних форматів для потокових відповідей — NDJSON, або Newline Delimited JSON. У ньому кожен рядок є окремим JSON-об’єктом:
{"id":1,"message":"Перша подія"}
{"id":2,"message":"Друга подія"}
{"id":3,"message":"Третя подія"}Такий формат зручний тим, що клієнт може розбирати об’єкти по одному, не очікуючи завершення всього масиву.
Проте межі HTTP-частин і межі рядків не збігаються. Наприклад, один рядок може бути розділений між двома частинами:
Частина 1: {"id":1,"mess
Частина 2: age":"Готово"}\nТому необхідно зберігати незавершений фрагмент у буфері.
Нижче наведено невеликий сервер без сторонніх бібліотек. Він надсилає NDJSON-події з інтервалом, імітуючи довгу операцію.
Створіть файл server.js:
const http = require("node:http");
const port = 3000;
const server = http.createServer((request, response) => {
if (request.url !== "/stream") {
response.writeHead(404, {
"Content-Type": "text/plain; charset=utf-8"
});
response.end("Not found");
return;
}
response.writeHead(200, {
"Content-Type": "application/x-ndjson; charset=utf-8",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
});
// Негайно надсилаємо заголовки, не очікуючи першого запису.
response.flushHeaders();
let id = 1;
const timer = setInterval(() => {
const event = {
id,
timestamp: new Date().toISOString(),
message: `Оброблено запис ${id}`
};
// Кожен JSON-об'єкт завершується символом нового рядка.
response.write(`${JSON.stringify(event)}\n`);
id += 1;
if (id > 20) {
clearInterval(timer);
response.end();
}
}, 400);
request.on("close", () => {
// Браузер міг скасувати запит до завершення передавання.
clearInterval(timer);
});
});
server.listen(port, () => {
console.log(`Сервер запущено: http://localhost:${port}`);
});Запустіть сервер:
node server.jsДля клієнта створіть файл index.html:
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Потокове отримання</title>
<style>
body {
font-family: sans-serif;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
button {
margin-right: 0.5rem;
padding: 0.5rem 1rem;
}
#status {
margin: 1rem 0;
}
#events {
padding: 1rem;
background: #f3f3f3;
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>Потокові події</h1>
<button id="start">Почати</button>
<button id="stop" disabled>Скасувати</button>
<div id="status">Очікування</div>
<pre id="events"></pre>
<script>
const startButton = document.querySelector("#start");
const stopButton = document.querySelector("#stop");
const statusElement = document.querySelector("#status");
const eventsElement = document.querySelector("#events");
let controller = null;
async function readNdjson(url, { signal, onMessage }) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
if (!response.body) {
throw new Error("Середовище не надало тіло відповіді як потік");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// Декодуємо байти, не втрачаючи символи між частинами.
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Останній елемент може бути неповним рядком.
buffer = lines.pop();
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
const message = JSON.parse(trimmedLine);
onMessage(message);
}
}
// Завершуємо роботу декодера.
buffer += decoder.decode();
const lastLine = buffer.trim();
if (lastLine) {
onMessage(JSON.parse(lastLine));
}
} finally {
reader.releaseLock();
}
}
async function startStreaming() {
controller = new AbortController();
startButton.disabled = true;
stopButton.disabled = false;
eventsElement.textContent = "";
statusElement.textContent = "Отримання даних...";
let received = 0;
try {
await readNdjson("/stream", {
signal: controller.signal,
onMessage(message) {
received += 1;
// textContent не виконує отриманий текст як HTML.
eventsElement.textContent +=
`${message.id}: ${message.message}\n`;
statusElement.textContent =
`Отримано повідомлень: ${received}`;
}
});
statusElement.textContent =
`Потік завершено. Отримано повідомлень: ${received}`;
} catch (error) {
if (error.name === "AbortError") {
statusElement.textContent = "Запит скасовано користувачем";
} else {
console.error(error);
statusElement.textContent = `Помилка: ${error.message}`;
}
} finally {
controller = null;
startButton.disabled = false;
stopButton.disabled = true;
}
}
function stopStreaming() {
if (controller) {
controller.abort();
}
}
startButton.addEventListener("click", startStreaming);
stopButton.addEventListener("click", stopStreaming);
</script>
</body>
</html>Сервер має віддавати index.html через HTTP. Найпростіше тимчасово додати до server.js оброблення кореневого маршруту або скористатися будь-яким локальним статичним HTTP-сервером.
Якщо клієнт і сервер працюють на одному origin, запит має виглядати так:
const response = await fetch("/stream");Якщо вони працюють на різних origin, сервер повинен явно дозволити CORS відповідним заголовком.
У прикладі використовується такий алгоритм:
Отримати байти з потоку.
Додати декодований текст до buffer.
Розділити буфер за \n.
Обробити всі повні рядки.
Зберегти останній рядок у buffer.
Після завершення потоку обробити залишок буфера.
Ключовий фрагмент:
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.trim()) {
const object = JSON.parse(line);
handle(object);
}
}Не можна безпечно робити так:
const object = JSON.parse(decoder.decode(value));Причини:
у частині може бути лише половина JSON-об’єкта;
у частині може бути кілька JSON-об’єктів;
межа частини не є протокольною межею повідомлення;
багатобайтовий символ може бути розділений між частинами.
Для скасування використовується AbortController:
const controller = new AbortController();
const response = await fetch("/stream", {
signal: controller.signal
});
// В іншій частині програми:
controller.abort();Після виклику abort():
fetch() або поточний виклик reader.read() завершиться помилкою AbortError;
сервер може отримати закриття з’єднання;
незавершені дані більше не слід обробляти як повний результат.
Зручно перевіряти тип помилки окремо:
try {
await readStream();
} catch (error) {
if (error.name === "AbortError") {
console.log("Операцію скасовано");
} else {
throw error;
}
}Скасування важливе не лише для кнопки «Зупинити». Його також варто виконувати, коли:
користувач переходить на іншу сторінку;
компонент інтерфейсу знищується;
змінюються параметри пошуку;
старий запит більше не має сенсу;
спливає тайм-аут.
fetch() не відхиляє проміс через статуси 4xx або 5xx. Помилкою вважаються переважно проблеми мережі, скасування або недоступність ресурсу.
Тому статус потрібно перевіряти вручну до початку читання потоку:
const response = await fetch("/stream");
if (!response.ok) {
throw new Error(`Сервер повернув ${response.status}`);
}Якщо сервер повертає потокове тіло навіть для помилки, програма повинна вирішити, чи читати це тіло, чи негайно завершити операцію.
Сучасні браузери підтримують асинхронне ітерування ReadableStream. Це дає компактніший синтаксис:
async function readTextStream(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const decoder = new TextDecoder();
for await (const chunk of response.body) {
const text = decoder.decode(chunk, { stream: true });
console.log(text);
}
console.log(decoder.decode());
}Такий варіант зручний, коли потрібно послідовно обробляти байтові частини. Для протоколів на кшталт NDJSON все одно потрібні:
TextDecoder;
буфер незавершеного рядка;
логіка оброблення повідомлень;
коректне фінальне декодування.
getReader() часто обирають, коли потрібен явний контроль над читанням, скасуванням і звільненням блокування.
Якщо сервер надсилає довгий текст, а не NDJSON, можна відображати його фрагментами:
async function streamText(url, outputElement) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
if (!response.body) {
throw new Error("Відповідь не має доступного потоку");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
outputElement.textContent += decoder.decode(value, {
stream: true
});
}
outputElement.textContent += decoder.decode();
} finally {
reader.releaseLock();
}
}Для великих обсягів навіть часте додавання до DOM може бути дорогим. У такому разі варто:
накопичувати невеликі групи повідомлень;
оновлювати DOM один раз на кілька десятків мілісекунд;
використовувати DocumentFragment;
обмежувати кількість видимих елементів;
видаляти старі записи з інтерфейсу.
Потік зменшує очікування та пікове використання пам’яті, але не робить необмежене накопичення даних у DOM безпечним.
Readable Streams підтримують механізм backpressure — зворотного тиску. Він дає змогу сигналізувати виробнику даних, що споживач не встигає їх обробляти.
Для мережевого fetch() браузер керує внутрішнім буфером і передаванням даних. Якщо JavaScript довго не викликає reader.read(), частини можуть накопичуватися в буферах нижчих рівнів, а сервер або транспорт поступово сповільнить передавання.
Однак backpressure не вирішує всіх проблем:
якщо отримані дані безконтрольно додавати в масив, пам’ять зростатиме;
якщо кожна частина спричиняє важке оновлення DOM, інтерфейс може зависати;
якщо сервер сам буферизує відповідь, браузер не отримає справжнього потоку.
Для довгих операцій сервер повинен надсилати дані поступово, а проміжні проксі, балансувальники та вебсервери не повинні буферизувати всю відповідь.
Тіло відповіді є одноразовим потоком. Після читання воно вважається використаним:
const response = await fetch("/data");
await response.text();
// Повторне читання завершиться помилкою.
await response.json();Так само не можна одночасно викликати response.json() і читати response.body.
Властивість:
response.bodyUsedпоказує, чи було тіло вже використано.
Якщо відповідь потрібно обробити двома споживачами, можна заздалегідь використати:
const [firstStream, secondStream] = response.body.tee();tee() створює два потоки-копії. Це збільшує споживання пам’яті, тому для великих відповідей його потрібно застосовувати обережно.
const object = JSON.parse(decoder.decode(value));Частина потоку не має гарантованого формату повідомлення. Потрібен протокол із явними роздільниками або інша логіка фреймування.
Останній рядок може не закінчуватися символом нового рядка. Після завершення читання потрібно обробити залишок:
buffer += decoder.decode();
if (buffer.trim()) {
handle(JSON.parse(buffer));
}stream: trueБез цього параметра декодер може некоректно обробити символ, байти якого розділено між двома частинами.
response.okHTTP-статус 500 не обов’язково спричинить відхилення промісу fetch().
innerHTML для отриманих данихДані від сервера можуть містити HTML або JavaScript. Для звичайного тексту безпечніше використовувати:
element.textContent = receivedText;Якщо HTML справді потрібен, його необхідно санітизувати перевіреним засобом перед вставленням у DOM.
Якщо код отримує reader, але не викликає releaseLock(), інші операції з цим потоком можуть бути заблоковані.
Клієнтський код не може змусити проксі або сервер передавати дані частинами. Перевіряти потрібно весь ланцюжок:
серверну реалізацію;
HTTP-проксі;
балансувальник;
кеш;
стиснення;
налаштування вебсерверів.
Скасований запит не є звичайною помилкою застосунку. AbortError варто обробляти окремо, щоб не показувати користувачу помилку мережі.
response.body надає доступ до тіла відповіді як до ReadableStream.
getReader() дає змогу читати дані частинами через reader.read().
Окремі частини потоку не мають гарантованих меж повідомлень.
Для тексту потрібно використовувати TextDecoder із stream: true.
Для NDJSON слід зберігати незавершений рядок у буфері.
Після завершення потоку потрібно виконати фінальне декодування та обробити залишок буфера.
AbortController дає змогу скасувати довгий запит.
fetch() не вважає статуси 4xx і 5xx помилкою без явної перевірки response.ok.
Потокове передавання зменшує час до першого результату, але не усуває витрати на оброблення та відображення великих обсягів даних.
Для справжнього потокового ефекту сервер і проміжна інфраструктура не повинні буферизувати всю відповідь.