Белый экран при загрузке SPA — типичная боль. Пользователь открывает приложение, ждёт пока загрузится приложение, а экран пуст. А если качество связи оставляет желать лучшего а размер чанков не может похвастаться оптимизацией(да это отдельная тема для обсуждений, но все же)? Я часто сталкиваюсь с этим в реальных проектах и вот наконец то появилось время и силы сделать так, чтобы у пользователя никогда не было пустоты на экране.
Вместо кучи прелодеров на уровне компонентов я сделал единую заглушку в index.html, которая появляется сразу и скрывается только тогда, когда приложение действительно готово.
Что происходит при старте:
Обычно цепочка такая:
грузится HTML,
качаются JS-чанки,
запускается Angular,
улетает первый запрос на сервер,
рендерятся компоненты.
До инициализации Angular пользователь видит пустой экран. Прелодеры внутри компонентов появляются только после запуска фреймворка. В моем случае так же возникла необходимость рендера приложения после получения стартовых данных с сервера(рендер канвас холста и т.д., но не об этом). Нужно было перекрыть весь процесс.
Решение:
Я добавил блок прямо в index.html, который показывается мгновенно и содержит:
логотип/иконку,
текст,
спиннер,
блок ошибки с кнопкой «повторить��.
Вот упрощённый кусок HTML (полный код ниже):
<div class="app-loading"> <div class="app-loading__logo"></div> <div id="loading-text" class="app-loading__text">Загрузка...</div> <div id="loading-spinner" class="app-loading__spinner"></div> <div style="display: none;" id="loading-error" class="app-loading__error"> <div class="app-loading__error-icon">⚠️</div> <div class="app-loading__error-text"></div> <button class="app-loading__retry-btn">Попробовать снова</button> </div> </div>
Стили — соответственно там же в index.html
Глобальное состояние: как я связал Angular и JS:
const NAMESPACE = '__TABLE_LOADING__'; window[NAMESPACE] = { isAppLoaded: false, isTableReady: false, isLoading: true, hasError: false, errorMessage: null };
Оптимизация:
Сначала я проверял состояние каждые 100 мс через setInterval(обычный пуллинг в деле). Это работало, но создавало лишнюю нагрузку: постоянные проверки, сравнения и потенциальные утечки памяти.
В финальной версии я заменил это на Proxy. Теперь заглушка реагирует только на реальные изменения состояния:
function createStateProxy(obj, callback) { return new Proxy(obj, { set(target, prop, value) { if (target[prop] === value) return true; // без лишних действий target[prop] = value; callback(target); // вызываю onStateChange только при изменении return true; } }); }
index.html — финальная версия(js + html):
<body> <div class="app-loading"> <div class="app-loading__logo"></div> <div class="app-loading__text" id="loading-text">Загрузка игрового стола...</div> <div class="app-loading__spinner" id="loading-spinner"></div> <div class="app-loading__error" id="loading-error" style="display: none;"> <div class="app-loading__error-icon">⚠️</div> <div class="app-loading__error-text"></div> <button class="app-loading__retry-btn" onclick="location.reload()">Попробовать снова</button> </div> </div> <table-root/> <script> const NAMESPACE = '__TABLE_LOADING__'; (function () { const initial = { isAppLoaded: false, isTableReady: false, isLoading: true, hasError: false, errorMessage: null }; let observerActive = true; function onStateChange(state) { if (!observerActive) return; if (state.hasError && state.errorMessage) { showError(state.errorMessage); return; } if (state.isAppLoaded && state.isTableReady && !state.isLoading && !state.hasError) { hideLoadingScreen(); } } window[NAMESPACE] = createStateProxy(initial, onStateChange); function createStateProxy(obj, callback) { return new Proxy(obj, { set(target, prop, value) { if (target[prop] === value) return true; target[prop] = value; callback(target); return true; } }); } function hideLoadingScreen() { observerActive = false; document.body.classList.add('app-loaded'); setTimeout(() => { const loading = document.querySelector('.app-loading'); if (loading) loading.remove(); }, 300); } function showError(message) { document.getElementById('loading-text').style.display = 'none'; document.getElementById('loading-spinner').style.display = 'none'; document.getElementById('loading-error').style.display = 'flex'; document.querySelector('.app-loading__error-text').textContent = message; } // На случай если что то пойдет не так, чтобы заглушка не зависла(в будущем скорее всего удалю за ненадобностью) const MAX_TIMEOUT_MS = 100 * 1000; const fallbackTimer = setTimeout(() => { if (observerActive) hideLoadingScreen(); }, MAX_TIMEOUT_MS); const originalHide = hideLoadingScreen; hideLoadingScreen = function () { clearTimeout(fallbackTimer); originalHide(); }; })(); </script> </body>
Синхронизация состояния из angular:
В Angular-компоненте я обновляю глобальный объект по мере изменения состояния загрузки и рендера
private initializeGlobalLoadingState(): void { if (typeof window !== 'undefined' && (window as any).__TABLE_LOADING__) { (window as any).__TABLE_LOADING__.isAppLoaded = true; } effect(() => { const isTableReady = this.tableRenderer.isTableReady(); const isLoading = this.controller.isLoading(); const errorMessage = this.controller.errorMessage(); const hasError = !!errorMessage; if (typeof window !== 'undefined' && (window as any).__TABLE_LOADING__) { const state = (window as any).__TABLE_LOADING__; state.isTableReady = isTableReady; state.isLoading = isLoading; state.hasError = hasError; state.errorMessage = errorMessage || null; } }); }
Чего получилось достичь:
Нет белого экрана. Пользователь сразу видит контент(заглушку).
Нет миганий и рывков между загрузками. Заглушка одна на весь процесс загрузки.
Ошибки. Показываются прямо в заглушке, пользователь сразу понимает — что-то не так

