SvelteKitのダークモード + SSRについて調べる。
このリポジトリ内で実装されている方法が良さそうだったので解剖していく。
Themeはlight
、dark
、auto
の3つが定義されていた。
// hooks.server.ts
export type Theme = 'light' | 'dark' | 'auto'
また、app.d.tsのLocalsとPageDataにthemeが生えていた。
// app.d.ts
declare global {
namespace App {
interface Locals {
theme: Theme
}
interface PageData {
theme: Theme
}
}
}
app.htmlのhtml要素にdata-themeという属性が付与されていた。
<!-- app.html-->
<!DOCTYPE html>
<html ... data-theme="%THEME%">
<head>
...
</head>
<body data-sveltekit-preload-data="hover">
...
</body>
</html>
hooks.server.tsにてテーマ切り替えとキャッシュの処理が実装されている。
テーマに関する処理のみ抜き出すとこんな感じ。
// hooks.server.ts
export type Theme = 'light' | 'dark' | 'auto'
export const isValidTheme = (theme: FormDataEntryValue | null): theme is Theme =>
!!theme && (theme === 'light' || theme === 'dark' || theme === 'auto')
const FIVE_MINUTES_IN_SECONDS = 5 * 60
export const handle: Handle = async ({event, resolve}) => {
const theme = event.cookies.get('theme') ?? 'auto'
if (isValidTheme(theme)) {
event.locals.theme = theme
}
event.setHeaders({
'cache-control': `private, max-age=${FIVE_MINUTES_IN_SECONDS}`,
})
const response = await resolve(event, {
transformPageChunk: ({html}) => html.replace('%THEME%', theme),
})
return response
}
Headerコンポーネントでテーマの切り替えUIとロジック部分の実装がされている。
テーマのUIとロジック部分のみ抜き出すとこんな感じ。
formのPOSTリクエストを送っている?
ThemeToggleIcon
は中身はただのsvgで、data-theme
の値によって見た目を切り替えているっぽい。
<script lang="ts">
import {browser} from '$app/environment';
import {applyAction, enhance} from '$app/forms';
import ThemeToggleIcon from './ThemeToggleIcon.svelte';
import {theme} from '$lib/stores/theme';
import type {Theme} from '../../../hooks.server';
const deriveNextTheme = (theme: Theme): Theme => {
switch (theme) {
case 'dark':
return 'light';
case 'light':
return 'dark';
case 'auto':
default:
if (!browser) return 'auto';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark';
}
};
$: nextTheme = deriveNextTheme($theme);
</script>
<header class="container flex items-center justify-between px-2 py-4">
<nav>
<form
method="POST"
action="/?/theme"
use:enhance={async () => {
$theme = nextTheme;
return async ({result}) => {
await applyAction(result);
};
}}
>
<input name="theme" value={nextTheme} hidden />
<button class="w-8">
<ThemeToggleIcon />
</button>
</form>
</nav>
</header>
lib/stores/theme.tsにwritableのThemeストアがあった。
// theme.ts
import {writable} from 'svelte/store'
import type {Theme} from '../../hooks.server'
export const theme = writable<Theme>()
src/routes/+layout.server.ts
ただテーマを返しているだけのように見えるけど、何をしているのだろう。
import type {LayoutServerLoad} from './$types';
export const load: LayoutServerLoad = async ({locals}) => {
const {theme} = locals;
return {theme};
};
src/routes/layout.svelte
テーマが変わるたびにdocumentElement.dataset.themeを書き換えてるっぽい。
datasetって何だろう。
<script lang="ts">
import '@fontsource/inter/400.css';
import '@fontsource/mansalva/400.css';
import '@fontsource/noto-color-emoji/emoji.css';
import '../app.css';
import type {LayoutServerData} from './$types';
import Header from '$lib/components/Header/Header.svelte';
import Footer from '$lib/components/Footer/Footer.svelte';
import {theme} from '$lib/stores/theme';
import {browser} from '$app/environment';
export let data: LayoutServerData;
$theme = data.theme;
$: browser && (document.documentElement.dataset.theme = $theme);
</script>
<svelte:head>
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Moving Scapes" />
</svelte:head>
<Header />
<slot />
<Footer />
+page.server.ts
Form actionsでCookieにテーマをセットしている。
import {fail, type Actions} from '@sveltejs/kit';
import { isValidTheme } from '../hooks.server';
const TEN_YEARS_IN_SECONDS = 10 * 365 * 24 * 60 * 60;
export const actions: Actions = {
theme: async ({cookies, request}) => {
const data = await request.formData();
const theme = data.get('theme');
if (!isValidTheme(theme)) {
return fail(400, {theme, missing: true});
}
cookies.set('theme', theme, {path: '/', maxAge: TEN_YEARS_IN_SECONDS});
return {success: true};
},
};
独自の変更箇所をメモ。
app.htmlのdata-themeを削除して、<html lang="en" class="%THEME%">とした。
導入予定のshadcn-svelteがデフォルトでclass表記でのダークモード対応のため。
src/routes/+layout.svelteも上記と同様の理由で、次のようにclassListを弄るように変更した。
<script lang="ts">
import { browser } from "$app/environment";
import Header from "$lib/components/custom/header/header.svelte";
import type { LayoutServerData } from "./$types";
import { theme } from "$lib/stores/theme";
import "../app.postcss";
export let data: LayoutServerData;
$theme = data.theme;
$: if (browser) {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add($theme);
}
</script>
<Header />
<slot />
完成。
その後、mode-watcherというパッケージが公開されたので、今後はこちらで行うのが良さそう(コード見た感じSSRに対応している気配はないが)。
shadcn-svelteの作者が公開している。