ch03 — 一覧から詳細画面へ
テクノロジ系 / L09 app-countries — Vue 国図鑑アプリ
ch03 — 一覧から詳細画面へ
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
ch03
API設計体験 — 一覧から詳細へ
ch03 で作るもの
一覧画面と詳細画面を本格的に連携させ、地域フィルタも追加する
| 機能 | 使う技術 |
|---|---|
| カードクリックで詳細へ | router.push |
| 詳細: 通貨・言語・隣接国 | /alpha/{cca2} の多フィールド取得 |
| 地域ドロップダウン | Pinia の selectedRegion + computed |
詳細エンドポイント
GET /v3.1/alpha/{cca2}?fields=name,flags,region,population,capital,currencies,languages,borders// レスポンス例 (Japan)
{
name: { common: "Japan", official: "Japan" },
flags: { png: "https://flagcdn.com/w320/jp.png" },
region: "Asia",
population: 123210000,
capital: ["Tokyo"],
currencies: { JPY: { name: "Japanese yen", symbol: "¥" } },
languages: { jpn: "Japanese" },
borders: ["PRK", "KOR"] // 隣接国のcca3コード
}詳細画面: 全フィールドを表示
<template>
<div v-if="country" class="detail">
<img :src="country.flags.png" :alt="country.name.common" class="flag" />
<h1>{{ country.name.common }}</h1>
<table class="info-table">
<tr><th>地域</th><td>{{ country.region }}</td></tr>
<tr><th>人口</th><td>{{ country.population.toLocaleString() }} 人</td></tr>
<tr><th>首都</th><td>{{ country.capital?.[0] ?? "—" }}</td></tr>
<tr>
<th>通貨</th>
<td>{{ Object.values(country.currencies ?? {}).map(c => c.name).join(", ") }}</td>
</tr>
<tr>
<th>言語</th>
<td>{{ Object.values(country.languages ?? {}).join(", ") }}</td>
</tr>
</table>
<router-link to="/">← 一覧に戻る</router-link>
</div>
</template>Pinia に地域フィルタを追加
// stores/countries.js に追加
const selectedRegion = ref("すべて")
const regions = ["すべて", "Africa", "Americas", "Asia", "Europe", "Oceania"]
const filtered = computed(() => {
if (selectedRegion.value === "すべて") return list.value
return list.value.filter(c => c.region === selectedRegion.value)
})
return {
list, isLoading, error, fetchAll,
selectedRegion, regions, filtered // 追加
}💡 Pinia の <code>selectedRegion</code> は HomeView と Navbar の両方から使える。
コンポーネント間での状態共有が Pinia の強み。
HomeView を更新する (ch03版)
<script setup>
import { onMounted } from "vue"
import { useCountriesStore } from "../stores/countries"
import CountryCard from "../components/CountryCard.vue"
const store = useCountriesStore()
onMounted(() => {
if (store.list.length === 0) store.fetchAll()
})
</script>
<template>
<!-- 地域フィルタ -->
<div class="filter">
<select v-model="store.selectedRegion">
<option v-for="r in store.regions" :key="r">{{ r }}</option>
</select>
<span>{{ store.filtered.length }} カ国</span>
</div>
<!-- カード一覧 -->
<div class="grid">
<CountryCard v-for="c in store.filtered" :key="c.cca2" :country="c" />
</div>
</template>ch03 まとめ
| やったこと | 学んだこと |
|---|---|
| カードクリックで詳細へ | router.push でプログラム遷移 |
| 詳細画面で別EPを叩く | 複数エンドポイントの組み合わせ |
| 通貨・言語を表示 | Object.values() でオブジェクトを配列に変換 |
| 地域フィルタを Pinia で管理 | computed を store に置く |
| ページ再読み込み防止 | list.length === 0 のときだけ fetchAll |
考えてみよう
既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。
ヒント
API データは欠けていることがある。安全に取り出すための「部品」を組み合わせる。
- オプショナルチェイン
?.: 手前がundefinedでもエラーにせずundefinedを返す。配列の先頭を取るときも配列?.[0]で安全に。 - null 合体演算子
??: 左がnull/undefinedのときだけ右の既定値('—'など)を使う。||と違い0や''は倒れない。 Object.values(obj): オブジェクトの「値」だけを配列で取り出す。キーが国ごとに変わるcurrencies/languagesで使う。?? {}を挟むと欠損時も空配列になり安全。- 取り出した配列は
.map(...)で必要な値(通貨はname)に変換し、.join(', ')でカンマ区切りにする。言語は値が文字列なので.mapは不要。
→ 上の部品をどう繋ぐかは自分で組む。完成形は explanation.md(答え合わせ後)で確認。
解説
API データは「欠けている前提」で扱う
実際の API(REST Countries)は国によってフィールドが欠けている。南極(cca2: AQ)には首都も通貨も無い。データが揃っている前提で country.capital[0] と書くと、欠損国でアプリ全体が落ちる。
そこで 2 つの演算子を組み合わせる:
country.capital?.[0] ?? '—'
?.(オプショナルチェイン):capitalがundefinedでも、エラーにせずundefinedを返す。??(null 合体演算子): 左がnull/undefinedのときだけ右('—')を使う。0や''は右に倒れない(||との違い)。
入れ子オブジェクトを文字列に整形する
API はしばしば「キーが可変なオブジェクト」を返す:
currencies: { JPY: { name: 'Japanese yen', symbol: '¥' } }
languages: { jpn: 'Japanese' }キー(JPY / jpn)は国ごとに違うので、Object.values() で 値だけ を配列として取り出す:
Object.values(country.currencies ?? {}).map((c) => c.name).join(', ')
// → "Japanese yen"
Object.values(country.languages ?? {}).join(', ')
// → "Japanese"?? {}で「欠損なら空オブジェクト」にしておくと、Object.valuesが[]を返し、joinで空文字になって安全。- 通貨は値が
{ name, symbol }オブジェクトなので.map((c) => c.name)で名前だけ抜く。言語は値が文字列なので.map不要。
この「欠損に強い整形」は、外部 API を扱う UI では毎回必要になる定石。
ヒント
computedの中でselectedRegion.valueを読むと、selectedRegionが変わるたびに自動で再計算される。- 「すべて」のときは絞り込まず全件:
if (selectedRegion.value === 'すべて') return list.value - それ以外は
list.value.filter((c) => c.region === selectedRegion.value) - composable の中では
refの値に.valueでアクセスする。
解説
フィルタは「状態を持つ側」に閉じ込める
ch01 では画面コンポーネントの中で computed フィルタを書いた。ch03 では同じフィルタを 状態を持つ composable(slide では Pinia store)側 に移す。
const selectedRegion = ref('すべて')
const filtered = computed(() => {
if (selectedRegion.value === 'すべて') return list.value
return list.value.filter((c) => c.region === selectedRegion.value)
})
return { list, regions, selectedRegion, filtered }こうすると、画面側は 絞り込みの中身を知らなくてよい:
<select v-model="store.selectedRegion">
<option v-for="r in store.regions" :key="r">{{ r }}</option>
</select>
<div v-for="c in store.filtered" :key="c.cca2">...</div>なぜ状態側に置くと嬉しいか
- 複数の画面で共有できる: 一覧画面のドロップダウンと、別の場所(ナビバーの件数表示など)が同じ
selectedRegion/filteredを見られる。slide が Pinia を使うのはこの共有のため。 - 画面が薄くなる: 画面は「状態を表示する」だけ。絞り込みロジックが散らばらない。
- テストしやすい: フィルタが composable 単体で完結するので、画面を mount せず
selectedRegion.value = 'Asia'→filtered.valueを検証できる(このドリルのテストがまさにそれ)。
selectedRegion を変えるだけで filtered が自動更新されるのは computed の依存追跡のおかげ。手で「変わったら再計算」を書く必要はない。