Содержание

Резюме

В этом подробном техническом статье мы исследуем, как реализовать селектор темы (light/dark) без неприятного эффекта мерцания, который возникает при загрузке страницы. Мы подробно анализируем цикл рендеринга браузера, основные причины FOUC (Flash of Unstyled Content) и предлагаем надежное решение, основанное на предварительной синхронной загрузке в`<head>`. Эта техника обеспечивает плавный и профессиональный пользовательский опыт.

Проблема: Это проклятое мерцание темы

Кошмар для пользовательского опыта

Представьте сцену: вы потратили часы на создание красивой тёмной темы для вашего веб-приложения. Цвета идеально сбалансированы, контраст оптимален, и вашим пользователям нравится этот вариант. Но есть неловкая проблема: при каждой перезагрузке страницы, на долю секунды, отображается светлая тема по умолчанию, прежде чем тёмная тема вступит в силу.

Эта визуальная "flash", хотя и кратковременная (иногда менее 100 мс), сразу же воспринимается человеческим глазом и создает неприятные ощущения. Для пользователей, которые выбрали тёмную тему из соображений визуального комфорта или доступности, это мерцание может даже стать болезненным, особенно в слабо освещённом окружении.

FOUC: Старая веб‑проблема

Это явление является вариантом того, что называется «Flash of Unstyled Content» (FOUC), классической проблемой веб‑разработки, которая восходит к первым дням CSS. FOUC возникает, когда браузер временно отображает HTML‑содержимое без применяемых к нему стилей CSS, создавая всплеск нестилизованного содержимого.

В нашем конкретном случае мы не говорим о полностью не оформленном контенте, а скорее оВспышка неправильной темы(FOWT) - контент стилизован, но с неправильной темой. Это особенно раздражает, потому что показывает, что наше приложение «забывает» предпочтение пользователя при каждой загрузке страницы.

Влияние на восприятие качества

Хотя эта проблема техническая, она оказывает значительное влияние на восприятие качества вашего приложения:

Недостаток полировки: Мерцание создает впечатление незавершенного или плохо оптимизированного приложения. Пользователи часто ассоциируют эти небольшие визуальные дефекты с недостатком профессионализма.

Нарушение когерентности: Приложение кажется "забывает" предпочтения пользователя, создавая ощущение десинхронизации между интерфейсом и ожиданиями.

Глазная усталостьДля пользователей, чувствительных к свету или страдающих от мигрени, эта яркая вспышка может быть больше, чем простое эстетическое неудобство.

Воспринимаемая производительностьИронично, даже если ваш сайт загружается быстро, это мигание может создавать впечатление медленного или малоотзывчивого приложения.

Технический анализ проблемы

Для понимания того, как решить эту проблему, сначала нужно понять, почему она возникает. Мерцание происходит из-за временного смещения между тремя критическими событиями в жизненном цикле веб-страницы:

  1. Первоначальный парсинг HTMLБраузер читает и анализирует структуру вашей страницы

  2. Применение стилей CSSБраузер применяет правила CSS и вычисляет визуальное отображение

  3. Выполнение JavaScriptВаш код, который меняет тему, выполняется

Проблема возникает, когда событие №3 (выполнение JavaScript) приходит после того, как браузер уже начал или завершил событие №2 (применение стилей). В этот момент браузер уже принял решение о том, какую тему отображать, и ваш код приходит слишком поздно, чтобы повлиять на это до первого рендера.

Почему классический скрипт недостаточен?

Интуитивный, но неэффективный подход

Самый естественный подход для разработчика — поместить скрипт в конец нашего`<body>`который проверяет предпочитаемую тему пользователя и применяет её. Этот подход следует традиционным лучшим практикам веб-разработки, которые рекомендуют загружать скрипты в конце страницы, чтобы не блокировать рендеринг.

// À la fin de <body> - L'APPROCHE INSUFFISANTE
document.addEventListener('DOMContentLoaded', () => {
  const theme = localStorage.getItem('preferred-theme');
  if (theme === 'dark') {
    document.documentElement.setAttribute('data-bs-theme', 'dark');
  }
});

Этот подход кажется логичным на первый взгляд. Мы ждём, пока DOM будет готов, а затем применяем тему. Просто, не так ли? К сожалению, эта простота скрывает фундаментальный недостаток, связанный с временем цикла рендеринга браузера.

Понимание события DOMContentLoaded

Событие`DOMContentLoaded`срабатывает когда начальный HTML‑документ полностью загружен и проанализирован браузером,не дожидаяськонец загрузки таблиц стилей, изображений и подфреймов. Это важный момент для понимания.

Вот типичная последовательность событий:

  1. Браузер начинает загружать HTML

  2. Он анализирует HTML по мере его получения

  3. Он обнаруживает теги`<link>`pour CSS и начинает загружать их

  4. Он обнаруживает метки`<script>`и выполняет их (в зависимости от их типа и атрибутов)

  5. Он создаёт DOM (Document Object Model)

  6. Событие DOMContentLoaded срабатывает

  7. Он продолжает применять стили и выполнять разметку

  8. Первый paint (отображение) происходит

  9. Событие`load`срабатывает когда все ресурсы загружены

Проблема? Между шагом 6 (DOMContentLoaded) и шагом 8 (первый рендер) браузер уже принял решения о том, как отобразить страницу. Если ваш скрипт переключения темы выполняется на шаге 6, то уже слишком поздно, чтобы избежать первого отображения с использованием стилей по умолчанию.

Проблема блокировки рендеринга

На самом деле, тайминг ещё сложнее. Современные браузеры используют сложные техники оптимизации для улучшения воспринимаемой производительности. Они стараются выполнить первый рендер (First Contentful Paint) как можно быстрее, чтобы пользователь увидел что-то на экране.

CSS по умолчанию являются "render-blocking", что означает, что браузер ждёт загрузки и разбора таблиц стилей перед выполнением первого отрисов. Это логично: мы не хотим отображать нестилизованный контент.

Но вот в чем подвох: когда браузер впервые применяет эти стили CSS, он делает это, исходя из текущего состояния DOM. Если атрибут`data-bs-theme`ещё не определён на метке`<html>`, браузер применит стили по умолчанию (обычно светлая тема).

Затем, когда ваш скрипт выполняется и меняет этот атрибут, браузер должен:

  1. Пересчитать все стили, затронутые этим изменением

  2. Переделать макет при необходимости

  3. Перекрасить затронутые элементы

Именно этот процесс пересчёта и перерисовки вызывает видимое мерцание.

Визуализация проблемы

Для лучшего понимания этой проблемной последовательности, рассмотрим подробную диаграмму последовательности:

@startuml
actor Utilisateur
participant Navigateur
participant "Parser HTML" as Parser
participant "CSSOM" as CSS
participant "Script.js (fin de body)" as Script
participant "Render Engine" as Render

Utilisateur -> Navigateur: Demande la page
activate Navigateur

Navigateur -> Parser: Parse le HTML
activate Parser
Parser -> CSS: Découvre et charge les CSS
activate CSS
Parser -> Parser: Construit le DOM
Parser -> Navigateur: DOM Ready (DOMContentLoaded)
deactivate Parser

CSS -> CSS: Parse les feuilles de style
CSS -> Render: CSSOM Ready
deactivate CSS

activate Render
Render -> Render: Calcule les styles avec data-bs-theme=""
note right: Thème par défaut (light)
Render -> Navigateur: Premier Paint (Thème Clair)
note right: L'utilisateur voit le thème clair
deactivate Render

Navigateur -> Script: Exécute le script
activate Script
Script -> Script: Lit localStorage
Script -> Navigateur: setAttribute('data-bs-theme', 'dark')
deactivate Script

activate Render
Render -> Render: Recalcule les styles avec data-bs-theme="dark"
note right: Thème sombre
Render -> Navigateur: Repaint (Thème Sombre)
note right: Clignotement visible
deactivate Render

deactivate Navigateur
@enduml

Эта диаграмма четко иллюстрирует проблему: первый paint происходит до того, как наш скрипт успевает задать правильную тему. Последующий repaint создает видимое мерцание.

Неэффективные попытки решения

Было предпринято несколько подходов для решения этой проблемы, но у большинства из них есть свои недостатки:

Подход 1: Скрыть содержимое до загрузки

body {
  opacity: 0;
  transition: opacity 0.3s;
}

body.loaded {
  opacity: 1;
}

Этот подход скрывает весь контент до тех пор, пока JavaScript не установит правильную тему. Проблема? Это искусственно задерживает отображение контента, создавая впечатление более медленного сайта. Более того, если JavaScript отключён, пользователь не увидит ничего вообще!

Подход 2: Использовать загрузчик/спиннер

Подобно подходу 1, но с индикатором загрузки. Это маскирует проблему, но не улучшает реальную производительность и добавляет ненужную воспринимаемую задержку.

Подход 3: По умолчанию — тёмная тема

Некоторые разработчики задают тёмную тему как значение по умолчанию в CSS. Это устраняет мерцание для пользователей тёмной темы, но создаёт обратную проблему для пользователей светлой темы !

Ни один из этих подходов не является удовлетворительным, поскольку они обрабатывают симптом, а не коренную причину проблемы.

Истинное решение : действовать раньше

Ключ к решению этой проблемы — осознать, что нам нужно задать атрибут`data-bs-theme` передчто браузер ещё не начал применять стили CSS. Это означает, что наш скрипт должен выполняться раньше в жизненном цикле страницы, и именно это мы будем изучать в следующем разделе.

Решение: Предварительная загрузка (Early Loading)

Основополагающий принцип

Элегантное решение нашей проблемы мерцания основано на простом, но мощном принципе:Синхронизировать состояние приложения с процессом рендеринга браузера. Вместо того чтобы ждать загрузки страницы, чтобы определить тему, мы должны её определитьв течениезагрузка, ещё до применения стилей CSS

Такой подход называется «Early Loading» или «Synchronous Preloading» в жаргоне веб‑разработки. Идея состоит в том, чтобы выполнить нашу логику определения темы как можно раньше в жизненном цикле страницы, идеально — в теге`<head>`, даже до того, как браузер начинает загружать файлы CSS.

Почему <head> — идеальное место

Le `<head>`Документ HTML обрабатывается браузером последовательно, сверху вниз. Каждый элемент обрабатывается в порядке, в котором он появляется. Это свойство является решающим для нашего решения.

Когда браузер встречает тег`<script>`в`<head>`без атрибутов`async` ou defer, il :

  1. Прерывает парсинг HTML

  2. Скачай скрипт(если внешнее) или кровать (если встроенное)

  3. Выполните скрипт немедленно

  4. Возобновить парсинг HTML

Это поведение, часто рассматриваемое как проблема производительности (отсюда обычная рекомендация размещать скрипты в конце страницы), становится нашим союзником в этом конкретном случае. Размещая наш скрипт определения темы в начале`<head>`, мы гарантируем, что он выполнится до того, как браузер встретит теги`<link>`из наших таблиц стилей.

Трёхслойная архитектура решения

Наше комплексное решение состоит из трёх взаимосвязанных слоёв, каждый из которых играет определённую роль:

Слой 1 : Persistence (localStorage) Этот слой отвечает за сохранение и восстановление выбора пользователя между сеансами.

Слой 2: Ранняя синхронизация (встроенный скрипт в <head>) Этот слой синхронизирует состояние приложения с DOM перед первоначальной отрисовкой.

Слой 3 : Реактивные стили (CSS с селекторами атрибутов) Этот слой определяет визуальные стили, основанные на состоянии, определённом слоем 2.

Теперь давайте подробно рассмотрим каждый слой.

Шаг 1: Сохранить выбор пользователя

localStorage: Ваша постоянная память

Le localStorage`это API Web Storage, которая позволяет хранить пары ключ-значение в браузере в постоянном режиме. В отличие от cookies, данные`localStorage:

  • Никогда не отправляются на сервер автоматически

  • У них есть большая ёмкость хранилища (обычно 5–10 МБ)

  • У них нет даты истечения срока (постоянные до явного удаления)

  • Ограничены протоколом и доменом (Same-Origin Policy)

Для нашего случая использования, этот`localStorage`Он идеален, потому что:

  1. Нам не нужно делиться этой информацией с сервером

  2. Мы хотим, чтобы предпочтение сохранялось навсегда

  3. Требуемый объём хранилища минимален (несколько байт)

Реализация сохранения темы

Вот как мы сохраняем выбор пользователя при смене темы :

// Fonction complète pour changer le thème
function setTheme(newTheme) {
  // Validation de l'entrée
  if (!['light', 'dark', 'auto'].includes(newTheme)) {
    console.error('Thème invalide:', newTheme);
    return;
  }

  try {
    // Sauvegarde dans localStorage
    localStorage.setItem('preferred-theme', newTheme);

    // Application immédiate dans le DOM
    document.documentElement.setAttribute('data-bs-theme', newTheme);

    // Dispatch d'un événement personnalisé pour notifier d'autres composants
    window.dispatchEvent(new CustomEvent('theme-changed', {
      detail: { theme: newTheme }
    }));

    console.log('Thème changé:', newTheme);
  } catch (error) {
    console.error('Erreur lors de la sauvegarde du thème:', error);
    // Fallback : on applique quand même le thème visuellement
    document.documentElement.setAttribute('data-bs-theme', newTheme);
  }
}

// Exemple d'utilisation avec un bouton
document.getElementById('theme-toggle').addEventListener('click', () => {
  const currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light';
  const newTheme = currentTheme === 'light' ? 'dark' : 'light';
  setTheme(newTheme);
});

Управление случаями ошибок

Это важно управлять случаями, когда`localStorage`не доступен или недоступен. Несколько сценариев могут препятствовать доступу к`localStorage`:

Строгая приватная навигацияSafari в режиме приватного просмотра генерирует исключение`QuotaExceededError`при попытках записи в`localStorage`.

Настройки конфиденциальности: Некоторые браузеры или расширения конфиденциальности могут блокировать доступ к`localStorage`.

Ограничения области : Le `localStorage`не доступен по протоколу`file://`в некоторых браузерах.

Хранилище заполнено: Хотя редко, пространство для хранения может быть полностью заполнено.

Поэтому наш код использует блок`try…​catch`чтобы грациозно обрабатывать эти случаи, продолжая предлагать функциональность смены темы даже если сохранение недоступно.

Продвинутые стратегии выживания

Для более сложных приложений вы можете рассмотреть дополнительные стратегии:

Синхронизация сервера (необязательная)

async function setTheme(newTheme) {
  // Sauvegarde locale immédiate
  localStorage.setItem('preferred-theme', newTheme);
  document.documentElement.setAttribute('data-bs-theme', newTheme);

  // Synchronisation serveur en arrière-plan (si l'utilisateur est connecté)
  if (userIsAuthenticated()) {
    try {
      await fetch('/api/user/preferences', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ theme: newTheme })
      });
    } catch (error) {
      console.warn('Échec de la synchronisation serveur:', error);
      // L'échec n'est pas critique car la préférence est déjà sauvegardée localement
    }
  }
}

Этот подход позволяет синхронизировать предпочтения между устройствами для подключённых пользователей, сохраняя при этом немедленную локальную отзывчивость.

Этап 2: Скрипт предзагрузки в `<head>

Сердце решения

Это здесь магия действительно происходит. Мы собираемся разместить небольшой скриптвстроенныйпрямо в нашем`<head>`, перед всеми нашими тегами`<link>`таблиц стилей. Этот скрипт намеренно минималистичен, автономен и предназначен для выполнения как можно быстрее.

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mon Site Incroyable</title>

    <!-- ==========================================
         NOTRE SCRIPT MAGIQUE DE PRÉ-CHARGEMENT
         Ce script DOIT être le premier élément
         dans le <head> après les meta tags
         ========================================== -->
    <script>
      // IIFE pour ne pas polluer le scope global
      (function() {
        'use strict';

        try {
          // Lecture de la préférence sauvegardée
          const savedTheme = localStorage.getItem('preferred-theme');

          // Si une préférence existe, on l'applique immédiatement
          if (savedTheme) {
            document.documentElement.setAttribute('data-bs-theme', savedTheme);
          }
          // Optionnel : Détecter la préférence système si aucune sauvegarde
          else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
            document.documentElement.setAttribute('data-bs-theme', 'dark');
          }
          // Sinon, le thème par défaut du CSS sera utilisé (généralement 'light')

        } catch (error) {
          // En cas d'erreur (localStorage bloqué, etc.), on log discrètement
          // et on laisse le thème par défaut s'appliquer
          console.warn('Impossible de charger la préférence de thème:', error);
        }
      })();
    </script>
    <!-- FIN DU SCRIPT MAGIQUE -->

    <!-- Les feuilles de style sont chargées APRÈS le script -->
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/styles.css">

    <!-- Autres ressources du head -->
    <link rel="icon" href="favicon.ico">
</head>
<body>
    <!-- Contenu de la page -->
</body>
</html>

Анатомия сценария: каждая строка важна

Разберём этот скрипт строка за строкой, чтобы понять каждое решение дизайна:

IIFE (немедленно вызываемая функция)

(function() {
  // ...
})();

Эта структура создаёт функцию, которая выполняется немедленно. Почему? Чтобы изолировать наши переменные в локальном скоупе и избежать загрязнения глобального скоупа. Даже если мы используем только`const`(который имеет область видимости блока), IIFE — хорошая практика, которая делает наши намерения очевидными и защищает от возможных конфликтов имён.

Строгий режим

'use strict';

Эта директива включает строгий режим JavaScript, который : - Запрещает использование не объявленных переменных - Генерирует ошибки для опасных операций - Улучшает производительность в некоторых движках JavaScript

Для критического скрипта такого рода мы хотим максимальную безопасность.

Блок try…​catch

try {
  // Code principal
} catch (error) {
  console.warn('Impossible de charger la préférence de thème:', error);
}

Этот блок абсолютно критичен. Он гарантирует, что если что-то пойдёт не так (localStorage заблокирован, маловероятная синтаксическая ошибка и т.д.), наш скрипт не заблокирует загрузку всей страницы. Использование`console.warn`скорее чем`console.error`указывает, что это некритическая проблема.

Чтение localStorage

const savedTheme = localStorage.getItem('preferred-theme');

Эта строка может вызывать исключение в некоторых контекстах (строгий приватный режим Safari). Поэтому она находится в блоке try…​catch.

Условное приложение

if (savedTheme) {
  document.documentElement.setAttribute('data-bs-theme', savedTheme);
}

Мы применяем тему только если нашли сохранённый вариант. Иначе мы позволяем CSS использовать его тему по умолчанию. Такой подход более надёжен, чем жёстко закодированное значение по умолчанию в JavaScript.

Обнаружение системного предпочтения (Бонус)

Опциональное, но элегантное улучшение — это определение предпочтения темы операционной системы пользователя, если он ещё не сделал явного выбора в вашем приложении :

else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
  document.documentElement.setAttribute('data-bs-theme', 'dark');
}

Эта функция использует медиа‑запрос`prefers-color-scheme`pour опроса системы. На macOS, Windows 10+, iOS и современном Android, этот запрос возвращает системное предпочтение пользователя.

Преимущества: Персонализированный опыт с первого посещения - Соответствие системному окружению пользователя - Не требуется хранение при первом посещении

Соображения : - Не все браузеры поддерживают эту функцию (но поддержка отличная с 2020 года) - Проверка`window.matchMedia`обеспечивает совместимость - Пользователь всегда может переопределить этот выбор

Производительность: Почему этот скрипт быстрый

Наш скрипт предзагрузки разработан, чтобы быть чрезвычайно быстрым:

Минимальный размерПримерно 300 байт не минифицированных, 200 байт минифицированных. Это пренебрежимо мало по сравнению с любым изображением или библиотекой JavaScript.

встроенный: Нет дополнительного HTTP-запроса. Скрипт находится в HTML, поэтому он сразу доступен.

Простые синхронные операцииЧтение ключа из localStorage (операция ультрабыстрая) и изменение атрибута DOM (операция, нативная для браузера).

Нет зависимостиНет фреймворка, нет библиотеки, просто чистый JavaScript. Нет времени запуска, нет парсинга зависимостей.

Единичное выполнение: Этот скрипт выполняется один раз при загрузке. Нет слушателей событий, нет циклов, нет сложных вычислений.

На практике, на современном оборудовании этот скрипт выполняется за менее чем 1 миллисекунды, незаметное время, которое не оказывает никакого влияния на производительность загрузки страницы.

Оптимальное размещение в `<head>

Порядок элементов в`<head>`Это важно. Вот рекомендуемый порядок:

<head>
    <!-- 1. Métadonnées critiques -->
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <!-- 2. Notre script de pré-chargement (IMMÉDIATEMENT après les meta) -->
    <script>
      (function() { /* notre code */ })();
    </script>

    <!-- 3. Titre de la page -->
    <title>Mon Site</title>

    <!-- 4. Feuilles de style -->
    <link rel="stylesheet" href="styles.css">

    <!-- 5. Autres ressources (fonts, favicons, etc.) -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="icon" href="favicon.ico">

    <!-- 6. Autres scripts avec defer ou async -->
    <script src="app.js" defer></script>
</head>

Этот порядок гарантирует, что :

  1. Кодировка определяется перед любой обработкой текста

  2. Наш скрипт выполняется перед загрузкой CSS

  3. CSS загружаются потом и напрямую применяют правильную тему

  4. Другие некритические ресурсы загружаются в последнюю очередь

Шаг 3 : Сила селекторов атрибутов CSS

Тематическая система Bootstrap 5

Bootstrap 5 ввёл элегантную систему управления темами, основанную на пользовательских свойствах CSS (CSS‑переменных) и селекторах атрибутов. Эта система использует атрибут`data-bs-theme`на элементе`<html>`чтобы определить, какой набор цветовых переменных применить.

Прелесть этой системы заключается в её простоте: вместо загрузки различных таблиц стилей или переключения классов на тысячах элементов, мы просто меняем один атрибут на одном элементе, а CSS делает остальное благодаря каскаду.

Структура CSS для системы тем

Вот полная структура CSS для реализации надёжной системы темы :

/**
 * SYSTÈME DE THÈME COMPLET
 * Utilise les Custom Properties CSS pour une maintenance facile
 */

/* ============================================
   THÈME PAR DÉFAUT (LIGHT)
   Défini sur :root pour être le fallback
   ============================================ */
:root {
  /* Couleurs de base */
  --color-primary: #0d6efd;
  --color-secondary: #6c757d;
  --color-success: #198754;
  --color-danger: #dc3545;
  --color-warning: #ffc107;
  --color-info: #0dcaf0;

  /* Couleurs de fond */
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --bg-tertiary: #e9ecef;

  /* Couleurs de texte */
  --text-primary: #212529;
  --text-secondary: #6c757d;
  --text-tertiary: #adb5bd;

  /* Couleurs de bordure */
  --border-color: #dee2e6;
  --border-color-subtle: #e9ecef;

  /* Couleurs d'ombre */
  --shadow-sm: rgba(0, 0, 0, 0.075);
  --shadow-md: rgba(0, 0, 0, 0.15);
  --shadow-lg: rgba(0, 0, 0, 0.25

Похожие статьи