ch04 — 検索・お気に入り・認証連携
テクノロジ系 / L09 app-countries — Vue 国図鑑アプリ
ch04 — 検索・お気に入り・認証連携
Vue.js で世界の国を探索するアプリを作ろう
作るもの
「世界の国を調べられる国図鑑Webアプリ」
- 250カ国以上の国旗・人口・地域を一覧表示
- 国名で検索・地域でフィルタ
- カードをクリックして詳細を見る
- お気に入り登録
4日間のロードマップ
| Day | テーマ | ゴール |
|---|---|---|
| ch01 | Vue基礎 | 静的データでVue文法をマスター |
| ch02 | Router/Pinia/fetch | 本物のAPIから国データを取得 |
| ch03 | API設計体験 | 一覧→詳細画面の連携 |
| ch04 | UI完成 | 検索・お気に入り・認証連携 |
サンプルコードの使い方
サンプルコード/ ├── 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 認証必須)前提:
- FastAPI が
localhost:8000で起動済み - FastAPI に CORS ミドルウェアを設定済み
- 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>認証エラーを体験する
- 正しいキーで削除 → 204 No Content → 成功
- キーを間違えて削除 → 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 | 習得した技術 |
|---|---|
| ch01 | ref / computed / v-for / props / emit |
| ch02 | Vue 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()してから比較。JapanとjapanとJAPANを同じ扱いにする。 - 部分一致:
includesは文字列の途中でもヒットする(「ra」で「France」「Brazil」)。前方一致にしたいならstartsWith。 - 空白の扱い:
searchQuery.trim()が空なら検索しない。スペースだけ打ったときに全件消えるのを防ぐ。
searchQuery も selectedRegion も ref なので、どちらが変わっても 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 を使う表示が自動更新される。