要約

この詳細な技術記事では、ページの読み込み時に発生する不快なちらつきを伴わないテーマセレクター(light/dark)の実装方法を探ります。ブラウザのレンダリングサイクルを詳しく分析し、FOUC(Flash of Unstyled Content)の根本原因を明らかにし、同期プリロードに基づく堅牢なソリューションの`<head>`この技術は、スムーズでプロフェッショナルなユーザー体験を保証します。

問題: この呪われたテーマの点滅

ユーザーエクスペリエンスの悪夢

想像してください:あなたはウェブアプリ用の素晴らしいダークテーマを作るのに何時間も費やしました。色は完璧にバランスが取れ、コントラストは最適で、ユーザーはこのオプションを愛しています。しかし、恥ずかしげな問題があります:ページをリロードするたびに、一瞬だけデフォルトのライトテーマが表示され、それからダークテーマに切り替わります。

このビジュアルな"flash"は、短い(時には100ms未満)にもかかわらず、人間の目にすぐに感じ取られ、不快な体験を作り出します。視覚的な快適さやアクセシビリティのためにダークテーマを選択したユーザーにとって、このちらつきは場合によって痛みを感じるほどになり得ます。特に照明が薄い環境ではそうなります。

FOUC:古いウェブの問題

この現象は「Flash of Unstyled Content」(FOUC)と呼ばれるものの一種であり、これはウェブ開発における古典的な問題で、CSSの黎明期まで遡ります。FOUCは、ブラウザが一時的にCSSが適用されていないHTMLコンテンツを表示し、スタイルの適用されていないコンテンツのフラッシュを引き起こすときに発生します。

私たちの特殊なケースでは、完全にスタイルが適用されていないコンテンツではなく、むしろ間違ったテーマのフラッシュ(FOWT) - 内容はスタイルされているが、間違ったテーマが適用されている。特にイライラするのは、ページを読み込むたびにアプリがユーザーの好みを「忘れて」しまうことを示しているからだ。

品質の認識への影響

この問題は技術的なものですが、アプリケーションの品質に対する認識に重大な影響を与えます。

ポリッシュ不足: 点滅は、未完成あるいは最適化されていないアプリケーションであるという印象を与えます。ユーザーはしばしばこれらの小さな視覚的な欠陥を、プロフェッショナリズム全般の欠如と関連付けます。

一貫性の欠如アプリケーションはユーザーの設定を「忘れている」ように見え、インターフェイスと期待の間にずれている感覚を作り出します。

視覚疲労光に敏感なユーザーや片頭痛に苦しむユーザーにとって、この明るいフラッシュは単なる見た目の不快感以上の問題となる可能性があります。

知覚されたパフォーマンス皮肉にも、サイトがすぐに読み込まれても、この点滅は遅い、または応答性の低いアプリケーションのように感じられることがあります。

問題の技術的分析

この問題を解決する方法を理解するには、まずなぜそれが起こるのかを理解する必要があります。ウェブページのライフサイクルにおける3つのクリティカルなイベント間の時間的ずれによって、ちらつきが発生します。

  1. HTMLの初期パースブラウザはページの構造を読み込み、分析します

  2. CSSスタイルの適用ブラウザはCSSのルールを適用し、視覚的レンダリングを計算します。

  3. JavaScriptの実行: テーマを変更するコードが実行されます

問題は、イベント n°3 (JavaScript の実行) が、ブラウザがすでに開始または完了したイベント n°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>`CSS用にダウンロードを開始する

  4. 彼はタグを見つける`<script>`それらを(その種類および属性に応じて)実行します

  5. 彼は DOM (Document Object Model) を構築します。

  6. イベント DOMContentLoaded がトリガーされます

  7. 彼はスタイルを適用し続け、レイアウトを作成し続けている

  8. 最初のペイント(表示)が発生します

  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 "HTMLパーサー" as Parser
participant "CSSOM" as CSS
participant "Script.js (ボディの終わり)" as Script
participant "レンダーエンジン" 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: デフォルトテーマ (light)
Render -> Navigateur: 初回ペイント (ライトテーマ)
note right: ユーザーはライトテーマを見ます
deactivate Render

Navigateur -> Script: スクリプトを実行
activate Script
Script -> Script: localStorageを読む
Script -> Navigateur: setAttribute('data-bs-theme', 'dark')
deactivate Script

activate Render
Render -> Render: data-bs-themeを含むスタイルを再計算"dark"
note right: Thème sombre
Render -> Navigateur: Repaint (Thème Sombre)
note right: Clignotement visible
deactivate Render

deactivate Navigateur
@enduml

この図は問題をはっきりと示しています:最初のペイントは、スクリプトが正しいテーマを設定する機会を得る前に発生します。その後のリペイントが目に見えるちらつきを引き起こします。

�効果のない解決策の試み

この問題を解決しようといくつかのアプローチが試みられましたが、ほとんどのものはそれぞれ固有の欠点があります:

方法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>`私たちのスタイルシートの。

ソリューションの3層アーキテクチャ

私たちの完全なソリューションは、相互に依存する3つの層から構成されており、それぞれが特定の役割を果たします:

層 1 : 永続性 (localStorage) この層は、セッション間でユーザーの選択を保存および取得する役割を担っています。

レイヤー 2: 早期同期(`<head>`内のインラインスクリプト) このレイヤーは、初期レンダリング前にアプリケーションの状態とDOMを同期します。

レイヤー3:レスポンシブスタイル (属性セレクタを使ったCSS) このレイヤーは、レイヤー2で定義された状態に基づいてビジュアルスタイルを定義します。

それでは、各層を詳細に調べていきましょう。

ステップ1:ユーザーの選択を保存する

localStorage: あなたの永続的なメモリ

Le localStorage`は、ブラウザにキーと値のペアを永続的に保存できる Web Storage API です。 Cookie とは異なり、データは`localStorage :

  • サーバーには決して自動的に送信されません

  • ストレージ容量が大きい(通常は5-10MB)

  • 有効期限がなく(明示的に削除されるまで継続)

  • プロトコルとドメインに制限されます (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`(No content to translate, so output nothing.)

�厳格なプライベートナビゲーション: 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は良いプラクティスであり、私たちの意図を明確にし、名前の衝突から保護します。

Strictモード

'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`システムを問い合わせるため。macOS、Windows 10+、iOS、および最新のAndroidでは、このクエリはユーザーのシステム設定を返します。

利点: 初めての訪問からのパーソナライズドな体験 - ユーザーのシステム環境との整合性 - 初回訪問にストレージは必要ありません

考慮事項 : - すべてのブラウザーがこの機能をサポートしているわけではない(ただし、2020年以降サポートは非常に優れている) - 検証`window.matchMedia`互換性を確保する - ユーザーは常にこの選択を上書きできます

パフォーマンス:なぜこのスクリプトは速い

私たちのプリロードスクリプトは、非常に高速に動作するように設計されています:

最小サイズ約300バイト(未圧縮)、200バイト(圧縮後)。これは、どの画像やJavaScriptライブラリに比べても無視できる程度です。

インライン追加の HTTP リクエストはありません。スクリプトは HTML に組み込まれているため、すぐに利用できます。

シンプルな同期操作localStorageからキーを読み取る(超高速な操作)と、DOM属性を変更する(ブラウザのネイティブな操作).

依存関係なし: フレームワークもライブラリもなく、ただのバニラJavaScriptだけ。起動時間も依存のパースもなし。

一度だけの実行: このスクリプトはロード時に1回だけ実行されます。イベントリスナーもループも複雑な計算もありません。

実際に、最新のハードウェアでは、このスクリプトは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. charsetはテキスト処理の前に定義される

  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

関連記事