ずいぶん昔に、サイトのダークモードを紹介する記事を書きました。
当時はシンプルなライト/ダークモード切替を実装しようと考えていました。発想はこうでした:
- 日中(朝6時〜夕方6時)は純粋なCSSの
@media (prefers-color-scheme: dark)でユーザーのデバイスがダークモードかどうかを判定。 - 夜(夕方6時〜翌朝6時)には夜用の別CSSファイルを読み込む。
見た目は直感的で簡単そうだし、夜にページを開いた瞬間目が眩む問題も解決できました。最初は悪くない効果でしたが、しばらくして2つの深刻な問題に気づきました:
- この方法ではライト/ダークモード切替のために昼用と夜用の2つのCSSファイルを常にメンテナンスしなければならない。つまり新機能を追加したり古いスタイルを削除するたびに両方のファイルを同時に更新し、CDNのキャッシュも2ファイルともクリアする必要がある。数か月ごとに2ファイルを比較して漏れがないか確認しなければならない。
- 夜のダークモードから昼のライトモードに戻せない。夜にスタイルを修正したい場合はコンソールで
<link>の中身を手動変更して昼用CSSを再読み込みしなければならなかった。
というわけで、ここ数日でライト/ダークモード切替をアップグレードしました。
Ver 0.1
書き直しの初期では「夜のダークモードから昼のライトモードに戻せない」問題に焦点を当て、localStorage で現在のページの明暗状態を保存することにしました。Light、Dark、System はそれぞれライトモード、ダークモード、システムに従うモードに対応。初回読み込み時はシステムに従い、そこからライト/ダークモードに切り替えられる。あとは light.css、dark.css、style.css の3ファイルを読み分ければよい。
<link rel="stylesheet" id="linkCSS"/>
<script>
if (localStorage.getItem("Lighting")) {
if (localStorage.getItem("Lighting") === 'Light') document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/light.css';
else if(localStorage.getItem("Lighting") === 'System') document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/style.css';
else if(localStorage.getItem("Lighting") === 'Dark') document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/dark.css';
}else{
localStorage.setItem("Lighting", "System");
document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/style.css';
}
</script>
上記を <head> に入れ、レンダリング前に実行して該当CSSを読み込む。切替ボタンも同様に localStorage を書き換えてモードを切り替え、システム従属モードでクリックされたら window.matchMedia('(prefers-color-scheme: dark)').matches でデバイスの明暗を判定して逆モードへ移行する。
ローカルでは悪くない感じでしたが、サーバーに載せて運用してみると問題が:
- やはり複数のCSSファイルを常にメンテナンスしなければならない。今回の方法は初期よりもさらに1ファイル増え、ボタンスタイル追加時の苦労が3倍に。
- CSSとJSのリソース読み込み優先度を考慮していなかった。CSSはHighest priority、scriptはHigh priorityなので、他のCSSが読み終わってから
<script>内で最もキーとなるCSSを読み始める。CSS読み込み時間の分だけ一瞬モードCSSが未読み込みの状態が発生し、見た目が非常に損なわれる。
直し直しで次のバージョンへ。
Ver 0.6
light.css、dark.css、style.css の違いは :root セレクタの変数値だけなら、3ファイルの :root 内容を順番通り1ファイルにまとめ、モードごとに <html> に該当classを付けるだけで済む。CSSは後のスタイルが前を上書きする原則を利用すれば以下のように書ける。
初回読み込みでシステム従属モードの場合、<html> には何のclassも付けない。これで :root セレクタと、デバイスがダークモード時は @media (prefers-color-scheme:dark) 内の :root セレクタが使える。ダーク/ライトモードに切り替える際は Dark または Light classを追加すれば、後方の .Dark/.Light スタイルで前方の :root スタイルを上書きできる。
また、<head> ではJS判定なしで直接CSSファイルを参照できる。デベロッパーツールのPerformanceペインを見ると、<script> 実行時にはTimingsでFP(First Paint)にまだ到達しておらず、初回ペイント時には該当モードのスタイルが即適用され、スタイル欠けが発生しない。
<link rel="stylesheet" href='https://cdn.vinking.top/css/style.css'/>
<script>
if (localStorage.getItem("Lighting")) {
if (localStorage.getItem("Lighting") === 'Light') document.getElementsByTagName('html')[0].classList.add("Light");
else if(localStorage.getItem("Lighting") === 'Dark') document.getElementsByTagName('html')[0].classList.add("Dark");
}else{
localStorage.setItem("Lighting", "System");
}
</script>
Ver 1.0
実運用ではモード切替ボタンクリック後、背景読み込みが間に合わず一時的に背景が欠ける問題が発生。これはプリロードで解決でき、ボタンに onmouseover イベントを付け、マウスが乗った時点で <link rel="prefetch" href=""> を追加してリソースを先読みする。最後に XXX.onmouseover = null を付けて重複トリガを防ぐ。
これでライト/ダークモード切替のメインフレームは完成。残りは切替時のシンタックスハイライト再読み込みなどの細かい調整だけ。
P.S. ちょっとした愚痴だけど、Pixivで見られる横長の良い絵が減ってきたよね……タイムラインは縦長画像だらけで……