Как улучшить UX в PWA на React с помощью потокового Backend-Driven UI — личный опыт
Привет! Меня зовут Ярослав, я фронтенд-разработчик в Outlines Tech. В одном из PWA-проектов с Backend-Driven UI (BDUI) я столкнулся с проблемой: интерфейс загружался слишком медленно. Пользователи видели спиннер и ждали более 15 секунд, пока страница заработает: интерфейс не начинал функционировать, пока не приходили все данные. За это время большинство пользователей теряли терпение и просто закрывали вкладку.
Медленная загрузка как конечный результат — меня не устроил. Это бесило, ведь при загрузке интерфейс мог бы работать хотя бы частично. Я начал искать решение проблемы, потому что это был вызов — как сделать UX удобнее и быстрее. В процессе я использовал собственный опыт, иногда ИИ подсказал возможные решения, а также нашёл open-source библиотеки и использовал хаки, которые ускоряли работу интерфейса.
Ниже хочу показать три приёма, как можно ускорить загрузку интерфейсов с Backend-Driven UI и улучшить UX. Решения показали хорошие результаты на демо-версии, но увы, пока ещё не внедрены в реальный проект. Было бы интересно обсудить с вами, как эти приёмы могут помочь в боевых задачах и что ещё можно улучшить.
Почему тормозит интерфейс при Backend-Driven UI
Немного вводных. Backend-Driven UI (BDUI) — это подход, при котором интерфейс генерируется на сервере и передаётся клиенту в виде данных, чаще всего в формате JSON. Пользователь получает описание и собирает на его основе страницу: какие компоненты отобразить, в каком порядке и с каким состоянием. Такой подход используют в крупных компаниях, таких как Яндекс, Альфа-Банк, Циан, Ozon.
Слабое место — синхронный парсинг JSON
Сам подход BDUI не вызывает проблем с производительностью. Проблема возникает из-за того, как браузер обрабатывает JSON на клиенте. Когда данные передаются и парсятся синхронно, это блокирует главный поток. Синхронный парсинг JSON заставляет интерфейс ждать завершения обработки всех данных перед тем, как он начнёт рендериться, что замедляет взаимодействие с пользователем.
Когда JSON-файл слишком большой, например, 80-100 КБ, браузер не может параллельно загружать и обрабатывать данные. Вместо этого весь файл должен быть распарсен синхронно, прежде чем интерфейс начнёт рендериться. Пока парсинг не завершится, интерфейс остаётся неактивным — пользователь видит только спиннер, даже если часть данных уже пришла.
Как синхронный парсинг JSON влияет на производительность и UX
Задержка в рендеринге. Из-за синхронного парсинга браузер не может параллельно выполнять другие задачи
Проблемы с отзывчивостью. Когда данные поступают поэтапно, а интерфейс состоит из множества динамических компонентов, синхронный парсинг усиливает задержки
Медленная загрузка. Из-за того, что браузер синхронно парсить JSON, весь процесс загрузки данных и рендеринга замедляется
В PWA на вебе нельзя заранее загрузить все возможные виджеты, как это делают в мобильных приложениях. На смартфоне пользователь один раз скачивает всё из магазина, а в браузере так не получится. Если подтягивать все компоненты сразу, страница будет дольше открываться и человек может просто закрыть вкладку. Поэтому JavaScript-файлы загружаются только при необходимости — когда из JSON становится понятно, какие блоки пришли с бэка.
Чтобы избавиться от этих проблем, я решил применить потоковую загрузку JSON через fetch, частичный парсинг и постепенный рендеринг компонентов. Ниже показываю как
Три приёма, чтобы решить проблему с долгой загрузкой
Чтобы ускорить рендеринг интерфейса, стоит изменить подход к обработке данных. Вместо того чтобы ждать полной загрузки всего JSON, можно загружать его по частям и сразу показывать интерфейс. Это позволяет постепенно загружать страницу, избавляет от спиннера и улучшает восприятие интерфейса.
Ниже — три приёма, которые я тестировал на демо-версии и результаты мне понравились:
Приём №1. Потоковая загрузка JSON через fetch
Я использовал ReadableStream — браузерное API, которое позволяет читать данные порциями. Как только приходит первая часть, можно сразу начать её обрабатывать, что сокращает задержку между отправкой запроса и появлением первых элементов интерфейса.
const sendRequest = async (fetcher: () => Promise<Response>) => {
const res = await fetcher();
if (!res.ok) throw new Error(await res.text());
const reader = res.body?.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Обработка порции данных
parser.write(chunk);
}
} finally {
reader?.releaseLock();
}
};
Такой подход позволяет начать обработку данных ещё до их полной загрузки
Приём №2. Частичный парсинг
Если JSON-файл слишком большой, парсинг через JSON.parse блокирует главный поток и тормозит интерфейс. Вместо этого я использовал библиотеку incomplete-json-parser, которая разбирает данные по мере поступления.
import { IncompleteJsonParser } from "incomplete-json-parser";
const parser = new IncompleteJsonParser();
parser.write(chunk); // отправка очередной порции в парсер
Такой парсер не дожидается полной последовательности и продолжает работу, пока приходят новые данные
Библиотека incomplete-json-parser работает иначе, чем стандартный JSON.parse. Она не требует, чтобы JSON-документ был получен полностью, включая завершающие скобки. Если данные поступают частями, JSON.parse завершит работу с ошибкой, тогда как incomplete-json-parser продолжает обрабатывать уже поступившую информацию. Такой подход позволяет начать разбор раньше, уменьшить задержки и ускорить отображение интерфейса.
Да, такой парсер работает медленнее встроенного — он написан на JavaScript, а не на C++. Но он даёт главное — интерфейс остаётся отзывчивым, даже если JSON большой или приходит медленно. По моему мнению, это особенно важно на слабых устройствах и при нестабильной сети.
Приём №3. Постепенный рендеринг компонентов
Обычно в классическом BDUI-flow после получения JSON разработчик строит новое виртуальное DOM-дерево и отдаёт его React для рендеринга. Но если JSON большой, это приводит к резкому росту потребления памяти. Сборщик мусора начинает срабатывать чаще, а интерфейс начинает тормозить.
Чтобы сократить потребление памяти, я решил не формировать новое виртуальное дерево. Вместо этого я брал уже полученный JSON и использовал его напрямую как основу для виртуального DOM.
useEvent(
"data",
(event) => {
const { detail } = event as CustomEvent;
const items: ReactElement[] = detail?.templates?.landing?.items || [];
for (const item of items) {
if ('$$type' in item) continue;
Object.defineProperties(item, {
$$typeof: { value: Symbol.for("react.transitional.element"), configurable: true },
type: { value: SignalValue, configurable: true },
props: {
configurable: true,
get() { return { data: this }; },
},
ref: { value: null, configurable: true },
});
}
setData(items);
},
stream.eventTarget,
{ signal: ac.current.signal }
);
function SignalValue({ data }) {
return JSON.stringify(data);
}
Для React 18 и ниже вместо react.transitional.element следует использовать react.element
Постепенный рендеринг помогает обновлять компоненты без создания нового дерева виртуального DOM. Это снижает использование памяти и устраняет фризы, связанные с работой сборщика мусора. Рендеринг выполняется на уже существующих данных, что делает интерфейс более отзывчивым и интерактивным.
Когда и как использовать эти приёмы
Все три приёма можно использовать как вместе, так и по отдельности, в зависимости от сложности задачи. Вы можете выбрать подход, который решает именно вашу проблему, или комбинировать их для максимального эффекта.
Например, если проект небольшой и данные на страницах не слишком велики, можно начать с одного из подходов — например, с потоковой загрузки. Если проект сложный, с большим объёмом данных и динамическим интерфейсом, лучше использовать все три приёма в комплексе.
Что у меня изменилось после внедрения
После перехода на потоковую загрузку, частичный парсинг и постепенный рендеринг интерфейс стал работать быстрее. Несмотря на то, что решения ещё не внедрены в реальный проект, результаты на демо-версии показали явное улучшение:
Потоковая загрузка сократила время ожидания. Данные начали загружаться и обрабатываться по частям за счёт чего первые элементы интерфейса отображаются сразу, а не только после загрузки всего JSON.
Частичный парсинг улучшил производительность. Чтобы не ждать полного парсинга всего JSON-файла, данные теперь обрабатываются по мере поступления. Это позволяет не блокировать главный поток и не замедлять интерфейс.
Постепенный рендеринг загружает контент частями и избавляет от спиннера. Раньше пользователь видел только спиннер в течение нескольких секунд, а теперь страница начинает собираться постепенно: шапка, первые блоки и всё остальное.
В итоге интерфейс раньше становится интерактивным. Можно кликать по ссылкам, скроллить и взаимодействовать с элементами ещё до полной загрузки страницы.
Краткая выжимка статьи
Как ускорить интерфейс с Backend-Driven UI с помощью трёх приёмов:
Потоковая загрузка JSON через fetch — уменьшает время ожидания загрузки
Частичный парсинг — не блокирует поток и ускоряет обработку
Постепенный рендеринг — снижает нагрузку на память
Эти приёмы решают несколько проблем:
Сокращается время загрузки:пользователь сразу видит первую часть интерфейса, не ожидая загрузки всего JSON
Ускоряется интерактивность с интерфейсом: даже если данные не пришли полностью, пользователь уже может взаимодействовать с тем, что загружено
Повышается отзывчивость интерфейса: страницы больше не «зависают» из-за большого JSON, что заметно на слабых устройствах и при плохом интернете
Заглядывайте в Telegram-канал Outlines Tech. Там мы с командой делимся кейсами на тему IT, обсуждаем работу, рассказываем про карьерный рост и публикуем мемы.
Буду рад услышать мнение и обсудить, какие приёмы, по вашему опыту, сработают лучше всего. Расскажите, как вы решали подобные задачи на своём проекте?