採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

ch04 — 検索・お気に入り・認証連携

テクノロジ系 / L09 app-countries — Vue 国図鑑アプリ

ch04 — 検索・お気に入り・認証連携

Vue.js で世界の国を探索するアプリを作ろう

作るもの

「世界の国を調べられる国図鑑Webアプリ」

  • 250カ国以上の国旗・人口・地域を一覧表示
  • 国名で検索・地域でフィルタ
  • カードをクリックして詳細を見る
  • お気に入り登録

4日間のロードマップ

Dayテーマゴール
ch01Vue基礎静的データでVue文法をマスター
ch02Router/Pinia/fetch本物のAPIから国データを取得
ch03API設計体験一覧→詳細画面の連携
ch04UI完成検索・お気に入り・認証連携

サンプルコードの使い方

サンプルコード/
├── day1/
│   ├── starter/   ← ここから始める (穴あきコード)
│   └── answer/    ← 完成形 (ヒントに使う)
├── day2/ ...
├── day3/ ...
├── day4/ ...
└── 完成品/        ← ch04 完成形の全コード
# starter を起動する例
cd day1/starter
npm install
npm run dev

ch04

UI完成 + FastAPI認証連携 (予備)

ch04 で作るもの (メインコンテンツ)

機能技術
国名でテキスト検索searchQuery ref + computed
地域 × 検索の複合フィルタcomputed のネスト
お気に入り登録Pinia の Set + 複数ページで共有
ページネーションslice + page ref

テキスト検索を追加する

// stores/countries.js に追加
const searchQuery = ref("")

const filtered = computed(() => {
  let result = list.value

  // 地域フィルタ
  if (selectedRegion.value !== "すべて")
    result = result.filter(c => c.region === selectedRegion.value)

  // テキスト検索
  if (searchQuery.value.trim())
    result = result.filter(c =>
      c.name.common.toLowerCase().includes(searchQuery.value.toLowerCase())
    )

  return result
})
<input v-model="store.searchQuery" placeholder="国名を検索..." class="search" />

ページネーションを追加する

// stores/countries.js に追加
const page = ref(1)
const pageSize = 20

const paged = computed(() =>
  filtered.value.slice((page.value - 1) * pageSize, page.value * pageSize)
)

const totalPages = computed(() =>
  Math.ceil(filtered.value.length / pageSize)
)
<div class="pagination">
  <button :disabled="store.page <= 1" @click="store.page--">← 前</button>
  <span>{{ store.page }} / {{ store.totalPages }}</span>
  <button :disabled="store.page >= store.totalPages" @click="store.page++">次 →</button>
</div>

お気に入り機能を追加する

// stores/countries.js に追加
const favorites = ref(new Set())

function toggleFavorite(cca2) {
  if (favorites.value.has(cca2)) favorites.value.delete(cca2)
  else favorites.value.add(cca2)
}

const isFavorite = (cca2) => favorites.value.has(cca2)
<!-- CountryCard.vue -->
<button
  class="fav-btn"
  :class="{ active: isFavorite }"
  @click.stop="emit('toggle-favorite', country.cca2)"
>
  {{ isFavorite ? "❤️" : "🤍" }}
</button>

💡 <code>@click.stop</code> で親の <code>@click</code> (詳細ページ遷移) を止める。

ch04 まとめ (メインコンテンツ)

実装ポイント
複合フィルタ地域 → 検索の順に絞り込む
ページネーションslice で範囲を切り出す
お気に入りSet で重複なし管理
フィルタ変更時にページリセットwatch(filtered, () => { page.value = 1 })

予備コンテンツ: Vue + FastAPI 認証連携

6/9で作ったタスク共有API (FastAPI) とVueを連携させる

FastAPI: localhost:8000
├── GET  /tasks           ← 一覧 (認証不要)
└── DELETE /tasks/{id}   ← 削除 (X-API-Key 認証必須)

前提:

  1. FastAPI が localhost:8000 で起動済み
  2. FastAPI に CORS ミドルウェアを設定済み
  3. Vue プロジェクトルートに .env を作成済み

FastAPI 側の CORS 設定 (確認)

6/9の FastAPI に以下を追加する:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_methods=["*"],
    allow_headers=["*"],
)

⚠️ Vue の開発サーバーは <code>localhost:5173</code> で動く。

FastAPI 側で許可しないとブラウザが CORS エラーでブロックする。

.env ファイルを作る

# プロジェクトルートに .env を作成
# .env.example をコピーして使う
cp .env.example .env
# .env の中身
VITE_API_KEY=admin-key-456

🔑 APIキーを <strong>コードに直接書かない</strong> ことが重要。

環境変数に分離することで、誤って公開リポジトリにアップする事故を防ぐ。

// Vite では import.meta.env で読む
const API_KEY = import.meta.env.VITE_API_KEY

tasks.js store を作る

// src/stores/tasks.js
import { defineStore } from "pinia"
import { ref } from "vue"

const FASTAPI_BASE = "http://localhost:8000"
const API_KEY = import.meta.env.VITE_API_KEY

export const useTasksStore = defineStore("tasks", () => {
  const list = ref([])
  const isLoading = ref(false)
  const error = ref(null)

  async function fetchTasks() {
    isLoading.value = true
    try {
      const res = await fetch(`${FASTAPI_BASE}/tasks`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      list.value = await res.json()
    } catch (e) {
      error.value = e.message
    } finally {
      isLoading.value = false
    }
  }

  async function deleteTask(id) {
    const res = await fetch(`${FASTAPI_BASE}/tasks/${id}`, {
      method: "DELETE",
      headers: { "X-API-Key": API_KEY },
    })
    if (res.status === 401) throw new Error("認証エラー: APIキーが正しくありません")
    if (res.status === 404) throw new Error("タスクが見つかりません")
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    list.value = list.value.filter(t => t.id !== id)
  }

  return { list, isLoading, error, fetchTasks, deleteTask }
})

TaskView.vue を作る

<script setup>
import { onMounted, ref } from "vue"
import { useTasksStore } from "../stores/tasks"

const store = useTasksStore()
const deleteError = ref(null)

onMounted(() => store.fetchTasks())

async function handleDelete(id) {
  deleteError.value = null
  try {
    await store.deleteTask(id)
  } catch (e) {
    deleteError.value = e.message
  }
}
</script>

<template>
  <div class="task-view">
    <h1>タスク管理 (FastAPI 連携)</h1>
    <p v-if="store.isLoading">読み込み中...</p>
    <p v-if="deleteError" class="error">{{ deleteError }}</p>
    <table v-if="store.list.length">
      <tr v-for="task in store.list" :key="task.id">
        <td>{{ task.title }}</td>
        <td>{{ task.status }}</td>
        <td><button @click="handleDelete(task.id)">削除</button></td>
      </tr>
    </table>
  </div>
</template>

認証エラーを体験する

  1. 正しいキーで削除 → 204 No Content → 成功
  2. キーを間違えて削除 → 401 Unauthorized → エラーメッセージ表示
// 意図的に間違えるテスト
const res = await fetch(`/tasks/${id}`, {
  method: "DELETE",
  headers: { "X-API-Key": "wrong-key" },
})
// → 401 → throw new Error("認証エラー")
// → TaskView.vue の deleteError に表示される

🔑 <code>fetch</code> は 4xx/5xx でも <strong>例外を投げない</strong>。

<code>res.status</code> を自分でチェックして <code>throw</code> することが重要。

4日間の振り返り

Day習得した技術
ch01ref / computed / v-for / props / emit
ch02Vue Router / Pinia / fetch + async/await
ch03複数EP / Object.values() / Pinia computed
ch04検索・フィルタ / Set / ページネーション / 認証連携

フロントエンドとバックエンドの連携:

  • Vue (Pinia store) ↔ FastAPI (REST API)
  • 環境変数 (import.meta.env) で機密情報を分離
  • HTTPレスポンスのステータスコードで状態を判定

完成品を動かす

# 完成品/ディレクトリで
cd 完成品
npm install

# .env を作成
cp .env.example .env  # VITE_API_KEY を設定

npm run dev
# → http://localhost:5173 で起動

確認ポイント:

  • [ ] 250カ国の一覧が表示される
  • [ ] 地域ドロップダウンで絞り込める
  • [ ] 検索ボックスで国名検索できる
  • [ ] カードクリックで詳細ページに遷移する
  • [ ] お気に入りボタンが動く
  • [ ] ページネーション (20件ずつ) が動く
  • [ ] タスク管理画面が FastAPI と連携する (予備)

お疲れ様でした!

4日間で Vue.js の基礎から実践的なAPI連携まで習得しました。

次のステップ:

  • TypeScript で型安全に書く
  • Composition API のカスタムフック (composables)
  • テスト (Vitest)
  • デプロイ (Netlify / Vercel)

参考リソース:

  • Vue.js 公式ドキュメント: https://ja.vuejs.org/
  • Pinia 公式: https://pinia.vuejs.org/
  • Vue Router 公式: https://router.vuejs.org/

考えてみよう

既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。

ヒント

  • 既にある「地域フィルタ」の後ろに、検索の絞り込みを 重ねて 書く(result を上書きしていく)。
  • 空白だけの検索語は無視: if (searchQuery.value.trim()) { ... }
  • 大文字小文字を無視するには、両方を .toLowerCase() してから includes:
  const q = searchQuery.value.toLowerCase()
  result = result.filter((c) => c.name.common.toLowerCase().includes(q))
  
  • includes は部分一致(「ra」で「France」「Brazil」がヒット)。

解説

複合フィルタは「絞り込みを順に重ねる」

条件が増えても、result を段階的に上書きしていけば素直に書ける:

const filtered = computed(() => {
  let result = list.value

  // ① 地域で絞る
  if (selectedRegion.value !== 'すべて') {
    result = result.filter((c) => c.region === selectedRegion.value)
  }

  // ② さらに検索語で絞る
  if (searchQuery.value.trim()) {
    const q = searchQuery.value.toLowerCase()
    result = result.filter((c) => c.name.common.toLowerCase().includes(q))
  }

  return result
})
  • 各条件は「適用するか / しないか」を if で判定する。未選択('すべて')や空検索のときは絞らない。
  • 順番に result を狭めていくので、条件が 3 つ 4 つに増えても同じパターンで足せる。

検索の定石

  • 大文字小文字を無視: 両辺を .toLowerCase() してから比較。JapanjapanJAPAN を同じ扱いにする。
  • 部分一致: includes は文字列の途中でもヒットする(「ra」で「France」「Brazil」)。前方一致にしたいなら startsWith
  • 空白の扱い: searchQuery.trim() が空なら検索しない。スペースだけ打ったときに全件消えるのを防ぐ。

searchQueryselectedRegionref なので、どちらが変わっても filtered が自動再計算される。画面側は <input v-model="searchQuery"> を置くだけでよい。

ヒント

  • Set は重複しない集まり。.add(x) で追加、.delete(x) で削除、.has(x) で存在確認。
  • トグルの考え方は「まず has で登録済みか判定 → 登録済みなら delete、未登録なら add」。この分岐を自分で書く。
  • isFavorite は「登録済みか」を返すだけ(has をそのまま使う)。
  • ref(new Set()) の中身を操作するときも .value を忘れずに。

解説

なぜ配列でなく Set か

お気に入りは「同じ国を 2 回登録したくない」「登録済みか一発で知りたい」。配列だと:

  • 重複を防ぐのに毎回 includes でチェックして push する必要がある
  • 削除は filter で作り直す
  • 「登録済みか」の判定も includes(要素数に比例して遅くなる)

Set ならこれらが標準で速い:

const favorites = ref(new Set())

function toggleFavorite(cca2) {
  if (favorites.value.has(cca2)) favorites.value.delete(cca2)  // あれば消す
  else favorites.value.add(cca2)                                // なければ足す
}

const isFavorite = (cca2) => favorites.value.has(cca2)
  • add は重複を自動で弾く(同じ値を 2 回足しても 1 つ)
  • has / delete は要素数に関係なく速い
  • 「トグル」は has → delete / add の分岐だけで書ける

画面との繋ぎ(参考)

カード側はクリックで toggle-favorite を emit し、親が toggleFavorite を呼ぶ:

<button @click.stop="emit('toggle-favorite', country.cca2)">
  {{ isFavorite(country.cca2) ? '❤️' : '🤍' }}
</button>

@click.stop.stop は、カード全体のクリック(詳細ページ遷移)に伝播させないためのもの。お気に入りボタンを押したのに詳細画面に飛んでしまう、を防ぐ。

> Vue 3 では ref(new Set())add / delete もリアクティブに追跡され、isFavorite を使う表示が自動更新される。