Белый экран при загрузке 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;
}
});
}
Чего получилось достичь:
Нет белого экрана. Пользователь сразу видит контент(заглушку).
Нет миганий и рывков между загрузками. Заглушка одна на весь процесс загрузки.
Ошибки. Показываются прямо в заглушке, пользователь сразу понимает - что-то не так
