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/ の両方が見える。
詰まった時の歩き方
- 本編 PDF の同じ STEP を読み直す (見落とし発見)
- DevTools の Console を見る (赤い文字に答えがある)
答え/stepN/を開いて差分を確認 (どこが違うか)- それでも詰まったら 学習者に声を掛ける (一人で 15 分粘ったら声出す目安)
よくあるエラーと対処
| エラー | 原因 | 対処 |
|---|---|---|
Cannot connect to localhost:8000 | FastAPI を起動していない | uvicorn のターミナルを確認 |
Cannot find module 'vue-router' | npm install vue-router 未実行 | frontend/ で npm install vue-router |
[Vue Router warn]: No match found for location | URL とルート定義のミスマッチ | 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 を開く。
タスクが 3 件表示される / 追加 / 編集 / 削除 が動く = 昨日まで作ってきたアプリがそのまま動いている。
STEP1 解説: 配布版に既に入っているもの
「Vue から FastAPI を叩く」 ためには本来いくつか下拵えが必要。 今日は backend/fastapi/topics/fastapi-basic/ + middleware/ch01-cors で習った要素なので配布版に組み込み済:
| 何 | どこに | 何のためか |
|---|---|---|
| CORS Middleware | fastapi/main.py | Vue (5173) と FastAPI (8000) は別オリジン |
.env + VITE_API_BASE | frontend/.env | API 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 📝 進め方 (順序)
事故を避けるため、 この順序で進める:
vue-routerをインストール- ページコンポーネントを 2 つ作る (
HomePage.vue/TaskListPage.vue) router.jsを作るmain.jsでルーターを使う宣言- 最後に
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に変わる - ブラウザの 戻る ボタンで
/に戻れる
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 📝 進め方
router.jsに/tasks/:idルートを追加TaskDetailPage.vueを新規作成 (= 詳細画面 + 編集 / 削除機能)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">← タスク一覧へ戻る</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)
> ⚠️ 一覧画面 (/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 段階で育てる:
- 検索入力欄を追加 (= まだ何も絞られない)
- 空の
computedでfilteredTasksを作る (= 全件返すだけ・動作確認) filterを足して実際に絞り込む- 件数表示 (
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 🌐 動作確認
| 確認 | 期待動作 |
|---|---|
| 「名前で絞り込み」 に "牛" と入れる | 一覧が即座に絞られる |
| 「全 3 件 / 表示中 1 件」 の表示 | キーワードに応じて更新される |
| 検索を空にする | 元の一覧に戻る |
STEP4 解説: ref と computed の .value ルール
<script> 内 | <template> 内 | |
|---|---|---|
ref | keyword.value で読み書き | keyword (.value 不要) |
computed | filteredTasks.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 | 絞り込み一覧、 件数ラベル、 合計金額 |
| 何かを実行 する | watch | localStorage 保存、 API 再取得、 ログ送信 |
> 迷ったときの判定: 「返り値が欲しい」 = computed / 「副作用が欲しい」 = watch。
STEP5 📝 今日 watch を使う 2 か所
| 場所 | 何を監視 | 何の副作用 |
|---|---|---|
(a) TaskListPage.vue | keyword (ref) の変化 | localStorage に保存 |
(b) TaskDetailPage.vue | URL の :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)
🌐 ブラウザで:
- 検索欄に "牛" と入れる → 一覧が絞られる
- ブラウザをリロード
- タスクが再取得され、 検索欄は "牛" のまま / 一覧も絞られたまま
> 💡 リロード時の流れ: 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 / RouterView | STEP2 |
動的ルート / useRoute().params | STEP3 |
computed の役割 | STEP4 |
watch の役割 / computed との違い | STEP5 |
> 詰まった所がそのまま次章 (state-management/ch03-pinia-cross-view) のスタートライン。 「ここがまだ自分の言葉になっていない」 を 1 つは見つけておく。
夕方は演習に進む
「読む」 から「書く」 に切り替える。 演習問題.pdf を開いて、 以下の順番がおすすめ:
| # | 演習 | 本編との関係 |
|---|---|---|
| 1 | Router 01: 2 画面分割 | 本編 STEP2 の追体験 |
| 2 | Router 02: 動的ルート | 本編 STEP3 の追体験 |
| 3 | Composition API 01: computed 練習 | 本編 STEP4 の追体験 |
| 4 | Composition API 02: watch で localStorage | 本編 STEP5 の追体験 |
> 全部やり切る必要はない。 「ここ手を動かしたい」 と思ったところから。
チャレンジ (時間が余ったら)
- ホーム画面に 「最後に開いたタスク」 を表示する (= STEP5 の watch + localStorage の応用)
- 動的ルートを
/tasks/:id/editで編集専用画面にも対応させる watchEffectを使って 依存を自動検出させる (watchとの違いを体感)<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 .map | MDN JS ガイド |
| Web 基礎 | HTTP メソッド (GET/POST) / JSON / DevTools (F12) | MDN HTTP 概要 |
| シェル | cd / ls / Ctrl+C / ターミナル複数枚 | - |
始める前のセルフチェック ② — 前提環境
| ソフト | バージョン | 確認コマンド | 入手 | |
|---|---|---|---|---|
| Node.js | 20.x LTS 以上 | node -v | https://nodejs.org/ | |
| Python | 3.10 以上 | python --version | https://python.org/ | |
| ブラウザ | Chrome / Edge | DevTools (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 ドリル (反復) │ │ ・次の学習へ接続 │
└──────────────────┘ │ ④ 詰まったら自己診断 │ └──────────────────────┘
└────────────────────────┘進捗チェックリスト (再開時の起点)
| 概念 | basic | applied | combined |
|---|---|---|---|
| 🔵 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/:id | URL 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 で失敗した場合の早見
| 症状 | 対処 |
|---|---|
ERESOLVE | npm install --legacy-peer-deps |
EACCES (権限) | sudo は使わず ~/.npm の owner を見直す |
| Node バージョン違い | nvm / mise で 20 LTS に切替 |
| Windows long path | git 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 — ゴール ① ホーム
> ヘッダーの「ホーム」 がアクティブ (下線)、 画面に「ホーム / ようこそ。」。
🔵 router/basic — ゴール ② プロフィール
URL: http://localhost:5173/profile
> ヘッダーの「プロフィール」 をクリック → 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 — ゴール ① ホーム
🔵 router/applied — ゴール ② About
URL: http://localhost:5173/about
🔵 router/applied — ゴール ③ お問い合わせ
URL: http://localhost:5173/contact
🔵 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 — ゴール ① 初期 (ホーム)
> 「ログイン画面へ」 ボタンを置く。 押下で router.push('/login') (= useRouter())。
🔵 router/combined — ゴール ② ボタン押下 → ログイン画面
URL: http://localhost:5173/login
> URL が /login に変わり、 名前入力欄 + 「ログイン」 ボタンが見える。
🔵 router/combined — ゴール ③ 名前空のままログイン
URL: http://localhost:5173/login (変わらず)
> URL は /login のまま (= 何も起きない)。 これが空入力ガードの効果。
🔵 router/combined — ゴール ④ taro でログイン → ダッシュボード
URL: http://localhost:5173/dashboard
> 名前を入れてログイン → 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
🟣 dynamic-route/basic — ゴール ② /users/7
URL: http://localhost:5173/users/7
> URL の :id 部分が変わると表示の数字も変わる = URL からデータを取り出す。
🟣 dynamic-route/basic — やること + 仕上げチェック
やること
- [ ]
useRoute()で route 取得 - [ ]
onMountedでfetch('http://localhost:8001/users/' + route.params.id) - [ ] 成功時:
user.valueに取得結果を入れる (期待形:{id, name}) - [ ] 失敗時 (
!res.okor 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
🟣 dynamic-route/applied — ゴール ② /posts/life/7
URL: http://localhost:5173/posts/life/7
🟣 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
> 初回マウントで onMounted が走り、 visited に 1 が追加される。
🟣 dynamic-route/combined — ゴール ② /items/2 へ遷移
URL: http://localhost:5173/items/2
> ナビの「Item 2」 をクリック → URL が変わるが、 同じコンポーネントが再利用されるので onMounted は走らない。
> watch が route.params.id の変化を捕まえて visited に 2 を追加。
🟣 dynamic-route/combined — ゴール ③ /items/3 へ遷移
URL: http://localhost:5173/items/3
> 同様に 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 に既に書かれている入力データ
| id | name | price | inStock |
|---|---|---|---|
| 1 | りんご | 100 | true |
| 2 | みかん | 80 | false (在庫切れ) |
| 3 | バナナ | 60 | true |
| 4 | メロン | 1500 | true |
🟢 computed/basic — ゴール
> みかんは在庫切れなので一覧に出ない。 合計は 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 — ゴール
> 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 件)
> 何も絞り込んでいない状態 = 全 5 件が表示。
🟢 computed/combined — ゴール ② keyword=Vue (2 件)
> タイトルに「Vue」 を含む 2 件 (Vue 入門 / Vue 中級) に絞られる。
🟢 computed/combined — ゴール ③ 3 軸全部効かせた状態 (1 件)
> 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 — ゴール ① 入力直後
> 入力欄に「保存される」 と入れた状態。 同時に watch が localStorage.setItem を実行。
🟡 watch/basic — ゴール ② F5 リロード後
> リロード後も値が残っている = 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)
> 初期状態 = 履歴は空。
🟡 watch/applied — ゴール ② name=Hanako に変更
> 「name: Taro→Hanako」 が履歴に追加。 watch の callback の 旧値 (oldVal) が活用されている。
🟡 watch/applied — ゴール ③ + age=30 に変更
> 「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 )
| Method | Path | レスポンス |
|---|---|---|
| GET | /history | ["vue", "react", ...] (既存履歴) |
| POST | /history (body: { word: "..." }) | { "history": [...] } (重複は API 側で無視) |
初期値
keyword = ref('')
history = ref([])🟡 watch/combined — ゴール ① 初期 (空)
> 何も入力していない状態。 文字数 0、 履歴も空。
🟡 watch/combined — ゴール ② vue 入力
> keywordLength (computed) が即時 3 を表示。 履歴 (watch) に vue が追加。
🟡 watch/combined — ゴール ③ + react 入力
> 履歴に react も追加。 文字数は react の長さ 5。
🟡 watch/combined — ゴール ④ + vue 再入力 (重複無視)
> 既に 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 別 origin | curl http://localhost:8001/users/42 で API 直叩き確認 |
EADDRINUSE :8001 | port を別プロセスが占有 | 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 | 何か動かない時、 まずここを開く |
| Network | fetch のリクエスト / レスポンス | /users/42 が叩かれているか確認 |
| Application > Local Storage | localStorage の中身 | watch/basic で drill_keyword を確認 |
| Vue DevTools (拡張) | コンポーネント / ref の値 | データが反応していないとき |
詰まったら (順番)
- 症状 → 原因マトリクス (上記) を確認
演習問題・ヒント.pdfを開く (各ドリルの書き始めサンプル)- **
answer.*** ファイル (完成例) を見て差分を考える - 一人で 15 分粘っても進まなければ、 完成例を写経して動きを確認 → 改めて自力で書き直す
完走したら残しておくもの
- 全 12 ドリルの
npx vitest runPASS スクショ - 各ドリルの「目で確認」 スクショ (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)
| Method | Path | レスポンス例 | 用途ドリル |
|---|---|---|---|
| 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 行ずつ確認
- 学習者に声を掛ける (= 「何でも聞いていい」 がチーム開発の基本姿勢)