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

ch03 — 一覧から詳細画面へ

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

ch03 — 一覧から詳細画面へ

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

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] ?? '—'
  • ?.オプショナルチェイン): capitalundefined でも、エラーにせず 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 の依存追跡のおかげ。手で「変わったら再計算」を書く必要はない。