Как исправить FOUC в React с Tailwind Dark Mode: полное руководство
Узнайте, как исправить Flash of Unstyled Content (FOUC) в React с Tailwind CSS dark mode. Узнайте, почему useLayoutEffect предотвращает вспышку и изучите несколько решений для бесшовного опыта темного режима.
Как исправить вспышку светлого фона в тёмном режиме (FOUC) в React с Tailwind CSS?
Я столкнулся с постоянной проблемой в своём проекте на React и Tailwind CSS, где при включённом тёмном режиме появляется вспышка светлого фона. Похоже, это проблема Flash of Unstyled Content (FOUC), несмотря на мои попытки реализовать корректную обработку тёмного режима.
Вот моя конфигурация Tailwind CSS:
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-bgprimary: var(--color-zinc-50);
--color-bgsecondary: var(--color-gray-200);
--color-primary: var(--color-zinc-800);
--color-secondary: var(--color-violet-800);
}
/* Переопределения для тёмного режима */
.dark {
--color-bgprimary: var(--color-neutral-900);
--color-bgsecondary: var(--color-gray-700);
--color-primary: var(--color-zinc-200);
--color-secondary: var(--color-cyan-400);
}
Мой компонент AppLayout включает утилиты переходов:
<div className="flex min-h-screen max-w-screen flex-col bg-bgprimary p-[0.05px] text-primary util-transition-colors">
<SiteHeader onOpenDrawer={openDrawer} isDrawerOpen={drawerOpen} />
<MobileDrawer visible={drawerVisible} active={drawerActive} onClose={closeDrawer} />
<main className="flex grow flex-col util-transition-colors" role="main">
<SuspenseErrorBoundary fallback={<PageLoadingSpinner />}>
<Outlet />
</SuspenseErrorBoundary>
</main>
<SiteFooter />
</div>
Я реализовал начальную настройку темы в index.html:
<script>
// Установка начальной темы перед рендерингом для предотвращения FOUC
(function () {
function getInitialTheme() {
const storedTheme = localStorage.getItem('theme');
if (typeof storedTheme === 'string') {
return storedTheme;
}
const userMedia = window.matchMedia('(prefers-color-scheme: dark)');
if (userMedia.matches) {
return 'dark';
}
return 'light';
}
const theme = getInitialTheme();
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
})();
</script>
И моя логика переключения тёмного режима:
useEffect(() => {
const root = document.documentElement;
const applyTheme = () => {
const systemPrefersDark = isSystemDarkMode();
const currentTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;
if (currentTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
applyTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
applyTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [theme]);
Несмотря на эти реализации, вспышка светлого фона в тёмном режиме сохраняется. Я пробовал различные решения без успеха. Я заметил, что изменение useEffect на useLayoutEffect решает проблему, но я хотел бы понять, почему это работает, и существуют ли другие подходы для предотвращения FOUC в React с Tailwind CSS в тёмном режиме.
Мерцание нестилизованного контента в тёмной теме (FOUC) происходит из-за того, что компоненты React рендерятся до того, как класс тёмной темы применяется к элементу документа. Хотя ваша реализация включает правильную логику обнаружения и переключения темы, время выполнения этой логики имеет решающее значение. Использование useLayoutEffect вместо useEffect решает эту проблему, так как оно выполняется синхронно перед отрисовкой браузера, гарантируя применение класса тёмной темы перед рендерингом любого контента.
Содержание
- Понимание FOUC и его причин
- Анализ вашей текущей реализации
- Почему useLayoutEffect решает проблему
- Несколько решений для предотвращения FOUC
- Лучшие практики реализации тёмной темы
- Продвинутые техники для сложных приложений
- Заключение и рекомендации
Понимание FOUC и его причин
Мерцание нестилизованного контента (FOUC) происходит при видимой задержке между начальной отрисовкой страницы и применением правильных стилей темы. Это создаёт неприятный пользовательский опыт, когда пользователи временно видят неправильную тему, прежде чем она переключится на предпочитаемую настройку.
Основная причина — время выполнения:
- React начинает рендерить компоненты до выполнения логики темы
- Компоненты рендерятся с настройками по умолчанию/светлой темы
- Логика темы применяет класс dark после фактического рендеринга
- Браузер перерисовывает с тёмными стилями, создавая эффект мерцания
Как объясняет Kent C. Dodds, “useLayoutEffect может работать медленнее и должен использоваться умеренно, так как он задерживает рендеринг до завершения выполнения”, но именно это необходимо для предотвращения мерцания в приложениях с темами.
Анализ вашей текущей реализации
Ваша реализация включает несколько хороших практик, но имеет критическую проблему со временем выполнения:
// Текущий подход - выполняется после начального рендеринга
useEffect(() => {
const root = document.documentElement;
const applyTheme = () => {
const systemPrefersDark = isSystemDarkMode();
const currentTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;
if (currentTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
// Это выполняется ПОСЛЕ рендеринга компонентов
applyTheme();
}, [theme]);
Проблема в том, что useEffect выполняется асинхронно после того, как React отрисовал DOM и браузер отрисовал экран. К моменту выполнения этой логики пользователи уже увидели светлый фон темы.
Ваш встроенный скрипт в index.html — это хороший старт, но он может быть недостаточно полным для всех сценариев, особенно когда изменения темы происходят во время навигации.
Почему useLayoutEffect решает проблему
useLayoutEffect выполняется синхронно перед отрисовкой браузера, после того как React выполнил мутации DOM, но до обновления экрана. Это время критически важно для предотвращения FOUC:
// Правильный подход - выполняется перед отрисовкой
useLayoutEffect(() => {
const root = document.documentElement;
const applyTheme = () => {
const systemPrefersDark = isSystemDarkMode();
const currentTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;
if (currentTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
// Это выполняется ДО того, как экран будет отрисован
applyTheme();
}, [theme]);
Как объясняет Telerik, “useLayoutEffect выполняется синхронно сразу после того, как React выполнил все мутации DOM”, что именно то время, когда нам нужно применять классы темы.
Ключевое отличие:
- useEffect: Выполняется после отрисовки → происходит FOUC
- useLayoutEffect: Выполняется перед отрисовкой → нет FOUC
Несколько решений для предотвращения FOUC
Решение 1: useLayoutEffect (Быстрое исправление)
Замените useEffect на useLayoutEffect в вашей логике темы:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('system');
useLayoutEffect(() => {
const root = document.documentElement;
const applyTheme = () => {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;
root.classList.toggle('dark', currentTheme === 'dark');
};
applyTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
applyTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return children;
}
Решение 2: Встроенный скрипт в Head (Рекомендуется Tailwind)
Как показано в официальной документации Tailwind CSS, добавьте этот скрипт непосредственно в ваш HTML <head>:
<script>
// При загрузке страницы или изменении тем лучше добавить встроенно в `head` для избежания FOUC
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
</script>
Это гарантирует, что тема будет применена до рендеринга любого контента.
Решение 3: Комбинированный подход (Наиболее надёжный)
Для получения лучших результатов объедините оба подхода:
- Встроенный скрипт в HTML head для начальной загрузки страницы
- useLayoutEffect для изменений темы во время навигации
// В вашем ThemeProvider
useLayoutEffect(() => {
const root = document.documentElement;
const applyTheme = () => {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;
root.classList.toggle('dark', currentTheme === 'dark');
localStorage.setItem('theme', currentTheme);
};
applyTheme();
// Обработка изменений системных предпочтений
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
applyTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
Лучшие практики реализации тёмной темы
1. Стратегия загрузки CSS
Убедитесь, что ваш Tailwind CSS загружается рано. Рассмотрите возможность использования:
<head>
<!-- Критический CSS встроенно -->
<style>
/* Предотвращение FOUC через показ состояния загрузки */
body { visibility: hidden; }
.dark-loaded { visibility: visible; }
</style>
<!-- Ранняя загрузка Tailwind CSS -->
<link href="/path/to/tailwind.css" rel="stylesheet">
<!-- Скрипт темы -->
<script>
// Применение темы и показ контента
(function() {
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
document.body.classList.add('dark-loaded');
})();
</script>
</head>
2. Структура компонентов
Структурируйте ваши компоненты для правильного учёта темы:
function App() {
return (
<div className="min-h-screen bg-bgprimary text-primary">
<ThemeProvider>
<AppLayout />
</ThemeProvider>
</div>
);
}
3. Особенности Next.js
Если вы используете Next.js, рассмотрите это решение с StackOverflow:
// _app.js
export default function App({ Component, pageProps }) {
return (
<>
<Script strategy="beforeInteractive" src="/scripts/darkMode.js" />
);
}
Где /scripts/darkMode.js содержит:
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
Продвинутые техники для сложных приложений
1. Контекст темы с SSR
Для приложений с серверным рендерингом реализуйте совместимое с SSR обнаружение темы:
// ThemeContext.js
import { createContext, useContext, useLayoutEffect, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children, initialTheme = 'light' }) {
const [theme, setTheme] = useState(initialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
const applyTheme = () => {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;
root.classList.toggle('dark', currentTheme === 'dark');
};
applyTheme();
}, [theme]);
// ... остальная логика провайдера
}
2. Постоянное состояние темы
Используйте более надёжное управление состоянием темы:
function useTheme() {
const [theme, setTheme] = useState(() => {
if (typeof window === 'undefined') return 'light';
const stored = localStorage.getItem('theme');
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
return { theme, setTheme };
}
3. Плавные переходы
Добавьте плавные переходы для предотвращения резких изменений темы:
/* В вашем CSS или конфигурации Tailwind */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
Заключение и рекомендации
Для предотвращения FOUC в React с Tailwind CSS в тёмной теме, следуйте этим ключевым рекомендациям:
-
Используйте useLayoutEffect вместо useEffect для логики переключения темы, чтобы гарантировать применение класса dark перед отрисовкой браузера.
-
Реализуйте встроенные скрипты в HTML head для начального обнаружения темы при загрузке страницы.
-
Объединяйте несколько подходов для наиболее надёжного решения:
- Скрипт в head для начальной загрузки
- useLayoutEffect для изменений темы во время выполнения
- Правильная стратегия загрузки CSS
-
Структурируйте ваши компоненты для правильного учёта темы с самого начала, избегая вложенных провайдеров темы.
-
Учитывайте совместимость с SSR при создании приложений с серверным рендерингом.
Разница во времени выполнения между useEffect и useLayoutEffect является ключевым фактором предотвращения FOUC. Как объясняет LogRocket, время выполнения играет огромную роль в предотвращении визуальных артефактов во время рендеринга.
Для вашей конкретной реализации переход на useLayoutEffect, скорее всего, решит немедленную проблему, но реализация комбинированного подхода со скриптами в head и правильной структурой компонентов обеспечит наиболее надёжный опыт работы с тёмной темой во всех сценариях.
Источники
- Документация Tailwind CSS по тёмной теме
- useEffect vs useLayoutEffect от Kent C. Dodds
- useLayoutEffect vs. useEffect в React - Telerik
- Предотвращение мерцания страницы в Next.js 12 с Tailwind CSS
- useEffect vs useLayoutEffect в React с примерами - LogRocket
- Реализация тёмной темы в Gatsby + Tailwind с избежанием FOUC
- Исправление мерцания тёмной темы (FOUC) в React и Next.js
- useEffect vs useLayoutEffect в React - Medium