Белый экран при загрузке SPA - типичная боль. Пользователь открывает приложение, ждёт пока загрузится приложение, а экран пуст. А если качество связи оставляет желать лучшего а размер чанков не может похвастаться оптимизацией(да это отдельная тема для обсуждений, но все же)? Я часто сталкиваюсь с этим в реальных проектах и вот наконец то появилось время и силы сделать так, чтобы у пользователя никогда не было пустоты на экране.

Вместо кучи прелодеров на уровне компонентов я сделал единую заглушку в index.html, которая появляется сразу и скрывается только тогда, когда приложение действительно готово.

Что происходит при старте:

Обычно цепочка такая:

  1. грузится HTML,

  2. качаются JS-чанки,

  3. запускается Angular,

  4. улетает первый запрос на сервер,

  5. рендерятся компоненты.

До инициализации 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;
    }
  });
}

Чего получилось достичь:

  1. Нет белого экрана. Пользователь сразу видит контент(заглушку).

  2. Нет миганий и рывков между загрузками. Заглушка одна на весь процесс загрузки.

  3. Ошибки. Показываются прямо в заглушке, пользователь сразу понимает - что-то не так

Внешний вид заглушки
Внешний вид заглушки

Комментарии (0)