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

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.vue は components/ フォルダから コピーして使う (= @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.vue を App.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.id ← useRoute() で取得

> 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. 空の computed で filteredTasks を作る (= 全件返すだけ・動作確認)
  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> を tasks → filteredTasks に書き換え (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))
})

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

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

STEP4 ④ 📝 件数表示も computed で

<script setup> に追加:

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

> computed から computed を参照できる (countLabel が filteredTasks を参照)。

> 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

> 💡 リロード時の流れ: onMounted で keyword.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.vue と TaskDetailPage.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)□
📝 onMounted と watch で同じ関数 (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. watch と watchEffect の違いは?

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/combined は fetch で 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 / /profile → ProfilePage)
  • [ ] 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.vue の login(): 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/

_api の GET 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 取得
  • [ ] onMounted で fetch('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 の category と id から API (GET /posts/:category/:id) で投稿を取得して表示。

配布ファイル + API 仕様

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

_api の GET 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 — やること + 仕上げチェック

やること

  • [ ] onMounted で fetch('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 仕様 ( _api の GET 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 が走り、 visited に 1 が追加される。

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

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

!w:65%

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

> watch が route.params.id の変化を捕まえて visited に 2 を追加。

🟣 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 を参照 = lineSubtotals → subtotal → tax → total の依存連鎖。

> 元 (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=2000 で AND 絞り込み → 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%

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

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

!w:65%

> リロード後も値が残っている = onMounted で localStorage.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)
  • [ ] onMounted で GET /history を fetch → history.value に入れる (try/catch)
  • [ ] keyword 変更で POST /history (body: {word: keyword}) → レスポンスの history を history.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.vue の login():

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 を参照できる (summary が availableItems と totalPrice を参照)。

🟢 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 を作る。

> cart か taxRate が変わると、 影響範囲だけが自動で再計算される (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 行ずつ確認
  • 学習者に声を掛ける (= 「何でも聞いていい」 がチーム開発の基本姿勢)