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

Vue 中級

テクノロジ系 / L12 app-tasks — タスク管理アプリ (Vue + FastAPI フルスタック中級応用)

Vue 中級

はじめに

教材の見方・PC 環境準備・配布物の中身

> 本章のテーマは Vue Router + computed + watch で「複数画面アプリ」 を作ること。

> 「Vue と FastAPI を繋ぐ」 部分の下拵え (CORS / .env / try-catch) は配布版に既に入っている。

学習の流れ (= 1 日完走想定 / 自分のペースで分割可)

ブロック内容
序盤はじめに (このスライド) → 本編 STEP1 (起動) → STEP2 (Router)
中盤本編 STEP3 (動的ルート) → STEP4 (computed) → STEP5 (watch)
後半演習 (Router / computed / watch をそれぞれ手を動かす)
完了前振り返り

> 本編だけで操作が完結するように作っている。 詰まったら答え (答え/step*/) を見る。

配布物の中身

配布資料/
├─ はじめに.pdf                  ← 今読んでいるもの
├─ Vue中級.pdf                   ← 本編 (今日の主役)
├─ サンプルコード/
│    ├─ 作業用/    ← STEP1 開始時点 (これを触る)
│    │    ├─ fastapi/      Python の FastAPI サーバー (起動するだけ)
│    │    └─ frontend/     Vue の画面 (vue-basic 完成形)
│    ├─ 完成品/    ← STEP5 完成形 (Router + 詳細 + computed + watch)
│    └─ 答え/      ← 各 STEP のゴール (詰まったら)
├─ 演習/                  Vue 中級の演習 (vitest)
├─ 演習問題.pdf            ← 演習の手引き
└─ 演習問題・ヒント.pdf    ← 詰まったときのヒント

進め方の基本姿勢

やることコツ
📝 VS Code で書く写経でいい。 動いたら 「なぜ動いた?」 を考える
🖥️ ターミナルで叩くコマンドを 1 つずつ確認しながら
🌐 ブラウザで見るDevTools (F12) を常時開いておく

> 今日のキーワードは 「ターミナルを 2 本」。 FastAPI 用 と Vue 用 で常に 2 つ動かす。

PC 環境チェック (10 分)

🖥️ ターミナルで以下を確認:

python --version    # 3.10 以上を期待
node --version      # 20 以上を期待
npm --version       # 9 以上を期待

> 全部 OK なら次へ。 どれかが入っていない・古い場合は first-step に戻って環境構築から。

> 先に FastAPI (first-step/ch01-uv-mise) と Vue (vue-basic) を一通りこなしていれば、 同じ環境がそのまま使える。

作業フォルダを開く

📝 VS Code で サンプルコード/作業用/ フォルダを開く。

開いてほしい階層中身
作業用/fastapi/Python の FastAPI (今日繋ぐ相手)
作業用/frontend/Vue の画面 (昨日の課題)

> 1 つの VS Code ウィンドウで 作業用/ を開けば、 fastapi/ と frontend/ の両方が見える。

詰まった時の歩き方

  1. 本編 PDF の同じ STEP を読み直す (見落とし発見)
  2. DevTools の Console を見る (赤い文字に答えがある)
  3. 答え/stepN/ を開いて差分を確認 (どこが違うか)
  4. それでも詰まったら 学習者に声を掛ける (一人で 15 分粘ったら声出す目安)

よくあるエラーと対処

エラー原因対処
Cannot connect to localhost:8000FastAPI を起動していないuvicorn のターミナルを確認
Cannot find module 'vue-router'npm install vue-router 未実行frontend/npm install vue-router
[Vue Router warn]: No match found for locationURL とルート定義のミスマッチrouter.js のパス指定を確認
ポート 5173 が使えない別の Vite が動いている古いターミナルを Ctrl+C

安全装置

🛟 作業用フォルダを書き換えてしまっても、 完成品フォルダは触らない

サンプルコード/
├─ 作業用/     ← ここを触る (壊しても OK)
├─ 完成品/     ← 触らない (リファレンス用)
└─ 答え/       ← 触らない (詰まった時に見るだけ)

> もし作業用を壊しちゃっても、 完成品 / 答え を見れば必ず復活できる。 安心して試してみる。

始める準備 ✅

確認OK?
Python / Node / npm のバージョン確認した
VS Code で 作業用/ を開いた
ターミナルを 2 本立てる準備ができた
本編 PDF (Vue中級.pdf) を別ウィンドウで開いた

→ 全部 ✅ なら本編 PDF に進む。

それでは始めましょう

本物の経路で動くフルスタックアプリ」 を 1 日で完成させる。

お楽しみに。

Vue 中級

Router と派生値で「複数画面アプリ」 に育てる

これまで作ってきたタスク管理アプリを、 画面が複数あって・派生値を計算する 本物のアプリへ。

今日のゴール

夕方
1 画面しかない / 全部 App.vue に書いているホーム / 一覧 / 詳細 の 3 画面 / 派生値は computed / 副作用は watch

URL が変わる ・ ブラウザの戻る進むが効く ・ リロードしても状態が残る — これがあって初めて本物の Web アプリ

今日の射程

扱う扱わない (明日以降)
Router (2 画面分割 + 動的 /:id)Pinia (画面間データ共有)
computed (派生値)バリデーション・エラー UX 洗練
watch (副作用)TypeScript / Nuxt 自動ルーティング

> 前提: vue-basic/ch05-list-form で作った 1 画面 CRUD が動いている (= 今日の 作業用/frontend)。

昨日までのアプリ、 ここが困る

困りごと今日の解決
URL が常に /。 「このタスク見て」 と URL で共有できないSTEP2: Router
全機能が 1 画面で縦に長い。 詳細だけ見たいときも全部表示されるSTEP3: 詳細画面分割
絞り込みやフィルタがリロードで消えるSTEP5: watch + localStorage

> 今日触る 3 つの道具 (Router / computed / watch) は、 すべて 既存アプリの困りごとへの直接の答え

作るもののイメージ

ホーム(/)            タスク一覧(/tasks)         タスク詳細(/tasks/:id)
┌─────────┐         ┌─────────────────┐        ┌────────────┐
│ 全 5 件 │  →     │ □ 牛乳を買う 詳細│   →    │ ID: 1      │
│ [一覧へ]│         │ □ 掃除      詳細│        │ 牛乳を買う │
└─────────┘         │ 絞り込み: [   ] │        │ ← 戻る     │
                    │ 全5件/表示中5件 │        └────────────┘
                    └─────────────────┘

ブラウザの URL がそのまま画面に対応する。 = URL を共有すれば相手も同じ画面が開ける

今日の道具

5 STEP で、 既存の単画面アプリを複数画面アプリへ育てる。

STEP主役新しい道具
STEP1🖥️📝🌐 配布物を起動して動かす(準備)
STEP2📝 ホーム / 一覧 の 2 画面に分けるVue Router (4 単語: createRouter / routes / RouterView / RouterLink)
STEP3📝 タスクをクリックして詳細へ動的ルート (/tasks/:id + useRoute + useRouter)
STEP4📝 検索と件数表示を作るcomputed (派生値)
STEP5📝 検索語を保存・URL 変化で再取得watch (副作用)

> 📝 = VS Code で書く / 🖥️ = ターミナルで叩く / 🌐 = ブラウザで見る

配布フォルダの中身

作業用/
  ├─ fastapi/       本物のサーバー (起動すれば使える状態)
  └─ frontend/      Vue アプリ (本章で育てる対象 / vue-basic 完成形)
完成品/             今日のゴール状態 (Router + 詳細 + computed + watch)
答え/step1〜step5/  各 STEP のゴール (詰まったら見る)

> 今日触るのは 作業用/frontend/src/ が中心。 FastAPI は起動するだけで触らない。

STEP1 🖥️📝🌐 配布物を起動して動かす

なぜここから? → 「今ある単画面アプリが動く状態」 を先に確認する。

STEP2 以降の改造で何かが壊れたとき、 「改造前は動いていた」 を起点に切り分けられる。

新しいコードは書かない。 動かすだけ。

STEP1 🖥️ ターミナル 2 本で起動する

# ターミナル1: FastAPI
cd 作業用/fastapi
python -m venv .venv
source .venv/bin/activate     # Windows: .venv\Scripts\activate
pip install -r requirements.txt
uvicorn main:app --reload
# ターミナル2: Vue
cd 作業用/frontend
cp .env.example .env           # API の場所を指定する .env を作る (Mac/Linux)
# Windows PowerShell の場合: copy .env.example .env
npm install
npm run dev

> ターミナルを 2 本 立てるのが今日の基本姿勢。

STEP1 🌐 ブラウザで確認

🌐 http://localhost:5173 を開く。

!h:330px

タスクが 3 件表示される / 追加 / 編集 / 削除 が動く = 昨日まで作ってきたアプリがそのまま動いている

STEP1 解説: 配布版に既に入っているもの

「Vue から FastAPI を叩く」 ためには本来いくつか下拵えが必要。 今日は backend/fastapi/topics/fastapi-basic/ + middleware/ch01-cors で習った要素なので配布版に組み込み済:

どこに何のためか
CORS Middlewarefastapi/main.pyVue (5173) と FastAPI (8000) は別オリジン
.env + VITE_API_BASEfrontend/.envAPI URL をハードコードしない
try / catch + エラー表示frontend/src/App.vue失敗時に画面が黙らない

> 「もう動く」 状態から始める。 ここから Vue 中級 に集中する。

STEP1 のゴール ✅

確認OK?
🖥️ ターミナル 2 本で uvicorn と vite が動いている
🌐 http://localhost:5173 でタスクが 3 件表示される
🌐 タスクを追加 / 編集 / 削除 が画面で反映される

> 今の状態は 1 画面のアプリ。 ここから 複数画面アプリ に育てていく。

STEP2 何が嬉しい? — URL の話

今のアプリは何をしても URL が / のまま。 すると:

  • ❌ 友達に「このタスク見て」 と URL を送れない
  • ❌ リロードしたらいつも最初の画面に戻る
  • ❌ ブラウザの「戻る」 が効かない (= 履歴がない)

この 3 つを一気に解決するのが Vue Router

STEP2 解説: なぜ Router を使う?

「画面を切り替える」 を自前で書こうとすると意外と難しい:

自前で書く場合Router を使う場合
v-if で表示するコンポーネントを切替<RouterView /> だけ
URL を手動で書き換え (history.pushState)<RouterLink>router.push()
ブラウザの戻る / 進むへの対応自動で動く
画面ごとにファイル分割するのが大変ファイル = ルート で整理しやすい

> 「自分で書くと地獄、 Router なら一発」。 まず「困りごと → 道具」 の対応を頭に入れてから手を動かす。

STEP2 今日覚える Router 4 単語

単語役割
createRouterルート定義をまとめて router オブジェクトを作る
routes「URL → コンポーネント」 の対応表 (配列)
<RouterView />ここに現在のルートのコンポーネントを表示」 する場所
<RouterLink to="...">ページ間リンク (画面リロードなしで遷移)

> これ以外 (createWebHistory / use(router) / router-link-active) は 添え物。 今は気にしない。

STEP2 📝 進め方 (順序)

事故を避けるため、 この順序で進める:

  1. vue-router をインストール
  2. ページコンポーネントを 2 つ作る (HomePage.vue / TaskListPage.vue)
  3. router.js を作る
  4. main.js でルーターを使う宣言
  5. 最後に App.vue を書き換え (旧コードは App.vue.bak にリネームして温存)

> 旧 App.vue を最後に触る = 「動いていた状態をギリギリまで残す」 安心感を確保。

STEP2 ① 🖥️ vue-router をインストール

cd 作業用/frontend
npm install vue-router

> 失敗したら: node --version で Node 20 以上か確認。

STEP2 ② 📝 ページコンポーネントを 2 つ作る

📝 frontend/src/pages/ フォルダを新規作成。 その中に 2 ファイル:

<!-- frontend/src/pages/HomePage.vue (新規) -->
<script setup>
import { RouterLink } from 'vue-router'
</script>

<template>
  <h1>マイ・タスク</h1>
  <p>タスクを一覧で見ましょう。</p>
  <RouterLink to="/tasks">タスク一覧へ</RouterLink>
</template>

TaskListPage.vue は次のスライドで詳しく。

STEP2 ② 📝 TaskListPage.vue へ「何を」 移植するか

📝 frontend/src/pages/TaskListPage.vue を新規作成。 旧 App.vue の中身を 選んで コピーする。

移植する (旧 App.vue から):

  • <script setup>tasks / loading / errorMsg / newName / API / onMounted / addTask / removeTask
  • <template><input> + <button>追加</button> + <ul> + <TaskItem> 部分 + errorMsg 表示
  • <style scoped> の中身

持ってこない:

  • editingId / editName / startEdit / cancelEdit / saveEdit
  • <template> の「編集中だけ表示される入力欄」 (<div v-if="editingId" class="edit-box"> 部分)

> 編集機能は STEP3 で詳細画面に移管 するため、 ここでは持ってこない。

> TaskItem は今はそのまま使ってよい。 STEP3 で <li> 直書きに置き換える

STEP2 ② 📝 TaskListPage.vue の完成形

<script setup>
import { ref, onMounted } from 'vue'
import TaskItem from '../components/TaskItem.vue'

const tasks = ref([])
const loading = ref(true)
const errorMsg = ref('')
const newName = ref('')
const API = `${import.meta.env.VITE_API_BASE ?? 'http://localhost:8000'}/tasks`

onMounted(async () => {
  try {
    const res = await fetch(API)
    if (!res.ok) throw new Error(`サーバーが ${res.status} を返しました`)
    tasks.value = await res.json()
  } catch (e) { errorMsg.value = `読み込みに失敗: ${e.message}` }
  finally { loading.value = false }
})

async function addTask() {
  if (newName.value === '') return
  try {
    const res = await fetch(API, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: newName.value }),
    })
    if (!res.ok) throw new Error(`追加に失敗しました (${res.status})`)
    tasks.value.push(await res.json())
    newName.value = ''
    errorMsg.value = ''
  } catch (e) { errorMsg.value = e.message }
}

async function removeTask(id) {
  try {
    const res = await fetch(`${API}/${id}`, { method: 'DELETE' })
    if (!res.ok) throw new Error(`削除に失敗しました (${res.status})`)
    tasks.value = tasks.value.filter(task => task.id !== id)
    errorMsg.value = ''
  } catch (e) { errorMsg.value = e.message }
}
</script>

> import TaskItem from '../components/TaskItem.vue'.. (1 つ上に戻る) に注意 (pages/ から見て components/ は 1 階層上)。

> TaskItem.vuecomponents/ フォルダから コピーして使う (= @edit を発火しない単純版に簡略化済 / 答え step2/src/components/TaskItem.vue 参照)。

STEP2 ② 📝 TaskListPage.vue の <template> + <style>

<template>
  <h1>タスク一覧</h1>
  <input v-model="newName" placeholder="新しいタスク" />
  <button @click="addTask">追加</button>

  <p v-if="errorMsg" class="error">{{ errorMsg }}</p>
  <p v-if="loading">読み込み中...</p>
  <ul v-else>
    <TaskItem v-for="task in tasks" :key="task.id"
      :name="task.name" @remove="removeTask(task.id)" />
  </ul>
</template>

<style scoped>
.error { color: #c00; background: #fff0f0; padding: 8px; border-radius: 4px; }
</style>

> <TaskItem>@edit ハンドラは外した (= 編集機能を持ってこなかった)。

STEP2 ③ 📝 router.js を作る

📝 frontend/src/router.js を新規作成 (src/ 直下):

import { createRouter, createWebHistory } from 'vue-router'
import HomePage from './pages/HomePage.vue'
import TaskListPage from './pages/TaskListPage.vue'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',      component: HomePage },
    { path: '/tasks', component: TaskListPage },
  ],
})

> これが「URL → コンポーネント」 の対応表。

STEP2 ④ 📝 main.js でルーターを使う

📝 frontend/src/main.js書き換え (Before/After):

// 旧 (Before)
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

// 新 (After) — 追加は import 1 行 + .use(router) 1 か所
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router.js'        // ← 追加
createApp(App).use(router).mount('#app')    // ← .use(router) を追加

> 増えるのは 2 か所だけ

STEP2 ⑤ 📝 最後に App.vue を書き換え

まず App.vueApp.vue.bak にリネーム (= 念のため温存):

cd 作業用/frontend/src
mv App.vue App.vue.bak    # Windows: rename App.vue App.vue.bak

📝 新しい App.vue を作成:

<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <header>
    <nav>
      <RouterLink to="/">ホーム</RouterLink>
      <RouterLink to="/tasks">タスク一覧</RouterLink>
    </nav>
  </header>
  <main>
    <RouterView />
  </main>
</template>

STEP2 ⑤ 📝 App.vue にグローバル CSS も入れる

上記の <template> の下に <style> (scoped なし) を追記:

<style>
body { font-family: system-ui, sans-serif; margin: 0; }
header { background: #2563eb; color: white; padding: 12px 24px; }
nav a { color: white; text-decoration: none; margin-right: 24px; font-weight: bold; }
nav a.router-link-active { border-bottom: 2px solid white; padding-bottom: 2px; }
main { max-width: 720px; margin: 24px auto; padding: 0 16px; }
</style>

> scoped付けない のがポイント (= グローバル CSS なのでアプリ全体に効かせる)。

> これがないとナビが裸テキストで横並びにならず「壊れた?」 と不安になる。

STEP2 🌐 動作確認

🌐 ブラウザで:

  • / → ホーム画面 (「タスク一覧へ」 のリンクが見える)
  • /tasks → タスク一覧画面 (タスクが 3 件表示)
  • ヘッダーの「タスク一覧」 をクリック → URL が /tasks に変わる
  • ブラウザの 戻る ボタンで / に戻れる

!h:280px

STEP2 🛟 白画面になったときの順番

見るもの何を確認
1🌐 DevTools (F12) → Console赤い文字のエラー (Failed to resolve component 等)
2🖥️ Vite のターミナルコンパイルエラー (どのファイルの何行目か)
3📝 自分のコード答え 答え/step2/src/ と diff (フォルダ構成も含めて)

> 1 分粘って分からなければ「答え」 を覗いてよい。 黙って止まる時間が一番もったいない。

STEP2 のゴール ✅

確認OK?
🌐 //tasks で違う画面が表示される
🌐 ヘッダーリンクで画面切替できる
🌐 戻る / 進むボタンが効く
📝 pages/HomePage.vue pages/TaskListPage.vue の 2 ファイル構成
📝 旧 App.vue.bak が残っている (= 戻れる安心)

> ここまでで 「画面が分かれている」 状態。 次は画面 → 画面の動的な遷移。

STEP3 何が嬉しい? — タスクの詳細を URL で開きたい

タスクが 100 件あったら、 一覧から 1 つクリックして詳細を見たい。

そのとき URL が /tasks/42 のように タスク ID 付き になっていれば:

  • ✅ URL を送れば相手も同じタスクが開ける
  • ✅ ブラウザのお気に入りに登録できる
  • ✅ 一覧画面に戻ったり別タスクへ飛んだりが自然

タスク 100 個分のルートを 100 行書くわけにいかない → 動的ルート を使う。

STEP3 遷移系 API の地図 (4 つの使い分け)

今日出てくる「画面を移動する / URL を読む」 道具は 4 つ。 場面で使い分け:

テンプレート<script setup>
移動する<RouterLink to="/tasks">router.push('/tasks')useRouter() で取得
URL を読む(使わない)route.params.iduseRoute() で取得

> useRoute = 読み取り (= 現在地) / useRouter = 操作 (= 移動)

> 名前が紛らわしいが「読みと書きで関数を分けてある」 設計。

STEP3 📝 進め方

  1. router.js/tasks/:id ルートを追加
  2. TaskDetailPage.vue を新規作成 (= 詳細画面 + 編集 / 削除機能)
  3. TaskListPage.vue を編集して「詳細」 ボタンを追加 (この時 TaskItem<li> 直書きに置き換える / 編集・削除は詳細画面に移管)

STEP3 ① 📝 router.js (全文・3 ルート版)

📝 router.js全文書き換え (STEP2 から増えるのは import 1 行 + routes 1 行):

import { createRouter, createWebHistory } from 'vue-router'
import HomePage from './pages/HomePage.vue'
import TaskListPage from './pages/TaskListPage.vue'
import TaskDetailPage from './pages/TaskDetailPage.vue'   // ← 追加

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',           component: HomePage },
    { path: '/tasks',      component: TaskListPage },
    { path: '/tasks/:id',  component: TaskDetailPage },   // ← 追加
  ],
})

> :id の部分は 任意の値が入る場所/tasks/1 /tasks/42 などすべてこの 1 つのルートで受ける。

STEP3 ② 📝 TaskDetailPage.vue (全文)

📝 frontend/src/pages/TaskDetailPage.vue を新規作成 (script + template + style まで一気に):

<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'

const route = useRoute()
const router = useRouter()
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000'
const task = ref(null)
const loading = ref(true)
const errorMsg = ref('')
const editing = ref(false)
const editName = ref('')

onMounted(async () => {
  try {
    const res = await fetch(`${API_BASE}/tasks/${route.params.id}`)
    if (res.status === 404) throw new Error('このタスクは見つかりません')
    if (!res.ok) throw new Error(`サーバーが ${res.status} を返しました`)
    task.value = await res.json()
  } catch (e) { errorMsg.value = e.message }
  finally { loading.value = false }
})

function startEdit() {
  editName.value = task.value.name
  editing.value = true
}

async function saveEdit() {
  try {
    const res = await fetch(`${API_BASE}/tasks/${task.value.id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: editName.value }),
    })
    if (!res.ok) throw new Error(`保存に失敗しました (${res.status})`)
    task.value = await res.json()
    editing.value = false
  } catch (e) { errorMsg.value = e.message }
}

async function removeTask() {
  if (!confirm('このタスクを削除しますか?')) return
  try {
    const res = await fetch(`${API_BASE}/tasks/${task.value.id}`, { method: 'DELETE' })
    if (!res.ok) throw new Error(`削除に失敗しました (${res.status})`)
    router.push('/tasks')
  } catch (e) { errorMsg.value = e.message }
}
</script>

> 編集/削除は 詳細画面でのみ可能 にする (= 一覧画面はクリーンに保つ設計判断)。

> useRouter を import に追加するのを忘れない (= 削除後の一覧画面遷移に必要)。

STEP3 ② 📝 TaskDetailPage.vue の <template> + <style>

<template>
  <RouterLink to="/tasks">&larr; タスク一覧へ戻る</RouterLink>
  <h1>タスク詳細</h1>
  <p v-if="errorMsg" class="error">{{ errorMsg }}</p>
  <p v-if="loading">読み込み中...</p>
  <div v-else-if="task" class="detail">
    <p class="id">ID: {{ task.id }}</p>
    <div v-if="!editing">
      <p class="name">{{ task.name }}</p>
      <div class="actions">
        <button @click="startEdit">編集</button>
        <button @click="removeTask" class="danger">削除</button>
      </div>
    </div>
    <div v-else>
      <input v-model="editName" />
      <div class="actions">
        <button @click="saveEdit">保存</button>
        <button @click="editing = false">やめる</button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.detail { margin-top: 16px; padding: 16px; border: 1px solid #ddd; border-radius: 8px; }
.id { color: #666; font-size: 13px; margin: 0 0 8px 0; }
.name { font-size: 24px; margin: 8px 0 16px 0; }
.actions { display: flex; gap: 8px; }
.actions button { padding: 6px 16px; cursor: pointer; }
.danger { background: #c00; color: white; border: 1px solid #900; }
input { width: 100%; padding: 8px; font-size: 16px; margin-bottom: 8px; }
.error { color: #c00; background: #fff0f0; padding: 8px; border-radius: 4px; }
</style>

> useRoute() で「現在のルート情報」 が取れる。 route.params.id:id の値を取り出す。

STEP3 ③ 📝 TaskListPage.vue を編集 (Before/After)

「詳細」 ボタンを追加し、 <TaskItem><li> 直書きに置き換える。

Before (STEP2 終了時点):

<ul v-else>
  <TaskItem v-for="task in tasks" :key="task.id"
    :name="task.name" @remove="removeTask(task.id)" />
</ul>

After (STEP3):

<ul v-else>
  <li v-for="task in tasks" :key="task.id" class="task">
    <span>{{ task.name }}</span>
    <button @click="openDetail(task.id)">詳細</button>
  </li>
</ul>

> ここで初めて <TaskItem> を捨てる。 import TaskItem の行も削除してよい。

STEP3 ③ 📝 TaskListPage.vue の <script setup> に追加

<script setup> の冒頭 import に useRouter を追加:

import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'   // ← 追加

const router = useRouter()                // ← 追加: ページ遷移用
// ... 既存の tasks / loading / errorMsg / newName / API はそのまま ...

function openDetail(id) {                 // ← 追加: 詳細へ遷移
  router.push(`/tasks/${id}`)
}

> 1 文字違いに注意: useRoute (e なし・読み取り) と useRouter (r 付き・操作) は別物。

STEP3 📝 TaskListPage.vue の <style scoped> も足す

<li class="task"> のスタイルがないと一覧が崩れる。 <style scoped> に追加:

.task {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 12px;
  border-bottom: 1px solid #eee;
  list-style: none;
}
button { padding: 4px 12px; cursor: pointer; }
ul { padding: 0; }

STEP3 🌐 動作確認

🌐 ブラウザで:

  • /tasks でタスクの「詳細」 ボタンをクリック → /tasks/1 などに飛ぶ
  • 詳細画面で ID と名前が見える
  • アドレスバーで直接 /tasks/2 と入力しても表示できる
  • 存在しない /tasks/99999 を入れると「このタスクは見つかりません」
  • 詳細画面の「編集」 → 名前を変えて「保存」 で更新される (= PATCH)
  • 詳細画面の「削除」 → 確認ダイアログ → 一覧画面に戻り、 削除済み (= DELETE)

!h:240px

> ⚠️ 一覧画面 (/tasks) には 編集/削除ボタンを置かない (= 詳細画面に集約する設計)。

> 「一覧から消す」 ときは詳細画面に入って削除する流れ。

STEP3 解説: サーバーはなぜ /tasks/{id} も持っていた?

fastapi/main.py には GET /tasks/{task_id} が既に書いてある。

今日まで使っていなかったが、 詳細画面のために用意されていた

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    ...
    return dict(row)

> 「フロントが何を必要とするか」 をサーバー側が先に想像して用意しておく。

> 18 日からのチーム開発では、 フロントとサーバーで「何が必要か」 を会話で詰める 場面の連続。

STEP3 のゴール ✅

確認OK?
🌐 タスクの「詳細」 ボタンで詳細画面に飛べる
🌐 URL に ID が表示される (/tasks/1 等)
🌐 存在しない ID でエラー表示
🌐 詳細画面で 編集 → 保存 すると名前が変わる (= PATCH)
🌐 詳細画面で 削除 すると一覧から消える (= DELETE)
📝 useRoute().params.id で URL から ID が取れている
📝 useRouter().push() で一覧 → 詳細 / 削除後 → 一覧 の遷移ができている

STEP4 派生値って何?

「元の値」 から「計算で出る値」 = 派生値

派生
税抜価格 + 税率→ 税込価格
タスク配列 + 検索キーワード→ 絞り込み結果
絞り込み結果→ 件数ラベル

派生値の特徴: 元が変わったら派生も自動で変わってほしい

これをラクに書く道具が computed

STEP4 解説: 3 つの書き方を比べる

「タスクをキーワードで絞り込む」 を 3 通りで書ける:

書き方動く?問題点
(A) テンプレに tasks.filter(...) を直書き読みにくい / 毎回再計算
(B) <script> 内に function filterTasks() {...} を書くテンプレから呼ぶたび 毎回再計算
(C) computed(() => ...)filteredTasks を定義 ← 今日使う依存先が変わったときだけ計算 (自動キャッシュ)

> Vue 中級の作法は (C)。 「派生値はテンプレに書かない」 を覚える。

STEP4 📝 進め方

TaskListPage.vue を 3 段階で育てる:

  1. 検索入力欄を追加 (= まだ何も絞られない)
  2. 空の computedfilteredTasks を作る (= 全件返すだけ・動作確認)
  3. filter を足して実際に絞り込む
  4. 件数表示 (countLabel) も computed で作る

> いきなり完成形を書かない。 1 段ずつ「動く」 を確認しながら進む。

STEP4 ① 📝 import に computed を追加

📝 TaskListPage.vue<script setup> の最初の import 文を 書き換え:

// Before
import { ref, onMounted } from 'vue'

// After (computed を追加)
import { ref, computed, onMounted } from 'vue'

> 既存行を 置き換え。 新しい import 行を増やすと二重定義 で警告が出る。

STEP4 ① 📝 検索入力欄 + keyword ref

<script setup>keyword を追加 (既存の newName の近くがおすすめ):

const keyword = ref('')

<template><h1>タスク一覧</h1>直下 に絞り込み欄を追加:

<div class="controls">
  <input v-model="keyword" placeholder="名前で絞り込み..." />
</div>

> ここまでは「入力欄ができただけ」。 何も絞られない (= まだ正常)。

STEP4 ② 📝 filteredTasks を空で作る (動作確認)

📝 <script setup> に追加 (まずは全件返すだけ):

const filteredTasks = computed(() => tasks.value)

<template><li v-for>tasksfilteredTasks に書き換え (Before/After):

<!-- Before -->
<li v-for="task in tasks" :key="task.id" class="task">
<!-- After -->
<li v-for="task in filteredTasks" :key="task.id" class="task">

> 変えるのは in の右側だけ。 ループ変数の task (左側) や :key="task.id" は触らない。

> 🌐 ブラウザで全件が見えれば OK = computed の繋ぎ込みが成功している。

STEP4 ③ 📝 filter を足して実際に絞り込む

filteredTasks の中身を書き換え:

const filteredTasks = computed(() => {
  if (keyword.value === '') return tasks.value
  return tasks.value.filter(t => t.name.includes(keyword.value))
})

> 🌐 「名前で絞り込み」 に "牛" と入れる → 即座に絞られる。 入力ごとに 自動再計算

> keywordtasks のどちらかが変わった瞬間に filteredTasks も変わる = これが Vue の リアクティビティ

STEP4 ④ 📝 件数表示も computed

<script setup> に追加:

const countLabel = computed(() =>
  `全 ${tasks.value.length} 件 / 表示中 ${filteredTasks.value.length} 件`
)

> computed から computed を参照できる (countLabelfilteredTasks を参照)。

> Vue が依存関係を勝手にたどり、 元のデータが変わったときだけ再計算する。

<template> の絞り込み欄の隣に表示:

<div class="controls">
  <input v-model="keyword" placeholder="名前で絞り込み..." />
  <span class="count">{{ countLabel }}</span>
</div>

<style scoped> に: .controls { display: flex; gap: 8px; align-items: center; margin: 8px 0; } .controls input { flex: 1; padding: 6px 8px; } .count { color: #666; font-size: 13px; }

STEP4 🌐 動作確認

!h:280px

確認期待動作
「名前で絞り込み」 に "牛" と入れる一覧が即座に絞られる
「全 3 件 / 表示中 1 件」 の表示キーワードに応じて更新される
検索を空にする元の一覧に戻る

STEP4 解説: ref と computed の .value ルール

<script><template>
refkeyword.value で読み書きkeyword (.value 不要)
computedfilteredTasks.value で読む (書けない)filteredTasks (.value 不要)

> Vue は テンプレート内では自動で .value を外してくれる。 script 内では明示する。

STEP4 のゴール ✅

確認OK?
🌐 検索欄で一覧が絞られる
🌐 件数表示が連動する
📝 computed を 2 つ使っている (filteredTasks / countLabel)
📝 computed 中で tasks.value と書いている (script 内では .value)

STEP5 副作用って何?

computed の仕事は「値を返す」 こと (= 派生値)。

これに対して「画面の外に何かをする」 = 副作用

画面に値を出す = 表示computed の仕事
画面の外で何かを実行 = 副作用localStorage 書き込み / API 再取得 / ログ送信 / 通知

副作用を起こしたいときの道具が watch

STEP5 解説: computed vs watch

目的道具
を作るcomputed絞り込み一覧、 件数ラベル、 合計金額
何かを実行 するwatchlocalStorage 保存、 API 再取得、 ログ送信

> 迷ったときの判定: 「返り値が欲しい」 = computed / 「副作用が欲しい」 = watch

STEP5 📝 今日 watch を使う 2 か所

場所何を監視何の副作用
(a) TaskListPage.vuekeyword (ref) の変化localStorage に保存
(b) TaskDetailPage.vueURL の :id の変化タスクを再取得

> 2 つの場所で 書き方が少し違う (= watch の第 1 引数の形)。 後でまとめて比較する。

STEP5 ① 📝 import に watch を追加

📝 TaskListPage.vue<script setup> の import を書き換え:

// Before
import { ref, computed, onMounted } from 'vue'

// After (watch を追加)
import { ref, computed, onMounted, watch } from 'vue'

STEP5 ① 📝 keyword を localStorage に保存する

<script setup> に追加 (computed の下あたり):

watch(keyword, (newVal) => {
  localStorage.setItem('lastKeyword', newVal)
})

onMounted冒頭で読み戻し (既存の try/catch の ):

onMounted(async () => {
  keyword.value = localStorage.getItem('lastKeyword') ?? ''   // ← 追加 1 行
  try {                                                       // ↓ ここから既存のまま
    const res = await fetch(API)
    // ... 以下、既存の fetch ロジック ...
  } finally { loading.value = false }
})

> 既存の try { ... } finally { ... }触らない。 1 行追加するだけ。

STEP5 ① 🌐 動作確認 (localStorage)

🌐 ブラウザで:

  1. 検索欄に "牛" と入れる → 一覧が絞られる
  2. ブラウザをリロード
  3. タスクが再取得され、 検索欄は "牛" のまま / 一覧も絞られたまま

!h:280px

> 💡 リロード時の流れ: onMountedkeyword.value = ... を実行 → watch(keyword) が発火 → 同じ値を localStorage に書き戻す (= 実害なし)。

STEP5 ② 📝 詳細画面で URL 変化に追随

詳細画面 (TaskDetailPage.vue) で、 /tasks/1 を見ているときにアドレスバーで /tasks/2 に変えると...

ページコンポーネントが再利用される ので onMounted が再実行されない。 = 中身が古いまま。

watch で URL の :id を見張る 必要がある。

STEP5 ② 📝 TaskDetailPage.vue の全文 (Before/After)

📝 全文書き換え (関数 loadTask に切り出して onMounted と watch から呼ぶ):

<script setup>
import { ref, watch, onMounted } from 'vue'              // ← watch を追加
import { useRoute, RouterLink } from 'vue-router'

const route = useRoute()
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000'
const task = ref(null)
const loading = ref(true)
const errorMsg = ref('')

async function loadTask(id) {                            // ← 関数に切り出し
  loading.value = true
  errorMsg.value = ''
  try {
    const res = await fetch(`${API_BASE}/tasks/${id}`)
    if (res.status === 404) throw new Error('このタスクは見つかりません')
    if (!res.ok) throw new Error(`サーバーが ${res.status} を返しました`)
    task.value = await res.json()
  } catch (e) { errorMsg.value = e.message; task.value = null }
  finally { loading.value = false }
}

onMounted(() => loadTask(route.params.id))                // ← 初回
watch(() => route.params.id, (newId) => {                 // ← URL 変化時
  if (newId) loadTask(newId)
})
</script>

> 元 onMounted の中身が 丸ごと loadTask に移った。 元の try-catch は loadTask 内にある。

STEP5 解説: watch の第 1 引数の 2 形式

TaskListPage.vueTaskDetailPage.vue で書き方が違うのに気付いたか?

監視対象書き方
ref そのもの (例: keyword)watch(keyword, (newVal) => {...})
オブジェクトのプロパティ (例: route.params.id)watch(() => route.params.id, (newVal) => {...})

> プロパティを見たいときは () => ... で getter にくるむ

> 直接 watch(route.params.id, ...) と書くと「値を渡しただけ」 になり、 変化を捕まえられない (= リアクティブにならない)。

STEP5 🌐 動作確認 (URL 追随)

🌐 ブラウザで:

  • /tasks/1 を開く → タスク 1 の詳細
  • アドレスバーで /tasks/2 に変更 → ページはそのまま、 中身だけ切り替わる
  • (= フル再読み込みが起きていない = onMounted ではなく watch で再取得した証拠)

> DevTools の Network タブで GET /tasks/2 のリクエストが新しく飛んでいるのが見える。

STEP5 のゴール ✅

確認OK?
🌐 検索語をリロードしても保持されている
🌐 詳細画面で URL を変えると中身も追随する
📝 watch を 2 か所で使っている (ref 直接 / getter)
📝 onMountedwatch で同じ関数 (loadTask) を呼んでいる

今日の判断軸 (持ち帰り 1 枚)

明日からのチーム開発で迷ったとき、 この 3 行を思い出す:

場面道具
値が欲しいcomputed
何か実行したいwatch
ref そのものを見たいwatch(refName, ...)
ref の中の何か (プロパティ) を見たいwatch(() => obj.prop, ...)

今日の完成 ✅

できるようになったこと道具
画面が複数あって、 URL で切り替わるVue Router + <RouterView> + <RouterLink>
URL のパラメータでデータを取れる動的ルート + useRoute().params
派生値を効率よく扱えるcomputed
値の変化で副作用を起こせるwatch

> 1 画面アプリ → 複数画面 + 検索 + 詳細追随 まで 1 日で育った。

> 明日 (後編) は、 状態を 画面をまたいで共有 (Pinia) + 入力チェック + エラー UX 洗練。

振り返り

1 画面しかなかったタスクアプリ」 が、 1 日で「複数画面 + 検索 + 詳細追随」 まで育った。

これらは Vue を使う限り 毎日触る道具。 まずは「使ったことがある」 状態を作るのが今日の目的。

[vue-basic ch01-06]                [本章 (app-tasks/ch01)]
1画面 / ref / v-for       3画面 / Router / computed / watch
↓ 育てる
[state-management/ch03-pinia-cross-view]            [forms/ch01-reactive-validation + api/ch01-async-ux-3states]
Pinia / バリデーション    両方の道具を実戦投入

翌日言える 1 文 (= 自分の言葉で言語化する)

> 「昨日は 画面を切り替える方法 (Router) と、 派生値を作る方法 (computed) と、 値の変化に反応する方法 (watch) を体に入れた」

声に出してみる。 言葉が出てこない部分がある = 明日の復習対象

詰まりやすい言葉戻るべき場所
Router / RouterLink / RouterViewSTEP2
動的ルート / useRoute().paramsSTEP3
computed の役割STEP4
watch の役割 / computed との違いSTEP5

> 詰まった所がそのまま次章 (state-management/ch03-pinia-cross-view) のスタートライン。 「ここがまだ自分の言葉になっていない」 を 1 つは見つけておく。

夕方は演習に進む

「読む」 から「書く」 に切り替える。 演習問題.pdf を開いて、 以下の順番がおすすめ:

#演習本編との関係
1Router 01: 2 画面分割本編 STEP2 の追体験
2Router 02: 動的ルート本編 STEP3 の追体験
3Composition API 01: computed 練習本編 STEP4 の追体験
4Composition API 02: watch で localStorage本編 STEP5 の追体験

> 全部やり切る必要はない。 「ここ手を動かしたい」 と思ったところから。

チャレンジ (時間が余ったら)

  1. ホーム画面に 「最後に開いたタスク」 を表示する (= STEP5 の watch + localStorage の応用)
  2. 動的ルートを /tasks/:id/edit で編集専用画面にも対応させる
  3. watchEffect を使って 依存を自動検出させる (watch との違いを体感)
  4. <RouterLink :to="...">toオブジェクト形式 で書き直す ({ name: '...' } 形式)

FAQ

Q. Vue Router と Vue は別ライブラリ?

A. はい。 公式チームが作っているが別パッケージ。 npm install vue-router で入れる。

Q. useRoute()useRouter() の違いは?

A. useRoute = 読み取り (現在のルート情報) / useRouter = 操作 (push 等で遷移)。 1 文字違いで意味が違う。

Q. computed の代わりに function を使ってもいい?

A. 動くが 毎回再計算が走るcomputed は値が変わったときだけ計算する (自動キャッシュ)。

Q. watchwatchEffect の違いは?

A. watch は「何を監視するか」 を明示。 watchEffect は「使ったものを自動で監視」。 入門は watch から。

Q. App.vue.bak はいつ消していい?

A. STEP5 まで通って動作確認 OK なら消してよい。 不安なら明日まで残しておく。

おわり

複数画面アプリの基礎一式が、 1 日で身に付いた。

明日は「散らからない状態」 にしていく (Pinia / バリデーション / エラー UX)。

お疲れさまでした。

考えてみよう

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

演習問題

4 概念 × 3 段階 = 12 本の反復ドリル

本編で読んだ道具を、 別の題材で 3 回ずつ書く。 これで「使える」 状態にする。

この教材について

項目内容
対象Vue の基本 (<script setup> / ref / v-for / v-model / SFC) を一通り触ったことがある
形式4 概念 × 3 段階 = 12 ドリル (反復練習)
修了条件全 12 ドリルで「目で確認」 OK + npx vitest run 全 PASS
動作確認環境Node.js 20.x LTS / Python 3.10+ / macOS / Windows 11 / WSL2

> 1 ドリルごとに「目視 (主) + テスト (副)」 の 2 段ゲートで完走判定する設計。

始める前のセルフチェック ① — 前提知識

ここに 1 つでも □ があれば、 該当リソースで補ってから戻ってくる。

領域内容不安なら
Vue 基礎<script setup> / ref / v-for / v-model / コンポーネント分割Vue 公式 Tutorial
JavaScript ES6+アロー関数 / 分割代入 / async / await / fetch / 配列 .filter .mapMDN JS ガイド
Web 基礎HTTP メソッド (GET/POST) / JSON / DevTools (F12)MDN HTTP 概要
シェルcd / ls / Ctrl+C / ターミナル複数枚-

始める前のセルフチェック ② — 前提環境

ソフトバージョン確認コマンド入手
Node.js20.x LTS 以上node -vhttps://nodejs.org/
Python3.10 以上python --versionhttps://python.org/
ブラウザChrome / EdgeDevTools (F12) が開けること-
エディタVS Code 推奨+ Vue (Volar) 拡張https://code.visualstudio.com/
ポート5173 (Vue) / 8001 (API)lsof -i :5173 (Mac) / `netstat -ano \findstr :5173` (Win)-

配布物の全体マップ

配布資料/
├ はじめに.pdf          ① 最初に読む (環境構築の前提)
├ Vue中級.pdf           ② 本編・概念解説
├ 演習問題.pdf          ③ 本書 (12 ドリルの手引き)
├ 演習問題・ヒント.pdf  詰まったら開く
├ サンプルコード/      本編で使う雛形 (作業用 / 完成品 / 答え)
└ 演習/                ★ 本書で作業するディレクトリ
    ├ _api/             演習用 FastAPI (起動するだけの箱)
    ├ router/           {basic, applied, combined}
    ├ dynamic-route/    {basic, applied, combined}
    ├ computed/         {basic, applied, combined}
    └ watch/            {basic, applied, combined}

推奨する読む順序

はじめに.pdf (環境準備) → ② Vue中級.pdf (本編通読) → ③ 本書 (12 ドリル) → ④ 詰まったら 演習問題・ヒント.pdf → ⑤ 最終手段 演習/<drill>/answer.*

VS Code で開くフォルダ

配布資料/演習/File > Open Folder で開く (ここをワークスペースルートに / 補完が効く)。

学習ワークフロー全体図

┌─ INPUT ──────────┐  ┌─ PROCESS ──────────────┐  ┌─ OUTPUT ─────────────┐
│ ・前提知識       │  │ ① 本編 通読            │  │ ・4 道具を場面で抜ける │
│ ・前提環境       │→ │ ② 環境構築 (1 回)      │→ │ ・完走証跡 (PASS スクショ)│
│ ・配布物 zip     │  │ ③ 12 ドリル (反復)     │  │ ・次の学習へ接続       │
└──────────────────┘  │ ④ 詰まったら自己診断   │  └──────────────────────┘
                      └────────────────────────┘

進捗チェックリスト (再開時の起点)

概念basicappliedcombined
🔵 Router□ 目視 □ PASS□ 目視 □ PASS□ 目視 □ PASS
🟣 動的ルート□ 目視 □ PASS□ 目視 □ PASS□ 目視 □ PASS
🟢 computed□ 目視 □ PASS□ 目視 □ PASS□ 目視 □ PASS
🟡 watch□ 目視 □ PASS□ 目視 □ PASS□ 目視 □ PASS

> 中断したら、 次回開始時はまずこの表を見て「前回どこまで」 を確認する。

大事な認識: ゴールは「画面が動く」 こと

各ドリルには 完成後の画面イメージ (ゴール画像) を載せている。 これがゴール

ゴール認識✅ こうあるべき❌ こうなりがち
何を作る?完成画面に書かれている動作テストが期待する文字列を逆算
テストの位置づけ裏方の機械チェックテストを通すのが目的
完成判定目視 (主) + テスト PASS (副) の 2 段テスト PASS だけ

> 各ドリルの 「目で確認 (主判定)」 が動いていれば、 vitest は自動で PASS する。

> vitest だけ通っても、 目視が抜けていたら 未完了

12 ドリル × 実戦場面 早見表

学ぶ 4 道具が、 実プロジェクトでどう使われるか:

道具実戦の使いどころ「この道具を抜くサイン」
🔵 Routerヘッダーナビ / 画面切替URL 1 つで複数画面を切替えたい
🟣 動的ルートメモ詳細画面 /memos/:idURL 1 つで複数データを使い回したい
🟢 computed一覧フィルタ / 件数表示 / 合計金額テンプレ式で同じ計算を 2 回書きそう
🟡 watch入力 → API 保存 / localStorage 同期ref が変わった瞬間に副作用が要る

色分けの見方 (タイトルスライド)

12 ドリルは 4 概念に分かれている。 タイトルスライドの色で「今どの概念か」 が一目で分かる目印として使う。

概念ドリル
🔵 薄青Router (画面切替)basic / applied / combined
🟣 薄紫動的ルート (URL から値取得)basic / applied / combined
🟢 薄緑computed (派生値)basic / applied / combined
🟡 薄黄watch (副作用)basic / applied / combined

> PDF をパラパラめくったとき、 色で「今どの概念か」 が一瞬で分かる目印。 教材的な意味はそれだけ。

演習はなぜ独立フォルダ?

観点独立フォルダ (= 教材の主線)
本編コードへの干渉ゼロ (= 本編は壊れない)
環境が壊れたときのリカバリ演習/ を消して zip から再展開 で復旧
vitest 設定教材側で用意済み (考えなくていい)
12 ドリル間の干渉ゼロ (= 各 <concept>/<level>/ が独立)

> 「壊しても怖くない場所で練習する」 が反復の基本。

環境構築 (1 回だけ)

Step 1: Vue 側 (vitest を回すための環境)

cd 配布資料/演習/
npm install            # vitest / @vue/test-utils / happy-dom / vue / vue-router

npm install で失敗した場合の早見

症状対処
ERESOLVEnpm install --legacy-peer-deps
EACCES (権限)sudo は使わず ~/.npm の owner を見直す
Node バージョン違いnvm / mise で 20 LTS に切替
Windows long pathgit config --system core.longpaths true
proxy 環境.npmrc の proxy 設定

環境構築: Step 2 — Vue dev server で目視するための準備

「目で確認」 をしたい人は npm create vue@latest で自分の Vue プロジェクトを 1 つ作り、 そこに各ドリルの素材を入れて動かす。 配布物にはあえて vite.config.js を入れていない (= 自分で Vue プロジェクトを立てる経験も込み)。

# 任意の作業ディレクトリで 1 回だけ
npm create vue@latest my-vue-drill
# 対話で以下を選ぶ:
#   - TypeScript? → No
#   - JSX? → No
#   - Vue Router? → Yes  ← 重要
#   - Pinia? → No
#   - Vitest? → Yes      ← 推奨
#   - End-to-end? → No
#   - ESLint? → 任意
cd my-vue-drill
npm install
npm run dev
# → http://localhost:5173 で初期画面が見えれば OK

> 1 つの my-vue-drill プロジェクトを 12 ドリルで使い回す。 ドリルを切り替えるときは src/App.vue 等を配布の 演習/<drill>/ のものに差し替える。

環境構築: Step 3 — 演習用 API

dynamic-route 3 本と watch/combinedfetch で API を叩くドリル。 配布の _api/ を「起動するだけの箱」 として使う。

Mac / Linux / WSL / Git Bash

cd 配布資料/演習/_api/
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --port 8001 --reload

Windows PowerShell

cd 配布資料\演習\_api\
python -m venv .venv
.\.venv\Scripts\Activate.ps1     # Restricted エラーが出たら次行を 1 回だけ
# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
pip install -r requirements.txt
uvicorn main:app --port 8001 --reload

> ⚠️ port 8001 で起動。 ブラウザで http://localhost:8001/docs を開いて API 一覧が見えれば OK。

> 既に 8001 が使われていれば --port 8002 に変えて、 ドリル側の fetch URL も合わせる。

テスト実行 (vitest)

やることコマンド
全 12 本いっぺんにnpx vitest run
概念ごとnpx vitest run router npx vitest run computed
1 本ずつnpx vitest run router/basic npx vitest run watch/combined
編集中にリアルタイム実行npx vitest (watch モード)

> テストは fetch を mock している ので、 vitest を回すだけなら _api の起動は不要。

> 目視確認 (npm run dev でブラウザ確認) のときだけ _api を起動する。

1 ドリルの作業ループ (7 ステップ)

全 12 ドリル共通の作業手順。 各ドリルでこの 7 ステップを繰り返す。

STEP 1: VS Code で 配布資料/演習/<concept>/<level>/ を開く
STEP 2: 「★ 編集」 マーク付きファイル (App.vue / router.js 等) を開く
STEP 3: ターミナル A で  npx vitest <concept>/<level>  (watch モード)
STEP 4: API が要るドリルはターミナル C で _api を起動
STEP 5: ターミナル B で  npm run dev  (my-vue-drill 内) → ブラウザで該当 URL を開く
STEP 6: 編集 → 保存 → ブラウザで目視確認 (主判定) + vitest 緑 (副判定)
STEP 7: 仕上げチェック全 ✓ → (任意) git commit → 次のドリルへ

1 ドリルを終えたサイン

  • [ ] 「目で確認」 のチェック項目 すべて OK
  • [ ] npx vitest run <concept>/<level> が PASS
  • [ ] (任意) git commit -m "<concept>/<level> done"
  • [ ] 進捗チェックリストに記入

進め方の推奨

進め方向いている場合
横断型 (おすすめ): 全概念 basic → 全概念 applied → 全概念 combined一通り全部に触れたい / 飽きにくい
縦断型: router 3 本 → dynamic-route 3 本 → computed 3 本 → watch 3 本一つの概念を集中して定着させたい
時間切れ用: basic 4 本だけ急いで進めにくい場合 / 全 12 本完走は理想

> ⚠️ 縦断型の注意: dynamic-route/combined は概念4 watch を先取りで使う。 縦断派は watch/basic を先にやるか、 ヒント PDF の書き出しサンプルを写して OK。

Chapter 1 of 12 — 概念1 Router の最小構成

🔵 router/basic

2 画面分割 — 最小の Router 構成

/ で「ホーム」、 /profile で「プロフィール」 を行き来する

🔵 router/basic — 課題テーマ

> 現実問題: ヘッダーから「ホーム」 と「プロフィール」 を行き来する最小構成

ユーザーが何をしてどうなる?

ヘッダーのリンクをクリックすると、 URL が / / /profile に切り替わり、 表示も切り替わる。

配布ファイル

演習/router/basic/
├ App.vue       ← ★ 編集
├ router.js     ← ★ 編集
├ pages/
│   ├ HomePage.vue   (完成済)
│   └ ProfilePage.vue (完成済)
└ tests/

🔵 router/basic — ゴール ① ホーム

URL: http://localhost:5173/

!w:65%

> ヘッダーの「ホーム」 がアクティブ (下線)、 画面に「ホーム / ようこそ。」。

🔵 router/basic — ゴール ② プロフィール

URL: http://localhost:5173/profile

!w:65%

> ヘッダーの「プロフィール」 をクリック → URL が /profile に変わり画面が切り替わる。

🔵 router/basic — やること + 仕上げチェック

やること

  • [ ] router.js: createRouter + 2 ルート (/HomePage / /profileProfilePage)
  • [ ] App.vue: <header><nav> に 2 つの <RouterLink> + <main><RouterView />

目で確認 (主判定)

  • [ ] / で「ホーム」 と「ようこそ。」 が見える
  • [ ] ヘッダーの「プロフィール」 をクリックで URL が /profile に変わり画面が「プロフィール」 になる
  • [ ] ブラウザの戻るで / に戻る

仕上げチェック (副判定)

npx vitest run router/basic

Chapter 2 of 12 — 概念1 Router の応用

🔵 router/applied

3 画面 + 各リンク

/ /about /contact の 3 ページサイト構成

🔵 router/applied — 課題テーマ

> 現実問題: トップ / About / お問い合わせ の典型 3 画面サイト

ユーザーが何をしてどうなる?

ヘッダーの 3 つのリンクをクリックすると、 URL が / /about /contact に切り替わり画面も切り替わる。

配布ファイル

演習/router/applied/
├ App.vue       ← ★ 編集
├ router.js     ← ★ 編集
└ pages/
    ├ HomePage.vue / AboutPage.vue / ContactPage.vue (完成済)

🔵 router/applied — ゴール ① ホーム

URL: http://localhost:5173/

!w:65%

🔵 router/applied — ゴール ② About

URL: http://localhost:5173/about

!w:65%

🔵 router/applied — ゴール ③ お問い合わせ

URL: http://localhost:5173/contact

!w:65%

🔵 router/applied — やること + 仕上げチェック

やること

  • [ ] router.js: 3 ルート (/ /about /contact)
  • [ ] App.vue: ナビに 3 つの <RouterLink> + <RouterView />

目で確認

  • [ ] 各 URL でそれぞれの画面が見える
  • [ ] アクティブなリンクに下線が付く (router-link-active の CSS)

仕上げチェック

npx vitest run router/applied

Chapter 3 of 12 — 概念1 Router の組み合わせ

🔵 router/combined

router.push と条件付き遷移

ホーム → ログイン → ダッシュボード (空入力ガード付き)

🔵 router/combined — 課題テーマ

> 現実問題: ログイン画面で名前入力 → 押してダッシュボードへ (空入力ガード付き)

ユーザーが何をしてどうなる?

操作URL結果
ホームの「ログイン画面へ」 押下/loginログイン画面
名前を入れずにログイン押下/login (変化なし)何も起きない
名前 taro を入れてログイン押下/dashboardダッシュボード

配布ファイル

演習/router/combined/
├ App.vue / router.js (完成済)
└ pages/
    ├ HomePage.vue   ← ★ 編集 (ログインへ遷移ボタン)
    ├ LoginPage.vue  ← ★ 編集 (login() 関数)
    └ DashboardPage.vue (完成済)

🔵 router/combined — ゴール ① 初期 (ホーム)

URL: http://localhost:5173/

!w:65%

> 「ログイン画面へ」 ボタンを置く。 押下で router.push('/login') (= useRouter())。

🔵 router/combined — ゴール ② ボタン押下 → ログイン画面

URL: http://localhost:5173/login

!w:65%

> URL が /login に変わり、 名前入力欄 + 「ログイン」 ボタンが見える。

🔵 router/combined — ゴール ③ 名前空のままログイン

URL: http://localhost:5173/login (変わらず)

!w:65%

> URL は /login のまま (= 何も起きない)。 これが空入力ガードの効果。

🔵 router/combined — ゴール ④ taro でログイン → ダッシュボード

URL: http://localhost:5173/dashboard

!w:65%

> 名前を入れてログイン → URL が /dashboard に変わり「ようこそ」 が表示。

🔵 router/combined — やること + 仕上げチェック

やること

  • [ ] HomePage.vue: <button data-testid="go-login"> を置く。 押下で router.push('/login')
  • [ ] LoginPage.vuelogin(): name が空でなければ router.push('/dashboard') (空なら何もしない)

目で確認

  • [ ] 「ログイン画面へ」 押下で URL が /login に変わる
  • [ ] 名前空のままログイン押下で URL が変わらない
  • [ ] 名前を入れてログイン押下で URL が /dashboard に変わる

仕上げチェック

npx vitest run router/combined

Chapter 4 of 12 — 概念2 動的ルートの最小構成

🟣 dynamic-route/basic

useRoute().params

URL の /users/<id> で表示が変わる動的ルート

🟣 dynamic-route/basic — 課題テーマ

> 現実問題: 1 つのルート定義で「ユーザー詳細」 を全 ID 分受ける (API から取得)

ユーザーが何をしてどうなる?

URL が /users/<id> のとき、 API (GET /users/:id) から取得した名前を画面に表示。

配布ファイル + API 仕様

演習/dynamic-route/basic/
├ UserPage.vue  ← ★ 編集
└ tests/

_apiGET http://localhost:8001/users/<id> を叩く:

GET /users/42 → { "id": 42, "name": "鈴木 一郎" }
GET /users/7  → { "id": 7,  "name": "佐藤 花子" }
GET /users/1  → { "id": 1,  "name": "山田 太郎" }
GET /users/99 → 404 (見つからない)

> 注: ルート定義はテスト内で { path: '/users/:id', component: UserPage } として与えられる。

🟣 dynamic-route/basic — ゴール ① /users/42

URL: http://localhost:5173/users/42

!w:65%

🟣 dynamic-route/basic — ゴール ② /users/7

URL: http://localhost:5173/users/7

!w:65%

> URL の :id 部分が変わると表示の数字も変わる = URL からデータを取り出す

🟣 dynamic-route/basic — やること + 仕上げチェック

やること

  • [ ] useRoute() で route 取得
  • [ ] onMountedfetch('http://localhost:8001/users/' + route.params.id)
  • [ ] 成功時: user.value に取得結果を入れる (期待形: {id, name})
  • [ ] 失敗時 (!res.ok or catch): errorMsg.value = 'ユーザーが見つかりません'
  • [ ] try / catch で囲む

目で確認 (_api 起動 + npm run dev)

  • [ ] /users/42 で「ユーザー ID: 42 / 名前: 鈴木 一郎」
  • [ ] /users/7 で「ユーザー ID: 7 / 名前: 佐藤 花子」
  • [ ] /users/99999 で「ユーザーが見つかりません」

仕上げチェック

npx vitest run dynamic-route/basic

Chapter 5 of 12 — 概念2 動的ルートの応用

🟣 dynamic-route/applied

複数 params

/posts/:category/:id の 2 階層 URL から値を取り出す

🟣 dynamic-route/applied — 課題テーマ

> 現実問題: 投稿詳細 /posts/<カテゴリ>/<投稿ID> のような複数階層 (API 取得)

ユーザーが何をしてどうなる?

URL の categoryid から API (GET /posts/:category/:id) で投稿を取得して表示。

配布ファイル + API 仕様

演習/dynamic-route/applied/
├ PostPage.vue  ← ★ 編集
└ tests/

_apiGET http://localhost:8001/posts/<category>/<id>:

GET /posts/tech/42 → { "id": 42, "category": "tech", "title": "Vue Router 入門" }
GET /posts/life/7  → { "id": 7,  "category": "life", "title": "朝の散歩" }
GET /posts/news/1  → { "id": 1,  "category": "news", "title": "今日の天気" }

ルート定義は { path: '/posts/:category/:id', ... }

🟣 dynamic-route/applied — ゴール ① /posts/tech/42

URL: http://localhost:5173/posts/tech/42

!w:65%

🟣 dynamic-route/applied — ゴール ② /posts/life/7

URL: http://localhost:5173/posts/life/7

!w:65%

🟣 dynamic-route/applied — やること + 仕上げチェック

やること

  • [ ] onMountedfetch('http://localhost:8001/posts/' + route.params.category + '/' + route.params.id)
  • [ ] 成功時: post.value = 取得結果 (期待形: {id, category, title})
  • [ ] 失敗時: errorMsg.value = '投稿が見つかりません'
  • [ ] try / catch で囲む

目で確認

  • [ ] /posts/tech/42 で「カテゴリ: tech / 投稿 ID: 42 / タイトル: Vue Router 入門」
  • [ ] /posts/life/7 で「カテゴリ: life / 投稿 ID: 7 / タイトル: 朝の散歩」

仕上げチェック

npx vitest run dynamic-route/applied

Chapter 6 of 12 — 概念2 動的ルートの組み合わせ (watch 先取り)

🟣 dynamic-route/combined

watch で URL 変化追随

URL を踏むたびに履歴に積む (watch 先取り)

🟣 dynamic-route/combined — 課題テーマ

> ⚠️ 概念依存: このドリルは概念4 watch を先取りで使う。 watch/basic を先にやるか、 ヒント PDF の書き出しサンプルを写して進めて OK。

現実問題: 詳細画面で URL を変えるたびに API から対応データを再取得 (本編 STEP5 と同じ型)

ユーザーが何をしてどうなる?

/items/<id> を踏むたびに、 API (GET /items/:id) で取得 → current に表示 + visited に追加 (重複無視)。

🟣 dynamic-route/combined — 配布ファイル + API 仕様

配布ファイル

演習/dynamic-route/combined/
├ ItemPage.vue  ← ★ 編集
└ tests/

API 仕様 ( _apiGET http://localhost:8001/items/<id> )

URLレスポンス
/items/1{ "id": 1, "name": "りんご" }
/items/2{ "id": 2, "name": "バナナ" }
/items/3{ "id": 3, "name": "メロン" }

🟣 dynamic-route/combined — ゴール ① /items/1 (初回)

URL: http://localhost:5173/items/1

!w:65%

> 初回マウントで onMounted が走り、 visited1 が追加される。

🟣 dynamic-route/combined — ゴール ② /items/2 へ遷移

URL: http://localhost:5173/items/2

!w:65%

> ナビの「Item 2」 をクリック → URL が変わるが、 同じコンポーネントが再利用されるので onMounted は走らない。

> watchroute.params.id の変化を捕まえて visited2 を追加

🟣 dynamic-route/combined — ゴール ③ /items/3 へ遷移

URL: http://localhost:5173/items/3

!w:65%

> 同様に 3 が追加されて履歴 = [1, 2, 3]。 watch + URL の連携の証拠。

🟣 dynamic-route/combined — やること

  • [ ] loadItem(id) 関数を定義: fetch('http://localhost:8001/items/' + id) で取得
  • [ ] 取得結果を current.value に入れる
  • [ ] visited.value に追加 (visited に既に同じ id があればスキップ)
  • [ ] onMounted(() => loadItem(route.params.id)) で初回
  • [ ] watch(() => route.params.id, (newId) => loadItem(newId)) で URL 変化時
  • [ ] try / catch で囲む

🟣 dynamic-route/combined — 目で確認 + 仕上げチェック

目で確認

  • [ ] /items/1 開いた直後、 「現在: りんご」 + 履歴 = [1: りんご]
  • [ ] /items/2 /items/3 と順に踏む → 履歴 = [1: りんご, 2: バナナ, 3: メロン]
  • [ ] /items/1 に戻ると履歴は増えない (重複無視)

仕上げチェック

npx vitest run dynamic-route/combined

Chapter 7 of 12 — 概念3 computed の最小構成

🟢 computed/basic

3 つの派生値

商品リストから「在庫表示」「合計」「サマリ」 を派生

🟢 computed/basic — 課題テーマ

現実問題: 商品リストから「在庫商品」「合計金額」「サマリ表示」 を派生

ユーザーが何をしてどうなる?

固定の商品リストから、 在庫がある商品だけを表示 + 合計金額を計算 + 「在庫 N 件 / 合計 M 円」 を表示。

🟢 computed/basic — 配布ファイル + 入力データ

配布ファイル

演習/computed/basic/
├ App.vue       ← ★ 編集
└ tests/

App.vue に既に書かれている入力データ

idnamepriceinStock
1りんご100true
2みかん80false (在庫切れ)
3バナナ60true
4メロン1500true

🟢 computed/basic — ゴール

!w:65%

> みかんは在庫切れなので一覧に出ない。 合計は 100 + 60 + 1500 = 1660 円

🟢 computed/basic — やること + 仕上げチェック

やること

  • [ ] availableItems = inStock:true の商品配列 (computed)
  • [ ] totalPrice = availableItems の price 合計 (computed)
  • [ ] summary = "在庫 N 件 / 合計 M 円" (computed)

目で確認

  • [ ] 一覧に「りんご」「バナナ」「メロン」 の 3 件 (みかんは出ない)
  • [ ] サマリに「在庫 3 件 / 合計 1660 円」

仕上げチェック

npx vitest run computed/basic

Chapter 8 of 12 — 概念3 computed の応用

🟢 computed/applied

多段 computed

カート計算を 4 段の computed で連鎖

🟢 computed/applied — 課題テーマ

> 現実問題: カート計算 (小計 → 税込合計) を段階的に派生

配布ファイル + 入力データ

演習/computed/applied/App.vue ← ★ 編集

# 入力データ
cart = [
  { name:'コーヒー',     price:500,  qty:2 },  → 1000
  { name:'サンドイッチ', price:800,  qty:1 },  →  800
  { name:'プリン',       price:300,  qty:3 },  →  900
]
taxRate = 0.10

# 期待値: subtotal=2700, tax=270, total=2970

🟢 computed/applied — ゴール

!w:60%

> computed が computed を参照 = lineSubtotalssubtotaltaxtotal の依存連鎖。

> 元 (cart / taxRate) が変われば 4 段全部が自動で再計算される。

🟢 computed/applied — やること + 仕上げチェック

やること

  • [ ] lineSubtotals = 各商品の小計配列 (price * qty)
  • [ ] subtotal = lineSubtotals の合計
  • [ ] tax = Math.round(subtotal * taxRate)
  • [ ] total = subtotal + tax

目で確認

  • [ ] 各行の小計が 1000 / 800 / 900
  • [ ] 小計 2700 円 / 税 270 円 / 合計 (税込) 2970 円

仕上げチェック

npx vitest run computed/applied

Chapter 9 of 12 — 概念3 computed の組み合わせ

🟢 computed/combined

3 軸複合フィルタ

書籍検索 (keyword + category + maxPrice) の AND 絞り込み

🟢 computed/combined — 課題テーマ

> 現実問題: 書籍検索 (キーワード + カテゴリ + 価格上限) の AND 絞り込み

配布ファイル + 入力データ

演習/computed/combined/App.vue ← ★ 編集 (filtered のみ)

# 入力データ
books = [
  { id:1, title:'Vue 入門',   category:'tech',    price:1800 },
  { id:2, title:'Vue 中級',   category:'tech',    price:2400 },
  { id:3, title:'料理の基本', category:'cooking', price:1500 },
  { id:4, title:'パスタ全集', category:'cooking', price:2200 },
  { id:5, title:'世界の歴史', category:'history', price:3000 },
]

🟢 computed/combined — ゴール ① 初期 (全 5 件)

!w:65%

> 何も絞り込んでいない状態 = 全 5 件が表示。

🟢 computed/combined — ゴール ② keyword=Vue (2 件)

!w:65%

> タイトルに「Vue」 を含む 2 件 (Vue 入門 / Vue 中級) に絞られる。

🟢 computed/combined — ゴール ③ 3 軸全部効かせた状態 (1 件)

!w:65%

> keyword=Vue + category=tech + maxPrice=2000AND 絞り込み → 1 件 (Vue 入門のみ)。

🟢 computed/combined — やること + 仕上げチェック

やること

  • [ ] filtered = books のうち以下 3 条件 全て満たす ものだけ (computed)

- keyword === ''title.includes(keyword)

- category === 'all'book.category === category

- book.price <= maxPrice

目で確認

  • [ ] 初期: 全 5 件 / 該当 5 件
  • [ ] keyword=Vue だけ: 2 件
  • [ ] keyword=Vue + category=tech + maxPrice=2000: 1 件 (Vue 入門)

仕上げチェック

npx vitest run computed/combined

Chapter 10 of 12 — 概念4 watch の最小構成

🟡 watch/basic

ref + localStorage

入力値をリロード後も保持する

🟡 watch/basic — 課題テーマ

> 現実問題: 検索キーワードがブラウザリロード後も残る

ユーザーが何をしてどうなる?

入力欄に値を入れると localStorage に保存され、 リロード後も値が復元される。

配布ファイル

演習/watch/basic/App.vue ← ★ 編集 (watch + onMounted を書き加える)

🟡 watch/basic — ゴール ① 入力直後

!w:65%

> 入力欄に「保存される」 と入れた状態。 同時に watchlocalStorage.setItem を実行。

🟡 watch/basic — ゴール ② F5 リロード後

!w:65%

> リロード後も値が残っている = onMountedlocalStorage.getItem して復元成功。

🟡 watch/basic — やること + 仕上げチェック

やること

  • [ ] watch(keyword, (newVal) => localStorage.setItem('drill_keyword', newVal))
  • [ ] onMounted(() => keyword.value = localStorage.getItem('drill_keyword') ?? '')

目で確認

  • [ ] 入力欄に文字を打つと「入力中: ○○」 が即時更新
  • [ ] ブラウザ F5 リロード後も同じ文字が残る ← watch + onMounted の核心
  • [ ] DevTools > Application > Local Storage に drill_keyword: ○○ が記録

仕上げチェック

npx vitest run watch/basic

Chapter 11 of 12 — 概念4 watch の応用

🟡 watch/applied

getter 形式 + 複数 watch

ref<オブジェクト> のプロパティを 2 つ別々に監視

🟡 watch/applied — 課題テーマ

> 現実問題: ユーザー情報のプロパティごとに変更ログを取りたい

ユーザーが何をしてどうなる?

名前 / 年齢を変えるたびに、 履歴ログに「name: 旧→新」 「age: 旧→新」 が追加される。

配布ファイル + 入力データ

演習/watch/applied/App.vue ← ★ 編集 (watch を 2 つ書き加える)

# 初期値
user = ref({ name: 'Taro', age: 20 })
changeLog = ref([])

🟡 watch/applied — ゴール ① 初期 (Taro / 20)

!w:65%

> 初期状態 = 履歴は空。

🟡 watch/applied — ゴール ② name=Hanako に変更

!w:65%

> 「name: Taro→Hanako」 が履歴に追加。 watch の callback の 旧値 (oldVal) が活用されている。

🟡 watch/applied — ゴール ③ + age=30 に変更

!w:65%

> 「age: 20→30」 も追加。 前のログ (name 変更) は残ったまま = 履歴が積まれる。

🟡 watch/applied — やること + 仕上げチェック

やること

  • [ ] watch(() => user.value.name, (newVal, oldVal) => changeLog.value.push(\name: \${oldVal}→\${newVal}\))
  • [ ] watch(() => user.value.age, ...) も同じ形で

> ref<オブジェクト> の中身を監視 = watch(() => ref.value.プロパティ, ...)getter 形式

> 直接 watch(user.value.name, ...) は値渡しなので反応しない。

目で確認

  • [ ] name 変更で「name: Taro→Hanako」 が追加
  • [ ] age 変更で「age: 20→30」 が追加 (前のログも残る)
  • [ ] 連続変更で履歴が積まれる (上書きではない)

仕上げチェック

npx vitest run watch/applied

Chapter 12 of 12 — 概念4 watch の組み合わせ (総まとめ)

🟡 watch/combined

統合 — computed + watch ×3 + onMounted

12 ドリル総まとめ

🟡 watch/combined — 課題テーマ

現実問題: 検索履歴を API に保存 (本編 STEP5 の localStorage を API 化したもの)

ユーザーが何をしてどうなる?

入力するたびに 文字数表示更新 + API に POST して履歴追加 (重複無視は API 側で)。

別タブを開いても履歴は復元される (= サーバー保存)。

🟡 watch/combined — 配布ファイル + API 仕様

配布ファイル

演習/watch/combined/App.vue ← ★ 編集 (computed + watch + fetch)

API 仕様 ( _api/history )

MethodPathレスポンス
GET/history["vue", "react", ...] (既存履歴)
POST/history (body: { word: "..." }){ "history": [...] } (重複は API 側で無視)

初期値

keyword = ref('')
history = ref([])

🟡 watch/combined — ゴール ① 初期 (空)

!w:65%

> 何も入力していない状態。 文字数 0、 履歴も空。

🟡 watch/combined — ゴール ② vue 入力

!w:65%

> keywordLength (computed) が即時 3 を表示。 履歴 (watch) に vue が追加。

🟡 watch/combined — ゴール ③ + react 入力

!w:65%

> 履歴に react も追加。 文字数は react の長さ 5。

🟡 watch/combined — ゴール ④ + vue 再入力 (重複無視)

!w:65%

> 既に vue が履歴にあるので 追加されない (重複無視ルール)。 履歴は vue / react の 2 行のまま。

🟡 watch/combined — やること

3 つの要件

  • [ ] keywordLength = keyword.value.length (computed)
  • [ ] onMountedGET /history を fetch → history.value に入れる (try/catch)
  • [ ] keyword 変更で POST /history (body: {word: keyword}) → レスポンスの historyhistory.value に入れる (watch / 空文字は無視 / try/catch)

> 重複無視は API 側でやってくれる。 Vue 側で includes チェック不要。

🟡 watch/combined — 目で確認 + 仕上げチェック

目で確認 (_api 起動済 + npm run dev)

  • [ ] vue と入れると文字数 3 / 履歴に「vue」 が追加
  • [ ] react と入れると文字数 5 / 履歴に「vue / react」
  • [ ] vue を再度入れても履歴は増えない (重複無視 by API)
  • [ ] 別タブnpm run dev 画面を開いても履歴が復元される (= サーバー保存)

仕上げチェック

npx vitest run watch/combined

> 12 本完走おつかれさまでした。

詰まったときの自己診断フロー

Step 0: 症状 → 原因マトリクス (まずここを見る)

症状一次原因対処
ブラウザが真っ白npm run dev 未起動 / Console にエラーF12 → Console のエラー文を検索
Failed to fetch / ERR_CONNECTION_REFUSED_api 未起動 / port 違いターミナル C で uvicorn 起動を確認
CORS エラー_api 起動忘れ or 別 origincurl http://localhost:8001/users/42 で API 直叩き確認
EADDRINUSE :8001port を別プロセスが占有lsof -i :8001 → kill / --port 8002 で起動
vitest が赤いexpected X to be Y を読む下記「vitest 出力の読み方」
環境を壊した-演習/ を消して zip から再展開 / rm -rf node_modules && npm install

vitest 出力の読み方

  • = PASS / × = FAIL
  • FAIL 時に着目: ① FAIL tests/xxx.spec.js (どのテスト) / ② expected ... to be ... (期待値 vs 実際) / ③ at App.vue:XX (どのファイル何行目)
  • watch モード終了: q キー / Ctrl+C
  • よくある FAIL 3 パターン:

- RouterLink not found<RouterLink> 未配置 or import 漏れ

- expected null to be ... → fetch のレスポンス処理が未実装 (user.value が null のまま)

- data-testid=... not found → テンプレートに data-testid を書き忘れ

ブラウザ DevTools (F12) で使う 4 タブ

タブ用途このドリル集での使い時
Consoleエラー / console.log何か動かない時、 まずここを開く
Networkfetch のリクエスト / レスポンス/users/42 が叩かれているか確認
Application > Local StoragelocalStorage の中身watch/basic で drill_keyword を確認
Vue DevTools (拡張)コンポーネント / ref の値データが反応していないとき

詰まったら (順番)

  1. 症状 → 原因マトリクス (上記) を確認
  2. 演習問題・ヒント.pdf を開く (各ドリルの書き始めサンプル)
  3. **answer.*** ファイル (完成例) を見て差分を考える
  4. 一人で 15 分粘っても進まなければ、 完成例を写経して動きを確認 → 改めて自力で書き直す

完走したら残しておくもの

  • 全 12 ドリルの npx vitest run PASS スクショ
  • 各ドリルの「目で確認」 スクショ (PDF のゴール画像と並べる)
  • (任意) git commit で 1 ドリル 1 コミットを積んでおくと、 後で見返したときに「自分はここで何をした」 が辿れる
  • 自分の学びを 1 行ずつメモする習慣 (1 ドリルにつき「気付いたこと / つまずいたこと」 1 行ずつ = 計 24 行)

次に学ぶ領域

領域テーマ
状態管理Pinia (画面間で状態を共有)
型安全Vue 3 + TypeScript
API 連携深掘りTanStack Query (Vue Query)
テスト戦略Vue Test Utils / Playwright E2E
実プロジェクトお題: Todo / ブックマーク管理 / 簡易 SNS

12 本完走後: 「この道具を抜くサイン」 まとめ

実プロジェクトで迷ったとき、 この 1 枚を思い出す:

場面抜く道具
URL を 1 つ変えたら画面が切り替わる構造にしたいRouter
URL に値を埋め込んで同じ画面で違うデータを表示したい動的ルート + useRoute().params
元データから派生する値 (合計 / 絞り込み / 件数) を表示したいcomputed
値が変わった瞬間に副作用 (保存 / 通信 / ログ) を起こしたいwatch
ref の中の何か (プロパティ) を監視したいwatch(() => obj.prop, ...) の getter 形式
配列 ref の中身の変化を監視したいwatch(arrRef, ..., { deep: true })

📎 補進ガイド: 本編 作業用/frontend/ に追記する場合

ここから先は 補進ガイド (= オプション)。

教材は 主線 (独立フォルダで動かす) で完結する。 本編に演習を取り込みたい人だけが読む。

補進: なぜわざわざ本編に追記する?

メリットデメリット
本編アプリで実際に画面に出して動かせる本編コードを壊すリスク
npm install を 1 か所で済ませられる本編との依存衝突の可能性
自分の Vue プロジェクトの作り方を一通り体験リカバリが面倒 (本編 zip 再展開)

> 「本編は壊しても zip から戻せる」 という安心を持ってから取り組む。

補進: vitest セットアップを本編に追加する

本編 作業用/frontend/npm create vue@latest で作られた標準的な Vue プロジェクト。

vitest 系は入っていないので追加が必要:

cd 作業用/frontend
npm install -D vitest @vue/test-utils jsdom

vitest.config.js を新規作成 (npm create vue@latest --vitest 生成テンプレ):

import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(viteConfig, defineConfig({
  test: { environment: 'jsdom', exclude: [...configDefaults.exclude, 'e2e/**'],
    root: fileURLToPath(new URL('./', import.meta.url)) },
}))

補進: 演習コードの配置 (干渉回避)

作業用/frontend/src/
├ App.vue / main.js / router.js / pages/ / components/  ← 本編 (触らない)
└ drills/                                                ← ★ 演習はここに
    └ router/basic/
        ├ App.vue / router.js / pages/
        └ __tests__/router-basic.spec.js

> 本編コードは src/drills/ を import しない → npm run dev に影響なし。

> テストファイルは __tests__/<name>.spec.js 形式 (公式デフォルト)。 教材の tests/test_drill.mjs をコピーしてリネームすれば動く。

補進: テスト実行 + リカバリ

cd 作業用/frontend
npx vitest run src/drills              # 全ドリル
npx vitest run src/drills/router/basic # 1 本

壊した時:

rm -rf 作業用/frontend/src/drills    # 演習側だけ消す
# 本編コードがおかしいなら zip から 作業用/ を再展開

補進: まとめ — 主線 vs 補進

主線 (独立フォルダ)補進 (本編追記)
推奨度⭐⭐⭐
環境構築npm install 1 回上記 6 ステップ
干渉リスクなしあり (本編に注意)
リカバリ演習/ を再展開本編 zip ごと再展開

> 迷ったら主線。 主線で 12 本完走してから補進に取り組む順序がおすすめ。

📎 演習用 API 仕様 (自分で書きたい人向け)

_api/main.py を読まずに、 仕様だけで自作する勉強もできる

演習用 API: 全エンドポイント (port 8001)

MethodPathレスポンス例用途ドリル
GET/users/{id}{"id":42,"name":"鈴木 一郎"}dynamic-route/basic
GET/posts/{category}/{id}{"id":42,"category":"tech","title":"Vue Router 入門"}dynamic-route/applied
GET/items/{id}{"id":1,"name":"りんご"}dynamic-route/combined
GET/history["vue","react"]watch/combined (取得)
POST/history{"history":["vue","react"]}watch/combined (追加 / body: {word:"..."})
DELETE/history{"history":[]}(リセット用)

> 存在しない id へのアクセスは 404 {"detail":"... not found"} を返す。

> CORS は http://localhost:5173 に対して許可。

演習用 API: データ実体

/users:
  id=1  → 山田 太郎
  id=7  → 佐藤 花子
  id=42 → 鈴木 一郎

/posts:
  tech/42 → Vue Router 入門
  life/7  → 朝の散歩
  news/1  → 今日の天気

/items:
  id=1 → りんご
  id=2 → バナナ
  id=3 → メロン

/history: 起動時は空、 POST されると追加 (重複無視)

自分で書いてみたい場合

_api/main.py を読まず、 上記の仕様だけで自作してみる:

# 自作のヒント (FastAPI の最小構成)
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware

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

# 例: /users/{id}
USERS = {"1": {"id":1,"name":"山田 太郎"}, ...}

@app.get("/users/{user_id}")
def get_user(user_id: str):
    u = USERS.get(user_id)
    if u is None: raise HTTPException(404, "user not found")
    return u

書けたら配布の _api/main.py と比較して 差分を学習する。

演習のヒント

詰まった時に開いてください

問題を見て自分で考えるのが本筋。 15 分以上手が止まったらここを覗く。

🔵 router/basic ヒント (2 画面分割)

router.js:

import { createRouter, createMemoryHistory } from 'vue-router'
import HomePage from './pages/HomePage.vue'
import ProfilePage from './pages/ProfilePage.vue'

export const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    { path: '/', component: HomePage },
    { path: '/profile', component: ProfilePage },
  ],
})

App.vue:

<RouterLink to="/">ホーム</RouterLink>
<RouterLink to="/profile">プロフィール</RouterLink>
<RouterView />

> テストでは createMemoryHistory、 実ブラウザでは createWebHistory

🔵 router/applied ヒント (3 画面)

basic とほぼ同じ。 ルートを 3 つ書く + RouterLink を 3 つ書く。

routes: [
  { path: '/',        component: HomePage },
  { path: '/about',   component: AboutPage },
  { path: '/contact', component: ContactPage },
]

> 配列に増えるだけ = ルートが増えても書き方は変わらない、 と体感する。

🔵 router/combined ヒント (router.push)

HomePage.vue:

<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function goLogin() { router.push('/login') }
</script>

<template>
  <button @click="goLogin" data-testid="go-login">ログイン画面へ</button>
</template>

LoginPage.vuelogin():

function login() {
  if (name.value === '') return
  router.push('/dashboard')
}

> useRouter() (操作) と useRoute() (読み取り) を混同しない。

🟣 dynamic-route/basic ヒント (useRoute.params)

<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>

<template>
  <p>ユーザー ID: {{ route.params.id }}</p>
</template>

> route.params.id文字列。 数値が必要なら Number(...) で変換。

🟣 dynamic-route/applied ヒント (複数 params)

<template>
  <p>カテゴリ: {{ route.params.category }} / 投稿 ID: {{ route.params.id }}</p>
</template>

> :category:id の両方が route.params から取れる。 ルート定義の : の名前と一致させる。

🟣 dynamic-route/combined ヒント (watch で URL 追随)

import { ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const visited = ref([])

function add(id) {
  if (id) visited.value.push(id)
}

onMounted(() => add(route.params.id))
watch(() => route.params.id, (newId) => add(newId))

> ポイント: watch の第 1 引数は () => route.params.id の getter 形式

> route.params.id を直接渡すと「値を渡しただけ」 になり反応しない。

🟢 computed/basic ヒント (3 つの派生値)

import { ref, computed } from 'vue'

const availableItems = computed(() => items.value.filter(i => i.inStock))

const totalPrice = computed(() =>
  availableItems.value.reduce((sum, i) => sum + i.price, 0)
)

const summary = computed(() =>
  `在庫 ${availableItems.value.length} 件 / 合計 ${totalPrice.value} 円`
)

> computed から computed を参照できる (summaryavailableItemstotalPrice を参照)。

🟢 computed/applied ヒント (多段 computed)

const lineSubtotals = computed(() => cart.value.map(it => it.price * it.qty))
const subtotal = computed(() => lineSubtotals.value.reduce((s, n) => s + n, 0))
const tax = computed(() => Math.round(subtotal.value * taxRate.value))
const total = computed(() => subtotal.value + tax.value)

> 4 段の computed が dependency graph を作る。

> carttaxRate が変わると、 影響範囲だけが自動で再計算される (Vue が計算済キャッシュを使う)。

🟢 computed/combined ヒント (3 軸複合フィルタ)

const filtered = computed(() =>
  books.value.filter(b =>
    (keyword.value === '' || b.title.includes(keyword.value)) &&
    (category.value === 'all' || b.category === category.value) &&
    (b.price <= maxPrice.value)
  )
)

> ポイント: 「条件が指定されていない場合は すべて通す」 を '''all' で表現する。

> (空 || 条件) の左側が true だと右側を見ない (= 短絡評価) ので、 全件返したいときは左側を真にする。

🟡 watch/basic ヒント (ref + localStorage)

import { ref, watch, onMounted } from 'vue'

const keyword = ref('')

watch(keyword, (newVal) => {
  localStorage.setItem('drill_keyword', newVal)
})

onMounted(() => {
  keyword.value = localStorage.getItem('drill_keyword') ?? ''
})

> watch書いた瞬間に発火するわけではない。 keyword の値が変わったときだけ。

> 初回復元は onMounted 側で書く。

🟡 watch/applied ヒント (getter 形式 + 旧値)

watch(() => user.value.name, (newVal, oldVal) => {
  changeLog.value.push(`name: ${oldVal}→${newVal}`)
})

watch(() => user.value.age, (newVal, oldVal) => {
  changeLog.value.push(`age: ${oldVal}→${newVal}`)
})

> ref の中の プロパティ を見たいときは () => ... で getter にくるむ。

> watch(user.value.name, ...) のように直接渡すと「値を渡しただけ」 になり反応しない。

> 第 2 引数 callback は (newVal, oldVal) の 2 引数で 旧値も取れる。 ログ用途で便利。

🟡 watch/combined ヒント (統合)

import { ref, computed, watch, onMounted } from 'vue'

const keyword = ref('')
const history = ref([])

// (1) computed
const keywordLength = computed(() => keyword.value.length)

// (2) watch 1: history に追加 (重複は無視)
watch(keyword, (newVal) => {
  if (newVal !== '' && !history.value.includes(newVal)) {
    history.value.push(newVal)
  }
})

// (3) watch 2: keyword を保存
watch(keyword, (newVal) => {
  localStorage.setItem('drill_kw', newVal)
})

// (4) watch 3: history を保存 (配列なので deep: true)
watch(history, (newVal) => {
  localStorage.setItem('drill_hist', JSON.stringify(newVal))
}, { deep: true })

// (5) 復元
onMounted(() => {
  keyword.value = localStorage.getItem('drill_kw') ?? ''
  const h = localStorage.getItem('drill_hist')
  history.value = h ? JSON.parse(h) : []
})

> ポイント: 配列 ref を watch するには 第 3 引数 { deep: true } が必須。

> でないと「配列そのものへの代入」 (= ref 全体差し替え) しか検知できない。

よくある詰まり

症状原因対処
vitest run が「テスト 0 件」コマンドを 演習/ で実行してないcd 演習/ してから
Cannot find module 'vue-router'npm install 未実行演習/npm install
Router テストで 404 警告path のスペル違いスラッシュを忘れていないか
computed が反応しない関数で書いている (const x = () => ...)computed(() => ...)
watch がトリガーされない (1)watch(keyword.value, ...).value 渡しwatch(keyword, ...) で ref そのもの
watch がトリガーされない (2)watch(user.name, ...)オブジェクトのプロパティ直接渡しwatch(() => user.name, ...) の getter 形式
配列 watch が動かない{ deep: true } 不足第 3 引数 { deep: true } を付ける

それでも詰まったら

  • 各演習フォルダの **answer.*** ファイル (= 完成例) を見る
  • 自分のコードと比較して「何が違うか」 1 行ずつ確認
  • 学習者に声を掛ける (= 「何でも聞いていい」 がチーム開発の基本姿勢)