🚋

Svelte5で作る実用Webアプリ - 到着時刻比較システムの実装

に公開

はじめに

本記事では、Svelte5を使って実際に動作する列車到着時刻比較システムを実装した経験を共有します。

🚆 デモサイト: https://train-time-comparator.vercel.app/

📝 ストーリー編: noteで公開中

このアプリは、総武線の各駅停車と快速のどちらが早く目的地に到着するかを比較するためのツールです。シンプルながら実用的なアプリケーションを通じて、Svelte5の新機能や実装のポイントを解説します。

この記事で学べること

  • Svelte 5の新しいリアクティビティシステム($state, $derived, $effect)の実践的な使い方
  • コンポーネント分割の設計思想
  • 双方向バインディングの実装
  • 時刻計算ロジックの実装
  • モバイル対応を含むレスポンシブデザイン

対象読者

  • Svelte 4からのアップデートを検討している方
  • Svelte 5の新機能を実践的に学びたい方
  • 実用的なWebアプリの設計パターンを知りたい方

プロジェクト構成

まず、プロジェクト全体の構成を見ていきましょう。

src/
├── App.svelte                      # メインコンポーネント
├── main.js                         # エントリーポイント
├── lib/
│   ├── components/
│   │   ├── StationSelector.svelte    # 出発駅選択
│   │   ├── DirectionSelector.svelte  # 方面選択
│   │   ├── TimeInput.svelte          # 時刻入力
│   │   └── ResultTable.svelte        # 結果表示テーブル
│   ├── data/
│   │   └── travelData.js             # 駅データ・所要時間データ
│   └── utils/
│       └── timeCalculator.js         # 時刻計算ロジック
└── styles/
    ├── global.css                    # グローバルスタイル
    └── variables.css                 # CSS変数定義

設計方針

  1. コンポーネントの単一責任: 各コンポーネントは1つの機能のみを持つ
  2. データとロジックの分離: データはdata/、ロジックはutils/に集約
  3. スタイルの一貫性: CSS変数でデザインシステムを統一

Svelte5の新機能を活用する

Svelte5では、リアクティビティシステムが大幅に刷新されました。従来のlet宣言と$:ラベルから、より直感的な$state$derivedに移行しています。

1. $state: リアクティブな状態管理

Svelte5では、$stateを使ってリアクティブな状態を宣言します。

<script>
  let departureStation = $state("錦糸町");
  let direction = $state("千葉方面");
  let kankouTime = $state("");
  let kyuukouTime = $state("");
</script>

従来のlet宣言と見た目は似ていますが、$stateを使うことで、この変数が変更されたときに自動的にUIが更新されることが明示されます。

2. $derived: 派生状態の計算

$derivedは、他の状態から計算される値を定義するために使います。従来の$:ラベルの代わりです。

<script>
  let departureStation = $state("錦糸町");
  
  // departureStationが変更されると自動的に再計算される
  let availableDirections = $derived(getAvailableDirections(departureStation));
  
  let displayStations = $derived(
    getDisplayStations(departureStation, direction)
  );
</script>

$derivedの利点は:

  • 依存関係が明確
  • 再計算のタイミングが最適化される
  • TypeScriptとの相性が良い

3. $effect: 副作用の実行

$effectは、状態が変更されたときに副作用を実行するために使います。

<script>
  let direction = $state("千葉方面");
  let availableDirections = $derived(getAvailableDirections(departureStation));

  // availableDirectionsが変更されたときに実行
  $effect(() => {
    if (!availableDirections.includes(direction) && availableDirections.length > 0) {
      direction = availableDirections[0];
    }
  });
</script>

このコードは、「選択中の方面が利用可能な選択肢に含まれなくなったら、自動的に最初の選択肢に切り替える」という処理を実現しています。


データ構造の設計

駅データと所要時間

アプリの核となるデータはtravelData.jsに定義しています。

// 駅リスト
export const stations = ['錦糸町', '新小岩', '市川', '船橋', '津田沼', '稲毛', '千葉'];

// 方面別所要時間データ(各起点からの累積時間)
export const travelTimes = {
    千葉方面: {  // 錦糸町起点
        緩行: {
            錦糸町: 0,
            新小岩: 7,
            市川: 14,
            船橋: 25,
            津田沼: 30,
            稲毛: 42,
            千葉: 47
        },
        急行: {
            錦糸町: 0,
            新小岩: 3,
            市川: 10,
            船橋: 16,
            津田沼: 20,
            稲毛: 27,
            千葉: 31
        }
    },
    錦糸町方面: {  // 千葉起点
        緩行: {
            千葉: 0,
            稲毛: 5,
            津田沼: 17,
            船橋: 22,
            市川: 33,
            新小岩: 40,
            錦糸町: 47
        },
        急行: {
            千葉: 0,
            稲毛: 4,
            津田沼: 11,
            船橋: 15,
            市川: 21,
            新小岩: 28,
            錦糸町: 31
        }
    }
};

設計のポイント

  1. 累積時間方式: 各駅の時間を「起点からの累積時間」として保持することで、任意の区間の所要時間を簡単に計算できる
  2. 方向別の分離: 千葉方面と錦糸町方面でデータを分けることで、双方向の計算に対応
  3. 拡張性: 新しい駅や路線を追加しやすい構造

コンポーネント設計

1. StationSelector: 出発駅選択

最もシンプルなコンポーネントです。$bindableを使って双方向バインディングを実現しています。

<script>
    import { stations } from "../data/travelData.js";

    let { value = $bindable() } = $props();
</script>

<div class="card">
    <div class="card-header">
        <span class="step-number">1</span>
        <h2>出発駅</h2>
    </div>
    <div class="card-content">
        <select class="large-select" bind:value>
            {#each stations as station}
                <option value={station}>{station}</option>
            {/each}
        </select>
    </div>
</div>

$bindableの役割

Svelte5では、親コンポーネントから渡されたプロパティを子コンポーネントで変更する場合、$bindableを使う必要があります。

// 親コンポーネント
<StationSelector bind:value={departureStation} />

// 子コンポーネント
let { value = $bindable() } = $props();

これにより、子コンポーネントでの変更が親コンポーネントに自動的に反映されます。

2. TimeInput: 時刻入力の工夫

時刻入力コンポーネントでは、ユーザビリティを向上させるための機能を実装しています。

<script>
    import { getCurrentTime } from "../utils/timeCalculator.js";

    let { kankouTime = $bindable(), kyuukouTime = $bindable() } = $props();

    function setCurrentTime() {
        const currentTime = getCurrentTime();
        kankouTime = currentTime;
        kyuukouTime = currentTime;
    }

    function adjustTime(timeStr, minutesToAdd) {
        if (!timeStr) return "";
        const [hours, minutes] = timeStr.split(":").map(Number);
        const date = new Date();
        date.setHours(hours);
        date.setMinutes(minutes + minutesToAdd);
        return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
    }
</script>

機能の詳細

  1. 現在時刻設定: ワンクリックで両方の出発時刻を現在時刻に設定
  2. ±1分調整: 微妙な時間差を簡単に設定できる
  3. 個別の現在時刻設定: 各駅・快速それぞれに現在時刻を設定可能

モバイル対応の重要ポイント

.time-input-group input[type="time"] {
    /* 16px以上に設定して自動拡大を防ぐ */
    font-size: 16px;
    /* タッチデバイスでの拡大を防ぐ */
    touch-action: manipulation;
}

iOSやAndroidでは、フォントサイズが16px未満の場合、入力時に自動的にズームされてしまいます。これを防ぐため、最小フォントサイズを16pxに設定しています。

3. ResultTable: 結果表示

結果表示では、到着時刻の比較と視覚的なハイライトを実装しています。

<script>
    let {
        displayStations,
        departureStation,
        kankouArrivalTimes,
        kyuukouArrivalTimes,
    } = $props();

    function timeToMinutes(timeStr) {
        if (!timeStr) return null;
        const [hours, minutes] = timeStr.split(":").map(Number);
        return hours * 60 + minutes;
    }

    function compareArrivalTimes(station) {
        const kankouMin = timeToMinutes(kankouArrivalTimes[station]);
        const kyuukouMin = timeToMinutes(kyuukouArrivalTimes[station]);

        if (kankouMin === null) return { faster: "kyuukou", difference: 0 };
        if (kyuukouMin === null) return { faster: "kankou", difference: 0 };

        const diff = Math.abs(kankouMin - kyuukouMin);

        if (kankouMin === kyuukouMin) return { faster: "same", difference: 0 };
        return {
            faster: kankouMin < kyuukouMin ? "kankou" : "kyuukou",
            difference: diff,
        };
    }
</script>

<table class="result-table">
    <tbody>
        {#each displayStations as station}
            {#if station !== departureStation}
                {@const comparison = compareArrivalTimes(station)}
                <tr>
                    <th>{station}</th>
                    <td class:fastest={comparison.faster === "kankou"}>
                        {#if kankouArrivalTimes[station]}
                            <span class="time">{kankouArrivalTimes[station]}</span>
                            {#if comparison.faster === "kankou"}
                                <span class="badge fastest-badge">最速</span>
                            {/if}
                        {/if}
                    </td>
                </tr>
            {/if}
        {/each}
    </tbody>
</table>

{@const}ディレクティブ

Svelte5では、テンプレート内で一時変数を定義できる{@const}ディレクティブが使えます。

{@const comparison = compareArrivalTimes(station)}

これにより、同じ計算を複数回実行することなく、結果を再利用できます。


時刻計算ロジックの実装

到着時刻の計算

export function calculateArrivalTimes(trainType, startTime, displayStations, departure, direction) {
    if (!startTime) return {};

    const [hours, minutes] = startTime.split(':').map(Number);
    const startTotalMinutes = hours * 60 + minutes;
    const times = {};

    for (const station of displayStations) {
        if (station === departure) continue;

        const fromTime = travelTimes[direction][trainType][departure];
        const toTime = travelTimes[direction][trainType][station];
        const travelTime = Math.abs(toTime - fromTime);

        const arrivalTime = startTotalMinutes + travelTime;
        const arrivalHours = Math.floor(arrivalTime / 60) % 24;
        const arrivalMinutes = arrivalTime % 60;

        times[station] = `${String(arrivalHours).padStart(2, '0')}:${String(arrivalMinutes).padStart(2, '0')}`;
    }

    return times;
}

計算の流れ

  1. 出発時刻を分単位に変換: 14:30870分
  2. 所要時間を計算: 目的地の累積時間 - 出発駅の累積時間
  3. 到着時刻を計算: 出発時刻 + 所要時間
  4. 時刻形式に変換: 900分15:00

表示駅リストの生成

export function getDisplayStations(departure, direction) {
    const departureIndex = stations.indexOf(departure);

    if (direction === '千葉方面') {
        return stations.slice(departureIndex);
    } else {
        return stations.slice(0, departureIndex + 1).reverse();
    }
}

出発駅と方向に応じて、表示すべき駅のリストを動的に生成します。


CSS設計: デザインシステムの構築

CSS変数による統一

variables.cssでデザイントークンを定義し、一貫性のあるUIを実現しています。

:root {
    /* カラー */
    --color-primary: #4caf50;
    --color-primary-dark: #45a049;
    --color-border: #ddd;
    --color-border-hover: #4caf50;
    
    /* スペーシング */
    --spacing-xs: 8px;
    --spacing-sm: 10px;
    --spacing-md: 12px;
    
    /* ボーダー */
    --border-radius: 6px;
    --border-width: 2px;
    
    /* シャドウ */
    --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
    --shadow-focus: 0 0 0 3px rgba(76, 175, 80, 0.1);
}

カードUIの共通化

.card {
    background: var(--color-bg-secondary);
    border: var(--border-width) solid var(--color-border);
    border-radius: var(--border-radius-lg);
    overflow: hidden;
    transition: all var(--transition-fast);
}

.card:hover {
    border-color: var(--color-border-hover);
    box-shadow: var(--shadow-md);
}

全てのカードコンポーネントで共通のスタイルを使うことで、統一感のあるデザインを実現しています。


パフォーマンスの最適化

1. リアクティビティの最適化

Svelte5の$derivedは、依存する値が変更されたときのみ再計算されます。これにより、不要な計算を避けることができます。

<script>
  // departureStationが変更されたときのみ再計算
  let availableDirections = $derived(getAvailableDirections(departureStation));
  
  // departureStation または direction が変更されたときのみ再計算
  let displayStations = $derived(
    getDisplayStations(departureStation, direction)
  );
  
  // 複数の状態に依存する計算も効率的
  let kankouArrivalTimes = $derived(
    calculateArrivalTimes("緩行", kankouTime, displayStations, departureStation, direction)
  );
</script>

2. バンドルサイズの最小化

Svelteは、使用されていないコードを自動的に削除(Tree Shaking)します。さらに、ビルド時にコンパイルされるため、ランタイムライブラリが不要で、バンドルサイズが小さくなります。

本プロジェクトのビルド結果:

  • JS: 約15KB(gzip圧縮後)
  • CSS: 約3KB(gzip圧縮後)

ReactやVueと比べて、非常にコンパクトなサイズで配信できます。


実装時の課題と解決策

課題1: 双方向バインディングの型安全性

Svelte5では、$bindableを使った双方向バインディングが推奨されていますが、初見では少し戸惑いました。

解決策:

// 子コンポーネント
<script>
  // デフォルト値を設定することで、未定義エラーを防ぐ
  let { value = $bindable("") } = $props();
</script>

// 親コンポーネント
<script>
  let myValue = $state("initial");
</script>
<ChildComponent bind:value={myValue} />

$bindable()にデフォルト値を渡すことで、親から値が渡されなかった場合のフォールバックを設定できます。

課題2: 時刻計算での日付跨ぎ処理

当初、深夜0時を跨ぐ計算(例:23:50発 → 00:10着)でバグが発生しました。

解決策:

// 24時間表記での計算後、24で割った余りを取る
const arrivalHours = Math.floor(arrivalTime / 60) % 24;

% 24を使うことで、24時を超えた場合に自動的に0時に戻ります。

課題3: モバイルでのフォーム入力時のズーム

iOSでは、フォントサイズが16px未満の入力欄をタップすると、自動的にズームしてしまいます。
解決策:

input[type="time"],
select,
button {
    /* 最小フォントサイズを16pxに */
    font-size: 16px;
    /* タッチ操作の最適化 */
    touch-action: manipulation;
}
<!-- ビューポート設定でユーザーズームを制御 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

テスト戦略

手動テストの重点ポイント

実用アプリとして、以下のシナリオを重点的にテストしました:

  1. エッジケース

    • 深夜0時を跨ぐ計算
    • 始発駅・終着駅からの出発
    • 同じ時刻での比較
  2. ユーザビリティ

    • モバイルでのタッチ操作
    • フォーム入力の快適さ
    • 結果の見やすさ
  3. レスポンシブデザイン

    • スマートフォン(320px〜)
    • タブレット(768px〜)
    • デスクトップ(1024px〜)

デプロイとCI/CD

Vercelへのデプロイ

Viteプロジェクトは、Vercelに非常に簡単にデプロイできます。

  1. GitHubにプッシュ
  2. Vercelでプロジェクトをインポート
  3. ビルドコマンドを設定(npm run build
  4. 出力ディレクトリを設定(dist

環境変数の管理

現在はハードコーディングされたデータを使用していますが、将来的にAPIから取得する場合は環境変数で管理できます。

// vite.config.js
export default defineConfig({
  define: {
    'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL)
  }
})

今後の拡張可能性

1. データの外部化

現在はJavaScriptファイルにデータをハードコーディングしていますが、以下のように拡張できます:

// APIからデータを取得
async function fetchTravelData(line) {
  const response = await fetch(`/api/lines/${line}`);
  return response.json();
}

// ローカルストレージにキャッシュ
function cacheTravelData(data) {
  // 注意: Claude.aiの環境ではlocalStorageは使えませんが、
  // 実際のWebサイトでは使用可能です
  localStorage.setItem('travelData', JSON.stringify(data));
}

2. 複数路線への対応

現在の設計は、他の路線にも簡単に対応できます:

export const lines = {
  '総武線': {
    stations: ['錦糸町', '新小岩', ...],
    travelTimes: { ... }
  },
  '中央線': {
    stations: ['御茶ノ水', '新宿', ...],
    travelTimes: { ... }
  }
};

学んだこと・得られた知見

1. Svelte5のリアクティビティは直感的

従来の$:ラベルよりも、$state$derivedの方が、「何がリアクティブなのか」が明確で分かりやすいです。
特に、TypeScriptとの相性が良く、型推論が効きやすいのが大きなメリットです。

2. コンポーネント分割の粒度

「1コンポーネント = 1責任」を意識すると、以下のメリットがありました:

  • テストしやすい
  • 再利用しやすい
  • デバッグしやすい
  • コードレビューしやすい

一方で、分割しすぎると逆に複雑になることもあるため、バランスが重要です。

3. CSS変数の威力

CSS変数を使ったデザインシステムは、以下の点で非常に有効でした:

  • 一貫性のあるデザイン
  • ダークモード対応が容易(今後の実装予定)
  • レスポンシブ対応の簡素化

4. モバイルファーストの重要性

スマートフォンでの使用を想定して設計したことで、以下に気づきました:

  • タップ領域は最低44x44px必要
  • フォントサイズは16px以上推奨
  • touch-actionプロパティで操作性が向上
  • user-scalable=noは慎重に使う(アクセシビリティの観点)

まとめ

Svelte5を使った実用Webアプリケーション開発を通じて、以下のポイントが明らかになりました:

Svelte5の強み

シンプルで直感的な構文 - 学習コストが低い
高速なパフォーマンス - ビルド時コンパイルによる最適化
小さいバンドルサイズ - 本番環境での配信に有利
新しいリアクティビティシステム - $state, $derived, $effectによる明確な状態管理

実装のポイント

  1. コンポーネント設計 - 単一責任の原則を守る
  2. データ構造 - 拡張性を考慮した設計
  3. CSS設計 - 変数による統一されたデザインシステム
  4. モバイル対応 - フォントサイズとタッチ操作の最適化

今後の課題

  • ユニットテストの導入
  • アクセシビリティの向上
  • PWA化(オフライン対応)
  • 他路線への対応

参考リンク

最後まで読んでいただき、ありがとうございました!
この記事が、Svelte5での開発を検討している方の参考になれば幸いです。

Discussion