ホーム
← 本棚に戻る

Vue 3 講座ノート(Composition API を基準)

Study Roomの資料は、keysubがAIを活用して作成・編集しています。

1. Vue 3 の準備と開始(Introduction)

展開 / 折りたたみ

1.1 開発環境のセットアップ(Node.js、VS Code、拡張機能)

1.1.1 Node.js

  • Node.js はブラウザーではなくコンピューター(サーバー/ローカル環境)で JavaScript を実行するランタイム(実行環境)です。
  • 開発ツール(Vite / Vue CLI)の実行:Vue プロジェクトを作成し、保存時にコードを再読み込みする開発サーバーの起動に必要です。
  • パッケージ管理(NPM):Vue 本体や、状態管理用 Pinia、ルーティング用 Vue Router などの外部ツールを 1 つのコマンドでダウンロード・管理できます(NPM / Yarn / PNPM)。
  • 最新構文の変換(ビルド/コンパイル):Vue 3 Composition API、TypeScript、.vue ファイルなど、ブラウザーが直接読み込めないコードを通常の HTML、CSS、JavaScript に変換・圧縮します。
  • ただし、完成した Vue の成果物は通常の Web ファイルになるため、本番運用で Node.js は必須ではありません。

1.1.2 Vite を使った Vue プロジェクトの作成

Vite は Vue プロジェクトのフォルダー構成のまま開発サーバーを起動し、開発用・デプロイ用ファイルを生成するツールです。

# 1. Vite ベースの Vue プロジェクト作成コマンドを実行
npm create vue@latest

# 2. プロジェクト設定を選択(矢印キーと Enter を使用)
# ✔ Project name: vue-first-app (任意の名前を入力)
# ✔ Add TypeScript? No (必要に応じて Yes)
# ✔ Add JSX Support? No
# ✔ Add Vue Router for Single Page Application development? Yes (推奨)
# ✔ Add Pinia for state management? Yes (推奨)
# ...以降のオプションはデフォルト(No)を選択

# 3. 生成したプロジェクトフォルダーへ移動
cd vue-first-app
# 4. 必要なライブラリ(依存関係)をインストール
npm install
# 5. ローカル開発サーバーを起動
npm run dev 

1.1.3 プロジェクトの主要フォルダー/ファイル構成

📂 node_modules/: npm install でダウンロードした外部ライブラリを保存します。(編集禁止)
📂 public/:ファビコン(favicon.ico)のように、ビルド(圧縮)処理を経ずにブラウザーへそのまま公開される静的リソースを配置します。
📂 src/: 実際にコードを記述する主要なフォルダーです。
📂 assets/:CSS、画像、フォントなど、コンポーネント内から読み込んで使用するリソースを管理します。
📂 components/: 再利用可能な UI 部品(ボタン、モーダル、ヘッダーなど)の .vue ファイルを格納します。
📂 router/:WebサイトのURLパスに応じて、表示するページを設定します。
📂 stores/:(Pinia 選択時)複数コンポーネントで共有するグローバルデータを管理します。
📂 views/:ホーム画面やマイページなど、完成した画面(ページ)単位のコンポーネントをまとめます。
📄 App.vue:最上位のルートコンポーネントです。他のすべてのコンポーネントがここに組み込まれて表示されます。
📄 main.js(または main.ts):プロジェクトのエントリーポイントです。Vue アプリを作成し、App.vue を読み込んで HTML に接続し、Router や Pinia などを登録します。
📄 index.html:ブラウザーが最初に読む唯一の HTML ファイルです。<div id="app"></div> に Vue コンポーネントが挿入され、画面が更新されます。
📄 vite.config.js:Vite開発ツールの設定ファイルです。プラグインの追加やサーバーポートの変更などを設定します。
📄 package.json:プロジェクト情報、インストール済みライブラリ、dev や build などのコマンドを記載します。

index.html -実行-> main.ts -読み込み-> App.vue -> 子コンポーネント


2. テンプレート構文と主要ディレクティブ(基本)

展開 / 折りたたみ

2.1 データバインディングとテキスト補間

  • Vue.js のデータバインディング(Data Binding)は、JavaScript オブジェクトのデータを HTML 画面(DOM)に結び付けて動的に表示する基本技術です。
  • テキスト補間(Text Interpolation)はデータバインディングの最も基本的な形式で、JavaScript のデータを HTML テキストノードに挿入するときに使います。

2.1.1 テキスト補間

データを画面に表示する最も一般的な方法で、二重中括弧 {{ }}(Mustache 構文とも呼ばれます)を使用します。

  • 動作:Vue インスタンス(コンポーネント)の data オブジェクトのプロパティ名を中括弧内に記述すると、その値が文字列に変換されて画面にレンダリングされます。
  • リアルタイム更新:データが変更されると画面の該当箇所も自動的に再描画されます。
  • 式の使用:単純な変数だけでなく、簡単な JavaScript 演算やメソッド呼び出しも使用できます。
<template>
  <div>
    <!-- 基本的な変数バインディング -->
    <p>こんにちは、{{ name }} さん!</p>

    <!-- JavaScript の演算と三項演算子 -->
    <p>状態:{{ isActive ? '有効' : '無効' }}</p>

    <!-- メソッド呼び出し -->
    <p>計算結果:{{ calculateTotal() }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const name = '開発者キム';
const isActive = ref(true);

const calculateTotal = () => {
  return 1000 + 2000;
};
</script>

2.2 ディレクティブ

ディレクティブは v- で始まる特殊属性で、HTML 要素にリアクティブな動作を付与し、Vue に DOM へ適用する変更を指示します。

2.2.1 制御ディレクティブ

2.2.1.1 条件分岐ディレクティブ(v-if、v-else、v-show)

v-if と v-else は JavaScript の if-else 条件文のように、条件に応じて HTML 要素を描画するかどうかを決めるディレクティブです。

<template>
  <div>
    <p v-if="score >= 90">成績:A</p>
    <p v-else-if="score >= 80">成績:B</p>
    <p v-else-if="score >= 70">成績:C</p>
    <p v-else>成績:F</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const score = ref(85); // 80 以上なので「成績:B」が表示される
</script>
2.2.1.2(参考)複数のタグをまとめて制御する場合(<template>)

複数の HTML 要素を 1 つの条件でまとめて表示・非表示にしたいとき、不要な <div> で囲むとマークアップが煩雑になります。その場合は <template> タグが便利です。

<template>
  <!-- template タグは条件判定にのみ使われ、画面のレンダリング時には跡形なく消えます。 -->
  <template v-if="isLoggedIn">
    <h2>ようこそ!</h2>
    <p>マイページへ</p>
    <button>ログアウト</button>
  </template>

  <template v-else>
    <h2>ログインが必要です。</h2>
    <button>ログインする</button>
  </template>
</template>
2.2.1.3 v-show ディレクティブ
  • v-show は v-if と同様に、条件に応じて要素を表示するかどうかを決めるディレクティブです。
  • 使い方は v-if とほぼ同じですが、動作の仕組みはまったく異なります。
  • 動作:条件の真偽に関係なく HTML タグは常に生成されます。条件が false の場合、Vue が CSS プロパティ display: none; を追加して見えないようにします。
  • <template> タグはレンダリング時に消えるため、v-show と併用するとエラーになります。display: none; を追加できません。
<template>
  <div>
    <!-- ボタンを押すと開閉するモーダル/ポップアップの例 -->
    <button @click="isOpen = !isOpen">ポップアップ切り替え</button>

    <!-- isOpen が false になると display: none スタイルが自動的に付きます。 -->
    <div v-show="isOpen" class="popup">
      <p>こんにちは!重要なお知らせです。</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const isOpen = ref(false); // 初期状態は閉じた状態(非表示)
</script>
2.2.1.4 リストのレンダリングと Key の重要性(v-for ディレクティブ)
  • 基本構文(配列の反復):v-for="item in array" の形式で使います。
2.2.1.4.1 基本的な使い方と :key

v-for を使うときは必ず :key 属性を指定し、重複しない一意の値(ID など)を設定します。

<template>
  <div>
    <h3>今日の ToDo リスト</h3>
    <ul>
      <!-- todos 配列の要素数だけ <li> タグが繰り返し生成されます。 -->
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.text }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const todos = ref([
  { id: 1, text: 'Vue の学習' },
  { id: 2, text: '運動する' },
  { id: 3, text: '買い物に行く' }
]);
</script>
2.2.1.4.2 インデックスの活用
<template>
  <!-- 番号を付けて表示(1. Vue の学習、2. 運動する...) -->
  <p v-for="(todo, index) in todos" :key="todo.id">
    {{ index + 1 }}. {{ todo.text }}
  </p>
</template>
2.2.1.4.3 オブジェクトの反復

v-for は配列だけでなく、オブジェクトのプロパティを反復するときにも使えます。値(value)、キー(key)、インデックス(index)をすべて取得できます。 - 順番は必ず value、key の順です。

<template>
  <ul>
    <!-- 順番に注意:(値、キー、インデックス) -->
    <li v-for="(value, key, index) in myProfile" :key="key">
      [{{ index }}] {{ key }} : {{ value }}
    </li>
  </ul>
</template>

<script setup>
const myProfile = {
  name: 'ホン・ギルドン',
  age: 25,
  job: '開発者'
};
</script>

2.2.2 イベントディレクティブ

2.2.2.1 イベント処理と修飾子(v-on、@)
2.2.2.1.1 基本的なイベントバインディング

イベントタイプをコロン(:)で v-on に結び付けます。通常は短く便利な @ 記号に省略して記述します。

<!-- v-on を使用 -->
<button v-on:click="counter += 1">Add 1</button>

<!-- @ の短縮記法を使用(推奨) -->
<button @click="counter += 1">Add 1</button>
<template>
  <button @click="count++">増加</button>
  <button @click="count--">減少</button>
  <button @click="reset">リセット</button>
  <p>カウント:{{ count }}</p>

  <!-- イベント修飾子 -->
  <form @submit.prevent="onSubmit">
    <input @keyup.enter="onEnter" placeholder="Enter キーを押してください">
    <button type="submit">送信</button>
  </form>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function reset() {
  count.value = 0   // スクリプト内では .value が必要
}
function onSubmit() {
  alert('フォームを送信しました(ページの再読み込みを防止)')
}
function onEnter() {
  alert('Enter キーが押されました')
}
</script>
2.2.2.1.2 メソッドハンドラーのバインディング
<!-- メソッド名だけを指定(event オブジェクトは自動的に渡されます) -->
<button @click="greet">あいさつ</button>

<!-- 引数を渡す -->
<button @click="say('こんにちは')">メッセージを表示</button>

<!-- 引数と元の DOM イベントオブジェクト($event)を同時に渡す -->
<button @click="warn('フォームは送信されませんでした。', $event)">送信</button>
// コンポーネントのスクリプト領域(Options API)
<script setup>
// 通常の関数として定義すると、テンプレートから直接使用できます。

// 1. 引数がない場合:event オブジェクトを自動的に受け取る
const greet = (event) => {
  alert(`こんにちは!クリックされた要素: ${event.target.tagName}`);
};

// 2. 引数を直接受け取る
const say = (message) => {
  alert(message);
};

// 3. 引数と元の DOM イベントオブジェクトを受け取る
const warn = (message, event) => {
  if (event) {
    event.preventDefault(); // デフォルトの動作を防ぐ
  }
  alert(message);
};
</script>
2.2.2.1.3 イベント修飾子

DOM イベント制御をテンプレート内で処理するための修飾子を用意しています。ドット(.)でつないで使用します。

  • .stop: イベントの伝播を停止します (event.stopPropagation()).
  • .prevent: タグのデフォルト動作を防ぎます (event.preventDefault()).
  • .capture: イベントキャプチャモードを使用します.
  • .self: イベントが子要素ではなく、その要素自体で発生した場合のみ実行します。
  • .once: イベントを一度だけ実行します。
  • .passive: スクロール性能を高めるため、デフォルト動作を妨げないことをブラウザーに事前通知します。
<!-- クリックイベントの伝播(バブリング)を停止 -->
<a @click.stop="doThis"></a>

<!-- 送信イベントによるページの再読み込みを防ぐ -->
<form @submit.prevent="onSubmit"></form>

<!-- 修飾子の連結(連続使用) -->
<a @click.stop.prevent="doThat"></a>

<!-- クリックハンドラーを最大 1 回だけ実行 -->
<button @click.once="doSomething">1 回だけ実行</button>
2.2.2.1.4 キー修飾子

キーボードイベントを受け取る際、特定のキーが押された場合のみイベントを発生させます。Vue が提供する主なエイリアスは次のとおりです。

  • .enter: Enter キー
  • .tab: Tab キー
  • .delete: Backspace キーと Delete キー
  • .esc: Escape キー
  • .space: スペースキー
  • .up / .down / .left / .right: 矢印キー
<!-- Input欄でEnterキーを押したときだけsubmitメソッドを呼び出す -->
<input @keyup.enter="submit" placeholder="名前を入力して Enter を押してください" />

<!-- kebab-case を使って実際のキー名を直接指定 -->
<input @keyup.page-down="onPageDown" />
2.2.2.1.5 システム修飾キー

特定の修飾キー(Ctrl、Alt、Shift など)も同時に押されている場合のみ、ハンドラーを実行するよう制限します。

  • .ctrl
  • .alt
  • .shift
  • .meta (Mac の Command キー/Windows キー)
<!-- Alt + Enter を同時に押したときに実行 -->
<input @keyup.alt.enter="clear" />

<!-- Ctrl キーを押しながらクリックしたときに実行 -->
<div @click.ctrl="doSomething">Ctrl クリック領域</div>

.exact 修飾子

システム修飾キーの指定した組み合わせのみを制御したいときに使用します。

<!-- Ctrl のみを押した場合に実行(Alt や Shift も押されている場合は実行しない) -->
<button @click.ctrl.exact="onCtrlClick">A</button>
2.2.2.1.6 マウスボタン修飾子

特定のマウスボタンをクリックした場合のみイベントが発生するよう制限します。

  • .left: マウスの左ボタン
  • .right: マウスの右ボタン
  • .middle: マウスホイール/中央ボタン
<!-- 右クリック時にデフォルトのコンテキストメニューを防ぎ、メソッドを実行 -->
<div @contextmenu.prevent="openCustomMenu">右クリックしてください</div>

<!-- マウスの右ボタンのクリックのみを許可 -->
<button @click.right="onRightClick">右クリック</button>

2.2.3 データバインディングディレクティブ:v-bind

概要

v-bind は Vue コンポーネントのデータを HTML 属性(attribute)に動的にバインドします。JavaScript の値を属性に反映するには、必ず v-bind を使用します。

v-bind:attribute="expression"

省略形としてコロン(:)だけを使うことができ、実務ではほとんどの場合この形を使用します。

:attribute="expression"

例 1. 基本的な属性バインディング(画像 src、リンク href)

<template>
  <img :src="imageUrl" alt="商品画像" width="200">
  <a :href="linkUrl">公式サイトへ</a>
</template>

<script setup>
import { ref } from 'vue'

const imageUrl = ref('https://vuejs.org/images/logo.png')
const linkUrl = ref('https://vuejs.org')
</script>

imageUrl、linkUrl の値が変わると、画面の src、href 属性も自動的に更新されます。

例 2. class バインディング — オブジェクト構文

条件に応じてクラスを切り替えるときに最もよく使われるパターンです。

<template>
  <p :class="{ active: isActive, 'text-danger': hasError }">
    状態に応じてスタイルが変わります。
  </p>
  <button @click="isActive = !isActive">active を切り替え</button>
</template>

<script setup>
import { ref } from 'vue'

const isActive = ref(true)
const hasError = ref(false)
</script>

<style scoped>
.active { color: blue; font-weight: bold; }
.text-danger { color: red; }
</style>
  • オブジェクトの key はクラス名、value は Boolean 式です。
  • isActive が true の場合のみ active クラスが適用されます。
  • <style scoped> を使うと、このスタイルは現在のコンポーネントのみに適用されます。

例 3. class バインディング — 配列構文

<template>
  <p :class="[baseClass, sizeClass]">配列構文の例</p>
</template>

<script setup>
import { ref } from 'vue'

const baseClass = ref('box')
const sizeClass = ref('large')
</script>

結果は <p class="box large"> としてレンダリングされます。配列構文とオブジェクト構文を組み合わせることもできます::class="[baseClass, { active: isActive }]"

例 4. style バインディング

<template>
  <div :style="{ color: textColor, fontSize: fontSize + 'px' }">
    スタイルが動的に適用されるテキストです。
  </div>
</template>

<script setup>
import { ref } from 'vue'

const textColor = ref('green')
const fontSize = ref(24)
</script>

CSS プロパティ名には camelCase(fontSize)と kebab-case 文字列('font-size')のどちらも使用できます。

例 5. 複数の属性を一度にバインド

複数の属性をオブジェクト形式でまとめて管理する場合は、ref より reactive を使うのが自然です。

<template>
  <img v-bind="imgAttrs">
</template>

<script setup>
import { reactive } from 'vue'

const imgAttrs = reactive({
  src: 'photo.jpg',
  alt: '説明画像',
  width: 300,
  height: 200
})
</script>

オブジェクト全体を v-bind に渡すと、各 key が個別の属性として展開されます。

参考:ref と reactive - ref(value):数値、文字列、Boolean などの プリミティブ値に主に使用します。スクリプトでは .value でアクセスします。 - reactive(object):オブジェクト/配列全体をリアクティブに扱うときに使います。.value なしでプロパティに直接アクセスします(imgAttrs.width)。

v-bind は Boolean 属性(disabled、checked など)にも使用できます。式が false、null、undefined の場合、その属性自体はレンダリングされません。 ```vue 送信

import { ref } from 'vue' const isLoading = ref(false) ```


2.2.4 双方向データバインディングディレクティブ:v-model

概要

v-model はフォーム要素(input、textarea、select など)と Vue のデータを 双方向に同期します。 - データが変わると画面(フォーム要素)が更新され、 - ユーザーがフォーム要素を操作すると、データも同時に更新されます。

内部的には :value バインディングと @input イベントリスナーを組み合わせたシンタックスシュガーです。

例 1. テキスト入力(input、textarea)

<template>
  <input v-model="username" placeholder="名前を入力してください">
  <p>入力した名前:{{ username }}</p>

  <textarea v-model="message" placeholder="メッセージ"></textarea>
  <p>プレビュー:{{ message }}</p>
</template>

<script setup>
import { ref } from 'vue'

const username = ref('')
const message = ref('')
</script>

例 2. チェックボックス(単一/複数)

<template>
  <!-- 単一チェックボックス:Boolean 値 -->
  <label><input type="checkbox" v-model="agree"> 利用規約に同意します</label>
  <p>同意状況:{{ agree }}</p>

  <!-- 複数チェックボックス:選択した値は配列に格納される -->
  <p>好きな果物を選択:</p>
  <label><input type="checkbox" value="りんご" v-model="fruits"> りんご</label>
  <label><input type="checkbox" value="バナナ" v-model="fruits"> バナナ</label>
  <label><input type="checkbox" value="ぶどう" v-model="fruits"> ぶどう</label>
  <p>選択した果物:{{ fruits }}</p>
</template>

<script setup>
import { ref } from 'vue'

const agree = ref(false)
const fruits = ref([])
</script>

例 3. ラジオボタン

<template>
  <label><input type="radio" value="男性" v-model="gender"> 男性</label>
  <label><input type="radio" value="女性" v-model="gender"> 女性</label>
  <p>選択した性別:{{ gender }}</p>
</template>

<script setup>
import { ref } from 'vue'

const gender = ref('')
</script>

例 4. セレクトボックス

<template>
  <select v-model="selectedCity">
    <option disabled value="">都市を選択してください</option>
    <option value="seoul">ソウル</option>
    <option value="busan">釜山</option>
    <option value="incheon">仁川</option>
  </select>
  <p>選択した都市:{{ selectedCity }}</p>
</template>

<script setup>
import { ref } from 'vue'

const selectedCity = ref('')
</script>

例 5. v-model 修飾子(Modifiers)

修飾子 説明
.lazy input イベントではなく change イベント時(フォーカスが外れたとき)に同期
.number 入力値を自動的に数値型へ変換
.trim 入力値の前後の空白を自動的に削除
<template>
  <input v-model.lazy="msg" placeholder="フォーカスが外れると反映">
  <input v-model.number="age" type="number" placeholder="年齢">
  <input v-model.trim="nickname" placeholder="ニックネーム">

  <p>msg:{{ msg }} / age 型:{{ typeof age }} / nickname:[{{ nickname }}]</p>
</template>

<script setup>
import { ref } from 'vue'

const msg = ref('')
const age = ref(0)
const nickname = ref('')
</script>

比較まとめ - v-bind:一方向(データ → 画面)。属性、スタイル、クラスなど「表示」だけが必要なとき。 - v-model:双方向(データ ↔ 画面)。ユーザー入力を受け取るとき(フォーム要素専用)。


2.2.5 その他のディレクティブ

よく使うイベント修飾子:.prevent(preventDefault)、.stop(stopPropagation)、.once(一度だけ実行)、.enter、.esc などのキー修飾子

1) v-html — HTML コンテンツのレンダリング

文字列を HTML として解釈し、そのまま挿入します。信頼できない外部入力に使うと XSS 攻撃の危険があるため、安全なデータにのみ使用してください。

<template>
  <div v-html="richContent"></div>
</template>

<script setup>
import { ref } from 'vue'

const richContent = ref('<strong>太字</strong> 表示され <em>斜体</em>も適用されます。')
</script>

2) v-text — テキストコンテンツのレンダリング

{{ }} 構文と同じ結果になりますが、要素の textContent 全体を上書きします。

<template>
  <span v-text="greeting"></span>
  <!-- 上記は以下と同じ -->
  <span>{{ greeting }}</span>
</template>

<script setup>
import { ref } from 'vue'

const greeting = ref('こんにちは!')
</script>

3) v-once — 一度だけレンダリング

以降データが変わっても再レンダリングされません。静的コンテンツのレンダリング性能を最適化するために使います。

<template>
  <p v-once>初期値:{{ count }}</p>
  <p>現在値:{{ count }}</p>
  <button @click="count++">増加</button>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

4) v-pre — コンパイルの省略

Vue のコンパイルをスキップし、Mustache 構文などをそのまま表示します。コード例をそのまま見せるときに便利で、スクリプトのロジックなしでも動作します。

<template>
  <span v-pre>{{ この文はそのまま表示されます }}</span>
</template>

5) v-cloak — コンパイル前のちらつきを防止

Vue の読み込み/コンパイル前に {{ }} 構文が露出する現象(FOUC)を CSS で隠します。

<template>
  <div v-cloak>
    {{ message }}
  </div>
</template>

<script setup>
import { ref } from 'vue'

const message = ref('読み込みが完了しました!')
</script>

<style scoped>
[v-cloak] { display: none; }
</style>

参考: v-cloak は CDN で読み込んだ Vue を実行時にコンパイルする方式(前の CDN 例)で主に意味があります。<script setup> + Vite のように ビルド時にテンプレートが事前コンパイルされる環境では {{ }} が画面に露出することはほぼなく、実務ではあまり使われません。ただし、ディレクティブの概念と存在は知っておくとよいでしょう。

ディレクティブ一覧表

ディレクティブ 目的 省略形
v-bind 属性/クラス/スタイルの一方向バインディング :
v-model フォーム要素の双方向バインディング なし
v-if / v-else-if / v-else 条件付きレンダリング(DOM の生成/削除) なし
v-show 条件付き表示(CSS display) なし
v-for リストの反復レンダリング なし
v-on イベント処理 @
v-html HTML の挿入(XSS に注意) なし
v-text テキストの挿入 なし
v-once 最初の 1 回のみレンダリング なし
v-pre コンパイルを省略(そのまま出力) なし
v-cloak コンパイル前のちらつきを防止 なし

<script setup> の要点

項目 説明
import { ref, reactive } from 'vue' 必要な Composition API 関数を明示的にインポート
ref(value) プリミティブ値/単一値のリアクティブ変数。スクリプトでは .value、テンプレートでは自動アンラップ
reactive(object) オブジェクト/配列全体をリアクティブに管理。.value なしでプロパティに直接アクセス
return 不要 <script setup> で宣言した変数・関数は自動的にテンプレートに公開される
実行方法 Vue SFC Playground (play.vuejs.org) または Vite プロジェクト(npm create vue@latest)

3. Vue 3 の中心となる Composition API

展開 / 折りたたみ

このセクションでは、Vue 3 らしさを支える Composition API の主要概念を 1 つずつ見ていきます。サンプルはすべて <script setup> 構文で記述します。

3.1 リアクティブな状態管理の基礎(ref と reactive)

Composition API でリアクティブデータを作る方法は 2 つあります。

ref — プリミティブ値もオブジェクトも包める万能コンテナー

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++ // スクリプト内では .value でアクセス
}
</script>

<template>
  <!-- テンプレートでは .value なしで自動アンラップ -->
  <button @click="increment">
    クリック回数:{{ count }}
  </button>
</template>

🖥️ 実行結果

[ボタン] クリック回数:0
→ クリックすると
[ボタン] クリック回数:1
[ボタン] クリック回数:2 ...

💡 要点:ref で作成した値はスクリプト内では必ず .value を付けますが、テンプレートでは Vue が自動的にアンラップします。

reactive — オブジェクト専用のリアクティブプロキシ

<script setup>
import { reactive } from 'vue'

const user = reactive({
  name: 'ホン・ギルドン',
  age: 25
})

function birthday() {
  user.age++ // .value なしで直接アクセス
}
</script>

<template>
  <p>{{ user.name }} さんは {{ user.age }} 歳です。</p>
  <button @click="birthday">誕生日を迎える</button>
</template>

🖥️ 実行結果

ホン・ギルドンさんは25歳です。
→ ボタンをクリック
ホン・ギルドンさんは26歳です。

ref と reactive の比較表

項目 ref reactive
包める値 プリミティブ値とオブジェクト オブジェクトのみ(配列、Map などを含む)
スクリプトでのアクセス .value が必要 直接アクセス
テンプレートでのアクセス 自動アンラップ 直接アクセス
再代入 count.value = {} は可能 オブジェクト全体の再代入は不可(リアクティブ性が失われる)
分割代入 .value を維持すれば安全 分割代入するとリアクティブ性を失う(→ toRefs が必要)

⚠️ 注意:const { name, age } = reactive({...}) のように分割代入するとリアクティブ性が失われます。3.6 節の toRefs で解決します。


3.2 派生状態の作成:算出プロパティ(computed)

既存の状態に基づいて 自動的に再計算される 値を作るために使用します。

<script setup>
import { ref, computed } from 'vue'

const price = ref(10000)
const quantity = ref(3)

// price または quantity が変わると total も自動的に再計算される
const total = computed(() => price.value * quantity.value)
</script>

<template>
  <p>単価:{{ price }}</p>
  <p>数量:{{ quantity }}</p>
  <p><strong>合計:{{ total }}</strong></p>
  <button @click="quantity++">数量を追加</button>
</template>

🖥️ 実行結果

単価:10,000
数量:3
合計:30,000
→ ボタンをクリック
数量:4
合計:40,000   ← total が自動的に再計算される!

💡 メソッドではなく computed を使う理由:getTotal() メソッドはレンダリングのたびに実行されますが、computed は 依存する値(price、quantity)が変わったときだけ再計算し、それ以外はキャッシュ値を再利用します。性能面で有利です。


3.3 データ変更の検知:ウォッチャー(watch、watchEffect)

watch — 特定の値を指定して監視

<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')
const searchResult = ref('')

watch(keyword, (newValue, oldValue) => {
  console.log(`検索語が「${oldValue}」から「${newValue}」に変更されました`)
  searchResult.value = `「${newValue}」の検索結果...`
})
</script>

<template>
  <input v-model="keyword" placeholder="検索語を入力">
  <p>{{ searchResult }}</p>
</template>

🖥️ 実行結果(input に「vue」と入力した場合)

コンソール:検索語が「」→「v」に変更
コンソール:検索語が「v」→「vu」に変更
コンソール:検索語が「vu」→「vue」に変更
画面:「vue」の検索結果...

watchEffect — 使用されたリアクティブ値を自動追跡

<script setup>
import { ref, watchEffect } from 'vue'

const width = ref(100)
const height = ref(50)

// コールバック内で使用する width と height を自動追跡して監視
watchEffect(() => {
  console.log(`現在の面積: ${width.value * height.value}`)
})
</script>

<template>
  <input type="number" v-model.number="width">
  <input type="number" v-model.number="height">
</template>

🖥️ 実行結果

コンソール:現在の面積:5000   ← 直ちに 1 回実行(初回実行)
→ width を 200 に変更
コンソール:現在の面積:10000

watch と watchEffect の比較

項目 watch watchEffect
監視対象 明示的に指定 コールバック内で自動追跡
初回実行 既定では実行されない 直ちに 1 回実行
前の値へのアクセス 可能(oldValue) 不可
使用する場面 監視対象が明確なとき 「複数の値」をまとめてリアクティブに扱いたいとき

3.4 コンポーネントのライフサイクルフック

Composition API では Options API のライフサイクルオプションが、on を接頭辞に持つ関数になります。

Options API Composition API 呼び出し時点
beforeCreate / created 不要(setup 自体がこの段階) インスタンスの初期化
beforeMount onBeforeMount DOM のマウント直前
mounted onMounted DOM のマウント直後
beforeUpdate onBeforeUpdate リアクティブデータ変更 → 再レンダリング直前
updated onUpdated 再レンダリング直後
beforeUnmount onBeforeUnmount コンポーネント削除直前
unmounted onUnmounted コンポーネント削除直後
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue'

const seconds = ref(0)
let timer = null

onMounted(() => {
  console.log('✅ マウント完了 - タイマー開始')
  timer = setInterval(() => {
    seconds.value++
  }, 1000)
})

onUpdated(() => {
  console.log('🔄 画面を更新、現在値:', seconds.value)
})

onUnmounted(() => {
  console.log('❌ アンマウント - タイマーをクリア')
  clearInterval(timer)
})
</script>

<template>
  <p>経過時間:{{ seconds }} 秒</p>
</template>

🖥️ 実行結果

コンソール:✅ マウント完了 - タイマー開始
(1 秒後)
コンソール:🔄 画面を更新、現在値:1
画面:経過時間:1 秒
(2 秒後)
コンソール:🔄 画面を更新、現在値:2
画面:経過時間:2 秒
...
(コンポーネントが消えるとき)
コンソール:❌ アンマウント - タイマーをクリア

⚠️ メモリリークに注意:onMounted で開始したタイマーやイベントリスナーは、必ず onUnmounted でクリーンアップしてください。


3.5 <script setup> 構文

<script setup> は Composition API を使うための コンパイル時のシンタックスシュガーです。

従来の setup() 関数方式

<script>
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    function increment() {
      count.value++
    }
    // テンプレートで使うには return が必要!
    return { count, increment }
  }
}
</script>

<script setup> 方式(推奨)

<script setup>
import { ref } from 'vue'

const count = ref(0)
function increment() {
  count.value++
}
// returnは不要!トップレベルの変数・関数は自動的にテンプレートに公開されます
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

🖥️ 実行結果(どちらの方式も同じ)

[ボタン] 0
→ クリック
[ボタン] 1

<script setup> の利点

利点 説明
ボイラープレートが少ない return や components オプションの登録が不要
優れたランタイム性能 コンパイラーがより効率的なコードに変換
優れた型推論 TypeScript との相性が非常によい
コンポーネントの自動登録 インポートしたコンポーネントをすぐテンプレートで使用可能
<script setup>
// import するだけで <MyButton /> を直接使用可能
import MyButton from './MyButton.vue'
</script>

<template>
  <MyButton />
</template>

3.6 リアクティブデータの分割代入(toRefs)

❌ 問題:reactive オブジェクトを分割代入するとリアクティブ性が失われる

<script setup>
import { reactive } from 'vue'

const state = reactive({ name: 'キム・チョルス', age: 30 })

// 分割代入した時点で name と age は通常の値になる
const { name, age } = state

function haveBirthday() {
  state.age++ // state.age は変わるが
}
</script>

<template>
  <!-- age は元データとの接続が切れているため画面が更新されない! -->
  <p>{{ name }} さんは {{ age }} 歳</p>
  <button @click="haveBirthday">誕生日</button>
</template>

🖥️ 実行結果

キム・チョルスさんは 30 歳
→ ボタンをクリック(複数回)
キム・チョルスさんは 30 歳   ← 画面が更新されない!(バグ)

✅ 解決策:toRefs でリアクティブ性を保ったまま分割代入

<script setup>
import { reactive, toRefs } from 'vue'

const state = reactive({ name: 'キム・チョルス', age: 30 })

// toRefs で包むと、各プロパティが元データに接続された ref になる
const { name, age } = toRefs(state)

function haveBirthday() {
  age.value++ // ref なので .value でアクセス
}
</script>

<template>
  <p>{{ name }} さんは {{ age }} 歳</p>
  <button @click="haveBirthday">誕生日</button>
</template>

🖥️ 実行結果

キム・チョルスさんは 30 歳
→ ボタンをクリック
キム・チョルスさんは 31 歳   ← リアクティブ性が正常に維持される!

💡 実践ヒント:toRefs は主に Composable 関数(カスタムフック)から複数の状態値を返すときに使用されます。

<!-- useCounter.js -->
import { reactive, toRefs } from 'vue'

export function useCounter() {
  const state = reactive({ count: 0, doubled: 0 })

  function increment() {
    state.count++
    state.doubled = state.count * 2
  }

  // 分割代入後もリアクティブ性が保たれるよう toRefs で返す
  return { ...toRefs(state), increment }
}
<!-- 使用するコンポーネント -->
<script setup>
import { useCounter } from './useCounter'

const { count, doubled, increment } = useCounter()
</script>

<template>
  <p>{{ count }} / 2 倍: {{ doubled }}</p>
  <button @click="increment">+1</button>
</template>

🖥️ 実行結果

0 / 2 倍: 0
→ クリック
1 / 2 倍: 2
2 / 2 倍: 4

📝 セクション 3 のまとめ

概念 一言まとめ
ref 任意の値を包むリアクティブなコンテナー。.value でアクセス
reactive オブジェクト専用のリアクティブプロキシ。分割代入に注意
computed 依存値に基づきキャッシュされる派生状態
watch 値を指定して監視し、前の値にアクセス可能
watchEffect コールバック内で使用した値を自動追跡し、即時実行
ライフサイクルフック onMounted、onUnmounted などの on 接頭辞関数
<script setup> ボイラープレートのない Composition API のシンタックスシュガー
toRefs reactive オブジェクトを分割代入してもリアクティブ性を維持

次のセクションでは コンポーネント間連携(props、emit、provide/inject、slot)を扱います 🚀


4. コンポーネント間連携

展開 / 折りたたみ

コンポーネントは単独よりも、相互に連携したときに真価を発揮します。 このセクションでは、親子コンポーネントが値やイベントをやり取りする 6 つの方法を、 実務でよく使う画面例と実行結果を通して見ていきます。

  • props — 下に渡すデータ
  • emit — 上に伝えるイベント
  • provide / inject — 階層を飛び越えた受け渡し
  • slot — 内容を挿入する場所

実習ガイド 各サンプルのコードは、実際のプロジェクト(Vite + <script setup>)で書く方法をそのまま示しています。


4.1 コンポーネントとは?— 独立性と Scoped スタイル

コンポーネントはテンプレート、ロジック、スタイルをひとまとめにした再利用可能な単位です。同じコンポーネントを複数回使っても各インスタンスは互いの状態を認識しません。また、<style scoped> を使うとクラス名が同じでもスタイルが互いの領域に影響しません。

Pro tip 実際のプロジェクトでは、コンパイラーが各コンポーネントに固有の data-v-xxxxxx 属性を自動付与し、.box[data-v-xxxxxx] の形で CSS をスコープ化します。

CardA.vue / CardB.vue

<!-- CardA.vue -->
<template>
  <div class="scope-card">
    <p class="box">A コンポーネントの .box</p>
  </div>
</template>
<style scoped>
.box { background: #3ddc97; }  /* A 内でのみ適用 */
</style>

<!-- CardB.vue:クラス名が同じでも競合しない -->
<template>
  <div class="scope-card">
    <p class="box">B コンポーネントの .box</p>
  </div>
</template>
<style scoped>
.box { background: #ffb454; }  /* B 内でのみ適用 */
</style>

📝 実際のブラウザー表示テキストと色

  • CardA.vue の表示
  • テキスト: A コンポーネントの .box
  • 背景色: 緑系 (#3ddc97)
  • CardB.vue の表示
  • テキスト: B コンポーネントの .box
  • 背景色: オレンジ系 (#ffb454)

4.2 Props & Emits — 親から子へのデータ受け渡し

親は props で子に値を渡し、子は emit で親にイベントを通知します。実務でよくあるパターンは、商品カードが情報を受け取って表示し、「カートに追加」のクリックを親に知らせる方法です。

App.vue

<script setup lang="ts">
import { ref } from 'vue'
import ProductCard from './components/ProductCard.vue' 

// 1. 親子で共有する商品データ型を定義
interface ProductType {
  id: number
  title: string
  price: number
  discountRate?: number 
}

// 2. ref のオブジェクトデータを宣言
const product = ref<ProductType>({
  id: 1,
  title: 'ワイヤレスマウス',
  price: 50000,
  discountRate: 10 
})

// 3. 子からのカート追加イベント(Emit)を処理する関数
function handleAddToCart(payload: { id: number; title: string; price: number }) {
  alert(`カートに追加しました!\nID: ${payload.id}\n商品名: ${payload.title}\n価格: ${payload.price}ウォン`)
}
</script>

<template>
  <div class="app-container">
    <h1>🛒 商品詳細ページ(親)</h1>

    <!-- 
      4. 子コンポーネントの呼び出しとデータ/イベントのバインド
      - v-bind(:):親のデータを子の Props に渡す
      - v-on(@):子から送られる 'add-to-cart' イベントを親の handleAddToCart 関数に接続
    -->
    <ProductCard 
      :product="product"
      @add-to-cart="handleAddToCart"
    />
  </div>
</template>

ProductCard.vue

<script setup lang="ts">
// 1. 親から渡される product オブジェクトの型を定義
interface ProductType {
  id: number
  title: string
  price: number
  discountRate?: number
}

// 2. Props を定義:親は 'product' という名前のオブジェクトを渡す
interface Props {
  product: ProductType
}

// 3.1 props を宣言(オブジェクト型なので上記のインターフェースに従う)
//const props = defineProps<Props>()

// 3.2 デフォルト値には defineProps ではなく withDefaults を使う 
const props = withDefaults(defineProps<Props>(), {
  product: () => ({
    id: 0,
    title: '商品情報がありません',
    price: 0,
    discountRate: 0 // 親から product オブジェクト全体が渡されなかった場合に使うデフォルト値
  })
})


// 4. 親に送るイベントを定義
const emit = defineEmits<{
  (e: 'add-to-cart', payload: { id: number; title: string; price: number }): void
}>()

// 5. ボタンクリック時に props.product の値を取り出して親に送信
function addToCart() {
  emit('add-to-cart', { 
    id: props.product.id, 
    title: props.product.title, 
    price: props.product.price 
  })
}
</script>

<template>
  <div class="product-card">
    <!-- 6. テンプレートでは props の product を介して値にアクセス -->
    <p class="pc-title">{{ product.title }}</p>

    <p class="pc-price">
      {{ product.price.toLocaleString('ko-KR') }}ウォン
      <!-- discountRate が存在し、0 より大きい場合のみ表示 -->
      <span v-if="product.discountRate && product.discountRate > 0" class="discount-badge">
        {{ product.discountRate }}% 割引
      </span>
    </p>

    <button class="cart-btn" @click="addToCart">カートに追加</button>
  </div>
</template>

defineProps <script setup lang="ts"> で親コンポーネントから渡されたデータ(Props)を子が受け取るためのコンパイラーマクロ関数です。

withDefaults <script setup lang="ts"> で型を使って props を宣言する場合、defineProps だけではデフォルト値を設定できません。withDefaults() で囲んで指定します。親が prop を渡さない場合、または undefined を渡した場合に適用されます。

defineEmits <script setup lang="ts"> で defineProps と対になるコンパイラーマクロです。「この子コンポーネントは親にこのイベントを送る」と事前に宣言する契約書のようなものです。

e: add-to-cart:e は Event(イベント名)の略です。 payload: { id: number; title: string; price: number }:payload はイベントとともに送信するデータを意味します。

1️⃣ 割引率(discountRate)を渡さない場合(デフォルト 0) - 渡した値: title: "メカニカルキーボード"、price: 150000(discountRate は省略) - ブラウザーに表示されるテキスト: text メカニカルキーボード 150,000ウォン [カートに追加] (割引率が 0 のため、v-if 条件により割引率の表示は画面に出ません。)

2️⃣ 割引率(discountRate)を 10% で渡す場合 - 渡した値: title: "ワイヤレスマウス", price: 50000, discountRate: 10 - ブラウザーに表示されるテキスト: text ワイヤレスマウス 50,000ウォン 10% 割引 [カートに追加]


4.3 v-model — コンポーネントの双方向バインディング

v-model は modelValue prop と update:modelValue emit をまとめるシンタックスシュガーです。名前を指定すれば(v-model:keyword)、1 つのコンポーネントで複数の双方向バインディングを作成できます。

① 単一の v-model — 数量ステッパー

App.vue 親コンポーネントで qty 状態を宣言し、子に v-model="qty" の形で接続します。最大数量(:max="10")も渡します。

<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import QuantityStepper from './components/QuantityStepper.vue' // 子コンポーネントをインポート

// 親が管理する数量データ(初期値 1)
const qty = ref(1)
</script>

<template>
  <div>
    <h1>🛒 カートの数量を選択(親)</h1>

    <!-- 
      インターフェースで定義した仕様に従って v-model と max 属性を安全に渡す
    -->
    <QuantityStepper v-model="qty" :max="10" />

    <!-- 親側にも数量の変更がリアルタイムで反映されることを確認 -->
    <p>現在選択中の合計数量:{{ qty }}</p>
  </div>
</template>

QuantityStepper.vue 親が省略した min と modelValue にはデフォルト値(default: 1)が適用されます。プラス/マイナスボタンをクリックすると、emit で変更値を親に送信します。

<!-- QuantityStepper.vue -->

<script setup>

// 1. 子で使う Props の構造をインターフェースで定義
// オプション属性('?')にして、親が省略できるようにする
interface StepperProps {
  min?: number
  max?: number
}

// 2. 親の v-model と連動する型をジェネリック <number> で宣言し、デフォルト値を設定
const count = defineModel<number>({ default: 1 })

// 3. 定義済みインターフェースを適用し、省略された属性のデフォルト値を withDefaults で補う
const props = withDefaults(defineProps<StepperProps>(), {
  min: 1,
  max: 10
})

// 減少処理(props.minはデフォルト値1が保証されるため、安全に比較できます)
function dec() {
  if (count.value > props.min) {
    count.value--
  }
}

// 増加処理(props.maxはデフォルト値10が保証されるため、安全に比較できます)
function inc() {
  if (count.value < props.max) {
    count.value++
  }
}
</script>

<template>
  <div class="stepper">
    <button @click="dec">−</button>
    <span>{{ count }}</span>
    <button @click="inc">+</button>
  </div>
</template>

v-model 双方向データバインディング(Two-way Data Binding)

defineModel v-model に設定した値を取得するもの

ブラウザーの最終実行結果(テキスト)

初回表示時とボタンをクリックした後にブラウザーに表示されるテキストです。

💡 初回読み込み時(初期状態)

🛒 カートの数量を選択(親)
[−] 1 [+]
現在選択中の合計数量:1

🖱️ [+] ボタンを 3 回クリックした結果

🛒 カートの数量を選択(親)
[−] 4 [+]
現在選択されている合計数量:4

🚫 最大数量(max="10")で [+] ボタンを押し続けた結果

🛒 カートの数量を選択(親)
[−] 10 [+]
現在選択中の合計数量:10

(子コンポーネントの props.modelValue < props.max 条件により、数量は 10 を超えずに固定されます。)

② 名前付き v-model を複数使用 — 検索フィルターバー

App.vue

<script setup>
import { ref } from 'vue'
import SearchFilterBar from './components/SearchFilterBar.vue'

// 親が管理する 2 つの検索フィルター状態
const searchKeyword = ref('')
const searchCategory = ref('すべて')
</script>

<template>
  <div class="app-container">
    <h1>🔍 商品検索フィルター(親)</h1>

    <!-- 
      `v-model:name="variable"` の形式で複数の双方向バインディングを同時に処理します。
    -->
    <SearchFilterBar
      v-model:keyword="searchKeyword"
      v-model:category="searchCategory"
    />

    <!-- リアルタイム同期の結果を確認 -->
    <div class="preview-box">
      <p>現在の検索語: "{{ searchKeyword }}"</p>
      <p>選択したカテゴリー: [{{ searchCategory }}]</p>
    </div>
  </div>
</template>

SearchFilterBar.vue

<script setup>
// 1. 親が v-model: の後に付けた名前と同じ名前で Props を定義
defineProps({
  keyword: { type: String, default: '' },
  category: { type: String, default: 'すべて' }
})

// 2. 親の v-model を更新するため、規定形式の 'update:name' イベントを定義
defineEmits([
  'update:keyword', 
  'update:category'
])
</script>

<template>
  <div class="filter-bar">
    <!-- 
      3. 一方向バインディング(:value)で値を表示し、 
         値が変更されるたび(@input)に、新しい値とともに update:keyword イベントを親へ送信します。
    -->
    <input 
      :value="keyword" 
      @input="$emit('update:keyword', $event.target.value)" 
      type="text"
      placeholder="検索語を入力してください"
    />

    <!-- 
      4. select タグも一方向バインディング(:value)を行い、 
         選択項目が変わるたび(@change)に、新しい値とともに update:category イベントを親へ送ります。
    -->
    <select 
      :value="category" 
      @change="$emit('update:category', $event.target.value)"
    >
      <option value="すべて">すべて</option>
      <option value="電子機器">電子機器</option>
      <option value="書籍">書籍</option>
    </select>
  </div>
</template>

ブラウザーの最終実行結果(テキスト)

💡 初回読み込み時(初期状態)

🔍 商品検索フィルター(親)
[ 検索語を入力してください          ] [ すべて     ▼ ]
現在の検索語: ""
選択したカテゴリー: [すべて]

🖱️ キーボードで「MacBook」と入力し、カテゴリーを「電子機器」に変更した場合

🔍 商品検索フィルター(親)
[ MacBook                        ] [ 電子機器 ▼ ]
現在の検索語: "MacBook"
選択したカテゴリー: [電子機器]

defineModel, defineProps, defineEmits defineModel という新しい構文が登場する前(Vue 3 初期)は、親の v-model を defineProps で受け取って処理していました。


4.4 Provide / Inject — 複数階層下への値の受け渡し

Props は一段ずつ手動で渡す必要があるため、中間コンポーネントが不要な値まで受け取る「prop drilling」が発生します。ダークモードのようにアプリ全体の深い階層で必要な値は、provide/inject で直接渡します。

App.vue → AppHeader → Toolbar → ThemeToggle.vue

<!-- App.vue (最上位、provide を実行) -->
<script setup>
const theme = ref('light')
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>
<template>
  <AppHeader />  <!-- theme を知らないまま通過させる -->
</template>

<!-- ThemeToggle.vue (3 階層下の子孫) -->
<script setup>
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>
<template>
  <button @click="toggleTheme">{{ theme === 'dark' ? 'ダークモード' : 'ライトモード' }}</button>
</template>

1️⃣ 初回表示時(初期状態) - 内部テーマ状態:theme.value = 'light' - ブラウザーに表示されるテキスト: text [ ライトモード ] <-- ボタンに表示されるテキスト

2️⃣ ユーザーがボタンを 1 回クリックした場合 - 動作:ボタンをクリックするとtoggleTheme()関数が実行され、トップレベルのテーマ状態が'dark'に変わります。 - 内部テーマ状態:theme.value = 'dark' - ブラウザーに表示されるテキスト: text [ ダークモード ] <-- ボタンに表示されるテキストが自動的に切り替わる


4.5 Slots — 再利用可能なレイアウトの作成

Slot はコンポーネント内にコンテンツを挿入する場所を作ります。親が何も渡さない場合は フォールバックコンテンツ(デフォルト)が表示されます。v-slot でデータを渡す スコープ付きスロットを使えば、同じコンポーネントでまったく異なる画面を描画できます。

src/components/BaseCard.vue

<!-- BaseCard.vue:カードレイアウトの枠を定義し、親から受け取る HTML を挿入する領域(slot)を指定 -->

<template>
  <div class="base-card">
    <!-- 名前付きヘッダースロット(親が指定しない場合は「デフォルトヘッダー」を表示)-->
    <header>
      <slot name="header">デフォルトヘッダー</slot>
    </header>

    <!-- デフォルトスロット(親が指定しない場合は「内容がありません。」を表示)-->
    <div class="body">
      <slot>内容がありません。</slot>
    </div>

    <!-- 名前付きフッタースロット(親が指定しない場合は[確認]ボタンを表示)-->
    <footer>
      <slot name="footer">
        <button>確認</button>
      </slot>
    </footer>
  </div>
</template>

src/components/DataList.vue

<!-- DataList.vue:配列を受け取り、v-for の反復だけを行う。データの内容を知らないラッパーコンポーネント -->
<script setup>
// 
defineProps({
  items: Array
})
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <!-- slot :item="item":スコープ付きスロット(Scoped Slot): 
        子が持つ item データを :item 属性に入れ、 
        親コンポーネントの v-slot 領域へ逆方向に渡します。
      -->
      <slot :item="item" />
    </li>
  </ul>
</template>

App.vue

<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import BaseCard from './components/BaseCard.vue'
import DataList from './components/DataList.vue'

// 1. スコープ付きスロットのテストデータを定義
const products = ref([
  { id: 101, name: 'メカニカルキーボード', price: 120000 },
  { id: 102, name: 'ワイヤレスゲーミングマウス', price: 75000 }
])

const members = ref([
  { id: 1, name: 'ホン・ギルドン', role: '管理者' },
  { id: 2, name: '李舜臣', role: '一般会員' }
])
</script>

<template>
  <div class="app-container">
    <h1>📦 1. 名前付きスロットとフォールバックコンテンツのテスト</h1>

    <!-- 例 ①:親がヘッダーとフッターを独自に設定するケース -->
    <div class="demo-section">
      <h3>ケース①:すべてのスロットを挿入</h3>
      <BaseCard>
    <!--
      `#header`は、親コンポーネントのHTMLコンテンツを名前付き`header`スロットに挿入するための`v-slot:header`プロパティの短縮記法です。
    * **元の構文:** `<template v-slot:header>`
    * **省略構文:** `<template #header>`  
    -->
        <template #header>
          <strong>📦 配送案内</strong>
        </template>

        <p>本日午後に発送し、明日到着予定です。</p>

        <template #footer>
          <button>閉じる</button>
          <button>詳細を見る</button>
        </template>
      </BaseCard>
    </div>

    <!-- 例 ②:本文だけを指定し、ヘッダー/フッターは子のデフォルトを表示 -->
    <div class="demo-section">
      <h3>ケース ②:一部のスロットを省略(デフォルトを表示)</h3>
      <BaseCard>
        <p>ヘッダーやフッターを指定しないとデフォルトが表示されます。</p>
      </BaseCard>
    </div>

    <hr />

    <h1>👥 2. スコープ付きスロットのテスト(同じコンポーネント、異なる UI)</h1>

    <!-- 例 ③:DataList で商品リストのレイアウトを描画 -->
    <div class="demo-section">
      <h3>ケース ③:商品一覧スタイル</h3>
      <DataList :items="products" v-slot="{ item }">
        {{ item.name }} — {{ item.price.toLocaleString() }}ウォン
      </DataList>
    </div>

    <!-- 例 ④:同じ DataList で異なる会員一覧のレイアウトを描画 -->
    <div class="demo-section">
      <h3>ケース ④:会員一覧スタイル</h3>
      <DataList :items="members" v-slot="{ item }">
        <strong>{{ item.name }}</strong> さん ·  <mark>{{ item.role }}</mark>
      </DataList>
    </div>
  </div>
</template>

4.6 動的コンポーネントと keep-alive

<component :is="..."> は値に応じてその場所に表示するコンポーネントを切り替えます。タブ UI が代表例です。タブを切り替えると前のコンポーネントが破棄され、新しいコンポーネントが作られるため入力値が失われます。<keep-alive> で囲むと状態をメモリに保持できます。

TabsPanel.vue

<script setup>
import { ref, computed } from 'vue'
import ProfileTab from './ProfileTab.vue'
import SettingsTab from './SettingsTab.vue'

// 1. タブデータを定義(コンポーネントオブジェクトを直接マッピング)
const tabs = [
  { key: 'profile', label: 'プロフィールを編集', comp: ProfileTab },
  { key: 'settings', label: '設定', comp: SettingsTab }
]

// 2. 現在アクティブなタブの key を管理
const current = ref('profile')

// 3. 現在の key に対応するコンポーネントを返す算出プロパティ(Computed)
const currentComp = computed(() => {
  return tabs.find(t => t.key === current.value)?.comp
})
</script>

<template>
  <div class="tabs-container">
    <!-- タブナビゲーションボタン -->
    <div class="tabs-nav">
      <button 
        v-for="t in tabs" 
        :key="t.key" 
        :class="{ active: current === t.key }"
        @click="current = t.key"
      >
        {{ t.label }}
      </button>
    </div>

    <!-- タブコンテンツ領域 -->
    <div class="tabs-content">
      <!-- keep-alive で囲むとタブを離れても内部状態が保持される -->
      <keep-alive>
        <component :is="currentComp" />
      </keep-alive>
    </div>
  </div>
</template>

ProfileTab.vue

<script setup>
import { ref } from 'vue'

const name = ref('')
</script>

<template>
  <div class="tab-item">
    <h3>👤 プロフィール編集タブ</h3>
    <p>名前を入力して別のタブへ移動しても値が保持されます。</p>

    <div class="form-group">
      <label for="username">名前:</label>
      <input 
        id="username"
        v-model="name" 
        type="text" 
        placeholder="名前を入力してください" 
      />
    </div>

    <p v-if="name" class="preview">入力した名前: <strong>{{ name }}</strong></p>
  </div>
</template>

SettingsTab.vue

<script setup>
import { ref } from 'vue'

const pushEnabled = ref(false)
const emailEnabled = ref(false)
</script>

<template>
  <div class="tab-item">
    <h3>⚙️ 設定タブ</h3>
    <p>設定を選択してから別のタブをクリックしてください。</p>

    <div class="checkbox-group">
      <label>
        <input v-model="pushEnabled" type="checkbox" />
        プッシュ通知を受け取る
      </label>
    </div>

    <div class="checkbox-group">
      <label>
        <input v-model="emailEnabled" type="checkbox" />
        メールニュースレターを受け取る
      </label>
    </div>

    <div class="status">
      <p>プッシュ通知: {{ pushEnabled ? '🟢 ON' : '🔴 OFF' }}</p>
      <p>メール: {{ emailEnabled ? '🟢 ON' : '🔴 OFF' }}</p>
    </div>
  </div>
</template>

💡 主な技術ポイント(Key Points)

  1. 動的コンポーネント(<component :is="..." />) - Vue が提供する組み込みコンポーネントです。:is 属性にコンポーネントオブジェクトまたは名前をバインドし、状況に合ったコンポーネントを動的に描画します。
  2. 状態の保持(<keep-alive>) - 動的コンポーネントが切り替わると既存コンポーネントは破棄(Unmount)されますが、<keep-alive> で囲むとメモリにキャッシュされます。 - そのためフォームの入力文字やチェックボックスの状態が保持されます。
  3. コンポーネントのバインド方法 - tabs 配列の comp フィールドに文字列ではなくコンポーネントインスタンス(ProfileTab、SettingsTab)を直接割り当てるため、コードが明快になります。

チートシート:実務で何をいつ使うか

状況 方法
親から子に値を一度渡せばよいとき Props
子から親へクリックや完了などのイベント/データを知らせるとき Emits
フォーム要素のように親の値と子の入力をリアルタイムに同期するとき v-model
中間コンポーネントに関係なく、深い階層の子孫まで値を渡すとき Provide / Inject
レイアウト(骨組み)は再利用し、内部コンテンツは利用側で自由に埋めたいとき Slots
タブやウィザードのように、1 か所で条件に応じて画面を切り替えるとき <component :is>
画面を切り替えても入力値やスクロール位置などの状態を保持したいとき <keep-alive>

5. ルーティングと状態管理(Ecosystem)

展開 / 折りたたみ

このセクションでは、ページ遷移を担う Vue Router 4 と、コンポーネント間でデータを効率よく共有する Pinia を学びます。

5.1 Vue Router 4 の基本設定とページ遷移

5.1.1 Vue Router とは

Vue Router は Vue 公式のルーティングライブラリです。SPA(Single Page Application)で ブラウザーを再読み込みせずに、URL に応じて異なるコンポーネントを表示できます。

なし あり
ページ遷移時にサーバーへ新たに要求 → ページ全体を再読み込み JS が URL のみ変更し、コンポーネントだけを切り替え → 高速な遷移

5.1.2 インストール

npm install vue-router@4

5.1.3 パスエイリアス:@ 記号

Vite プロジェクトでは src フォルダーを指すエイリアスとして @ を使うのが一般的です。vite.config.ts では次のように設定されている場合がよくあります。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

こう設定すると、相対パス(../../)の代わりに一貫した絶対パス形式で import できます。

// Before
import Home from '../../views/Home.vue'

// After (@ = src/)
import Home from '@/views/Home.vue'

5.1.4 プロジェクトフォルダー構成の例

src/
 ├─ views/
 │   ├─ Home.vue
 │   ├─ About.vue
 │   └─ UserProfile.vue
 ├─ router/
 │   └─ index.ts
 ├─ App.vue
 └─ main.ts

5.1.5 router/index.ts の作成

主なキーワード:createRouter()、routes、export

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'

const routes = [
  { path: '/', name: 'home', component: Home },
  { path: '/about', name: 'about', component: About },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router
  • createRouter():ルーターインスタンスを作成する関数
  • history:createWebHistory()(すっきりした URL、/about)または createWebHashHistory()(/#/about)を選択
  • routes:パス(path)とコンポーネント(component)を対応付ける配列
  • export default:他のファイル(main.ts)からこのルーターを使えるようにエクスポート

5.1.6 main.ts へのルーター登録

// src/main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/router'

createApp(App).use(router).mount('#app')
<!-- App.vue -->
<template>
  <nav>
    <RouterLink to="/">ホーム</RouterLink>
    <RouterLink to="/about">紹介</RouterLink>
  </nav>

  <RouterView />
</template>
  • RouterLink:<a> タグの代わりに使用し、クリック時にページ全体を再読み込みせずパスだけを変更
  • RouterView:現在のパスに一致するコンポーネントが実際に描画される場所(placeholder)
<!-- App.vue -->
<RouterView v-slot="{ Component }">
  <component 
    :is="Component" 
    :level2Menus="level2Menus"
    :activeLevel2="activeLevel2"
    @onSelect="handleSetActiveLevel2"
  />
</RouterView>
  • v-slot="{ Component }":これは、現在マッチしているコンポーネント(画面オブジェクト)をそのまま描画するのではなく、一時的な名前(変数)Componentに取り出して、内部で直接扱うという意味です。

  • <component :is="Component" ... />:Vue の動的コンポーネントタグです。:is 属性で指定されたコンポーネントを画面に描画します。 -- 先ほど v-slot で受け取った Component を指定しているため、現在の URL に対応する画面がここに描画されます。

実行結果

ブラウザーのアドレスバー: http://localhost:5173/
┌─────────────────────────────┐
│ [ホーム]  [紹介]                 │  ← RouterLink で作成されたナビゲーション
├─────────────────────────────┤
│ Home.vue の内容がここに表示される    │  ← RouterView の位置
└─────────────────────────────┘

「紹介」をクリック → アドレスバーのみ /about に変わり、
              再読み込みなしで About.vue の内容に切り替わる

5.2 動的ルーティングとナビゲーションガード

5.2.1 動的ルーティング — params

URL の一部を変数のように使う場合は :name の形式で定義します。

// router/index.ts
const routes = [
  { path: '/user/:id', name: 'user', component: () => import('@/views/UserProfile.vue') },
]
<!-- UserProfile.vue -->
<script setup lang="ts">
import { useRoute } from 'vue-router'

const route = useRoute()
</script>

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

実行結果

/user/42 にアクセスした場合:
  → 「ユーザー ID:42」を表示

/user/99 にアクセスした場合:
  → 「ユーザー ID:99」を表示(コンポーネントを再利用し、再マウントしない)

💡 実務のヒント:同じコンポーネントを再利用して params だけが変わる場合、watch(() => route.params.id, ...) で変更を検知し、データを再取得します。

5.2.2 children — ネストされたルート

レイアウト内に子ページを配置するときに使用します。

const routes = [
  {
    path: '/settings',
    component: () => import('@/views/SettingsLayout.vue'),
    children: [
      { path: '', component: () => import('@/views/SettingsGeneral.vue') },   // /settings
      { path: 'account', component: () => import('@/views/SettingsAccount.vue') }, // /settings/account
    ],
  },
]
<!-- SettingsLayout.vue -->
<template>
  <h2>設定</h2>
  <RouterView /> <!-- children がここにレンダリングされる -->
</template>

実行結果

/settings          → 「設定」というタイトル + SettingsGeneralの内容
/settings/account   → 「設定」というタイトル + SettingsAccountの内容

5.2.3 redirect — パスのリダイレクト

const routes = [
  { path: '/home', redirect: '/' },
  { path: '/user/:id', redirect: (to) => `/user/${to.params.id}/profile` },
]

/home にアクセスすると / へすぐ移動します。古い URL を維持しながら新しい構成へ案内する際によく使います。

5.2.4 ナビゲーションガード — ログイン処理の例

ガードはルーティングの 前後に特定のロジックを挿入するフックです。未ログインユーザーのページアクセスを防ぐためによく使われます。

// router/index.ts
router.beforeEach((to, from, next) => {
  const isLoggedIn = !!localStorage.getItem('token')

  if (to.meta.requiresAuth && !isLoggedIn) {
    next({ name: 'login' })
  } else {
    next()
  }
})

ルートの meta 属性で認証が必要かどうかを示します。

const routes = [
  { path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
  {
    path: '/mypage',
    name: 'mypage',
    component: () => import('@/views/MyPage.vue'),
    meta: { requiresAuth: true },
  },
]

実行結果

[未ログイン状態]
/mypage への移動を試行
  → beforeEach ガードが isLoggedIn = false を検知
  → /login へ自動リダイレクト

[ログイン済み状態]
/mypage への移動を試行
  → isLoggedIn = true → MyPage.vue を正常にレンダリング

5.2.5 参考:Nuxt

Nuxt は Vue ベースの上位フレームワークで、これまで手作業で設定した router/index.ts を 自動化します。

  • pages/ フォルダーに .vue ファイルを作るだけで、ファイル名に基づいてルートが自動生成されます(pages/about.vue → /about)。
  • [id].vue のような名前は動的ルーティング(params)として自動処理されます。
  • SSR(サーバーサイドレンダリング)/SSG(静的サイト生成)を標準サポート

小規模プロジェクト、学習コストを下げたい場合、または SEO が重要なサービスでは Nuxt の導入を検討します。


5.3 状態管理が必要な理由と Pinia の紹介

5.3.1 Prop Drilling の問題

コンポーネントツリーが深くなると、最上位のデータを末端まで渡すために、中間コンポーネントが不要な props を何度も中継しなければなりません。

App.vue
 └─ Layout.vue        (user をそのまま通すだけ)
     └─ Header.vue     (user をそのまま通すだけ)
         └─ UserMenu.vue  (実際に user が必要な場所)

このような prop drilling が増えると保守が難しくなります。解決にはどこからでもアクセスできる グローバルストア(store)が必要で、Vue のエコシステムでは Pinia を使います。

5.3.2 Pinia と Vuex の比較

項目 Vuex Pinia
Vue 3 公式推奨 ❌(レガシー) ✅(公式推奨)
TypeScriptのサポート 不十分(別途型定義が必要) 非常に優秀(型を自動推論)
Mutation 概要 あり(state の変更には mutation が必須) なし(actions から state を直接変更可能)
モジュール構造 namespaced モジュール(設定が複雑) 各 store が独立(設定が簡単)
コード量 多い(ボイラープレート) 少ない
Devtools サポート あり あり(より詳細)

主な違い:Vuex では state の変更に mutation が必須ですが、Pinia は actions 内で this.state = ... のように直接変更できるため、より簡潔です。

5.3.3 インストールと登録

npm install pinia
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '@/App.vue'
import router from '@/router'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')

5.4 Pinia を使ったグローバル状態管理

5.4.1 Store の定義 — State、Getters、Actions

// src/stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // State:保存するデータ
  state: () => ({
    count: 0,
  }),

  // Getters:state を加工した計算値(computed に類似)
  getters: {
    doubleCount: (state) => state.count * 2,
  },

  // Actions:state を変更するメソッド(同期/非同期どちらも可)
  actions: {
    increment() {
      this.count++
    },
    reset() {
      this.count = 0
    },
  },
})
  • defineStore('counter', {...}):第 1 引数は store 固有の ID(Devtools にこの名前で表示)
  • state:必ず関数で返します(コンポーネントの data() と同じ理由)
  • getters:値は自動キャッシュされ、依存する state が変わったときだけ再計算
  • actions:this.count++ のように state を直接変更可能(Vuex の mutation は不要)

5.4.2 コンポーネントでの使用

<!-- Counter.vue -->
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <p>Count: {{ counter.count }}</p>
  <p>Double: {{ counter.doubleCount }}</p>
  <button @click="counter.increment()">+1</button>
  <button @click="counter.reset()">リセット</button>
</template>

実行結果

初期画面:
  Count: 0
  Double: 0

「+1」ボタンを 3 回クリックした後:
  Count: 3
  Double: 6   ← getter が自動的に再計算

「リセット」ボタンをクリックした後:
  Count: 0
  Double: 0

💡 Header.vue などの他のコンポーネントでも useCounterStore() を呼び出すと 同じデータを共有できます。prop を階層ごとに渡す必要はありません。

5.4.3 実例 — ログインユーザー情報のグローバル管理

前述のナビゲーションガードと組み合わせる、実務でよく使われるパターンです。

// src/stores/auth.ts
import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
}

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null as User | null,
    token: '' as string,
  }),

  getters: {
    isLoggedIn: (state) => !!state.token,
  },

  actions: {
    async login(email: string, password: string) {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })
      const data = await res.json()

      this.user = data.user
      this.token = data.token
      localStorage.setItem('token', data.token)
    },

    logout() {
      this.user = null
      this.token = ''
      localStorage.removeItem('token')
    },
  },
})
<!-- Login.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const email = ref('')
const password = ref('')
const auth = useAuthStore()
const router = useRouter()

async function handleLogin() {
  await auth.login(email.value, password.value)
  router.push('/mypage')
}
</script>

<template>
  <input v-model="email" placeholder="メールアドレス" />
  <input v-model="password" type="password" placeholder="パスワード" />
  <button @click="handleLogin">ログイン</button>
</template>

実行結果

ログインフォームの送信
  → auth.login() を実行 → API 応答の user と token を保存
  → auth.isLoggedIn が true に変化
  → router.push('/mypage') で遷移
  → (5.2.4節のナビゲーションガードがlocalStorageのトークンを確認)
  → MyPage.vue に正常にアクセス

これで Header.vue や MyPage.vue など、どのコンポーネントからでも useAuthStore() を呼び出してログイン状態やユーザー情報を取得できます。

<!-- Header.vue -->
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>

<template>
  <span v-if="auth.isLoggedIn">{{ auth.user?.name }} さん、ようこそ</span>
  <span v-else>ログインが必要です</span>
</template>

まとめ

概念 一言まとめ
createRouter() ルーターインスタンスを作成
RouterView / RouterLink ページ表示領域/移動リンク
params URL に動的に含まれる値(/user/:id)
children レイアウト内のネストされたルート構成
redirect 特定パスへ自動遷移
beforeEach(ナビゲーションガード) ルーティング前にログインなどの条件を確認
Pinia state 保存する元データ
Pinia getters state を加工した計算値
Pinia actions state を変更する処理(同期/非同期)

6. 応用と最適化(Advanced Topics)

展開 / 折りたたみ

6.1 非同期コンポーネントと Suspense の処理

定義 - 非同期コンポーネント(Async Component):defineAsyncComponent() を使って必要な時点で動的に遅延読み込みするコンポーネントです。初期バンドルサイズの削減に役立ちます。 - <Suspense>:子ツリー内の非同期依存関係(非同期コンポーネント、<script setup> 内の top-level await など)がすべて処理されるまで、代替コンテンツ(fallback)を表示する組み込みコンポーネントです。公式ドキュメントではまだ実験的機能です。

サンプルコード

<!-- AsyncComp.vue -->
<script setup>
// top-level await を使用するため、このコンポーネントは「非同期依存」になる
const res = await new Promise((resolve) =>
  setTimeout(() => resolve('サーバーから取得したデータ'), 1500)
)
</script>

<template>
  <p>{{ res }}</p>
</template>
<!-- App.vue -->
<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'))
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncComp />
    </template>
    <template #fallback>
      <p>⏳ 読み込み中...</p>
    </template>
  </Suspense>
</template>

実行結果 1. 1. 初回レンダリング時に ⏳ 読み込み中... がすぐ表示される 2. 2. 1.5 秒後に AsyncComp の非同期処理が完了すると、表示が サーバーから取得したデータ に自動的に切り替わる

参考 - defineAsyncComponent にはオブジェクト形式で loadingComponent、errorComponent、delay、timeout オプションも指定できます。 - エラー処理では <Suspense> の #fallback に加えて、親側で onErrorCaptured を併用することがよくあります。


6.2 再利用可能なロジックの分離:Composable 関数の作成

定義 - Composable:Composition API の関数(ref、computed、watch など)を使って状態を持つロジックをカプセル化し、複数のコンポーネントで再利用できるようにした関数です。 - 慣例として名前は use で始めます(例:useCounter、useMouse、useFetch)。

サンプルコード

// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => (count.value = initialValue)

  return { count, increment, decrement, reset }
}
<!-- CounterView.vue -->
<script setup>
import { useCounter } from './composables/useCounter'

const { count, increment, decrement, reset } = useCounter(10)
</script>

<template>
  <p>現在値:{{ count }}</p>
  <button @click="increment">+1</button>
  <button @click="decrement">-1</button>
  <button @click="reset">リセット</button>
</template>

実行結果 - 初期画面:現在値:10 - +1 をクリック → 11、12 ... と増加 - リセット をクリック → 10 に戻る

利点 - カウンター、マウス座標の追跡、フォーム検証など同じロジックを複数のコンポーネントで 重複なく再利用 - ロジックと UI が分離され、単体テストを作成しやすい - Mixin よりデータの出所が明確で 読みやすい


6.3 DOM 要素への直接アクセス(Template Refs)

定義 - Template Ref:テンプレート内の特定 DOM 要素または子コンポーネントインスタンスに直接アクセスするための参照です。 - ref() で宣言した変数名とテンプレートの ref="name" 属性を一致させると、<script setup> では別途登録せずに自動バインドされます。

サンプルコード

<script setup>
import { ref, onMounted } from 'vue'

const inputRef = ref(null)

onMounted(() => {
  inputRef.value.focus()
})
</script>

<template>
  <input ref="inputRef" placeholder="ページ表示時に自動フォーカス" />
</template>

実行結果 - コンポーネントのマウント直後に input 要素へカーソル(フォーカス)が自動的に移動する - ユーザーがクリックしなくてもすぐ入力できる状態になる

参考 - v-for と併用すると、ref は自動的に 配列として収集されます。

<script setup>
import { ref } from 'vue'
const items = ref(['a', 'b', 'c'])
const itemRefs = ref([])
</script>

<template>
  <li v-for="item in items" :key="item" ref="itemRefs">{{ item }}</li>
</template>
  • 子コンポーネントに ref を付ける場合、<script setup> の内部は既定でカプセル化されています。親がアクセスできるのは defineExpose() で明示的に公開されたプロパティ/メソッドのみです。

6.4 Transition を使ったアニメーション効果

定義 - <Transition>:要素やコンポーネントが画面に挿入/削除されるときにアニメーションを適用する Vue 組み込みコンポーネントです。 - 状態が変化すると 6 つの CSS クラスが順に適用されます: v-enter-from → v-enter-active → v-enter-to, v-leave-from → v-leave-active → v-leave-to - name 属性を指定すると、接頭辞が v- からその名前に変わります(例:name="fade" → fade-enter-active など)。

サンプルコード

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

<template>
  <button @click="show = !show">切り替え</button>

  <Transition name="fade">
    <p v-if="show">こんにちは!フェード効果が適用されます。</p>
  </Transition>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

実行結果 - 切り替え ボタンをクリックすると、テキストはすぐに表示/非表示にならず、0.5 秒かけて opacity が徐々に変化し滑らかにフェードイン/アウトします。

参考 - 複数の要素をリストで扱う場合は <TransitionGroup> を使って各項目に個別のアニメーションを付けられます。 - JavaScript フック(@before-enter、@enter、@leave など)を使うと GSAP などのアニメーションライブラリとも連携できます。


6.5 Axios / Fetch を使った REST API の非同期通信

6.5.1 Fetch は非同期関数

定義 - fetch() はブラウザー組み込み関数で、ネットワークリクエストを送り Promise を返す非同期関数です。 - ライブラリをインストールせずに使えますが、HTTP 4xx/5xx エラーも正常応答(resolve)として処理されるため、response.ok を自分で確認する必要があります。

サンプルコード

<script setup>
import { ref, onMounted } from 'vue'

const todo = ref(null)
const error = ref(null)

onMounted(async () => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
    if (!response.ok) {
      throw new Error(`HTTP エラー: ${response.status}`)
    }
    todo.value = await response.json()
  } catch (err) {
    error.value = err.message
  }
})
</script>

<template>
  <div v-if="error">⚠️ エラー発生: {{ error }}</div>
  <div v-else-if="todo">
    <p>タイトル:{{ todo.title }}</p>
    <p>完了状態:{{ todo.completed }}</p>
  </div>
  <div v-else>読み込み中...</div>
</template>

実行結果

{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

画面表示:

タイトル:delectus aut autem
完了状態:false

6.5.2 Fetch を使いやすくするライブラリ Axios

定義 - Axios は Promise ベースの HTTP クライアントライブラリです。 - Fetch と比較した利点: - 応答データを 自動で JSON に変換(.json() 呼び出し不要) - リクエスト/レスポンスインターセプターをサポート - HTTP エラーコード(4xx/5xx)を 自動的に reject するため、catch で処理可能 - リクエストタイムアウトを簡単に設定可能

インストール

npm install axios

サンプルコード

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const todo = ref(null)
const error = ref(null)

onMounted(async () => {
  try {
    const { data } = await axios.get('https://jsonplaceholder.typicode.com/todos/1')
    todo.value = data
  } catch (err) {
    error.value = err.message
  }
})
</script>

<template>
  <div v-if="error">⚠️ エラー発生: {{ error }}</div>
  <div v-else-if="todo">
    <p>タイトル:{{ todo.title }}</p>
    <p>完了状態:{{ todo.completed }}</p>
  </div>
  <div v-else>読み込み中...</div>
</template>

実行結果 - Fetch の例と 同じデータが表示されますが、response.json() を直接呼び出さず data プロパティからすぐにアクセスできます。

Fetch と Axios の比較

項目 Fetch Axios
インストール 不要(ブラウザー組み込み) npm install axios が必要
JSON 変換 .json() を手動で呼び出す 自動変換
エラー処理 4xx/5xx も resolve → response.ok を手動確認 4xx/5xx は自動 reject → catch で処理
リクエストのキャンセル AbortController を使用 AbortController または CancelToken を使用
インターセプター 非対応 対応(リクエスト/レスポンスを傍受可能)
タイムアウト 自分で実装 timeout オプションで簡単に設定

まとめ

トピック 主な API 使用場面
非同期コンポーネント/Suspense defineAsyncComponent、<Suspense> 初期読み込みやコード分割を最適化したいとき
Composable use* 関数 複数コンポーネントで同じロジックを再利用するとき
Template Refs ref, ref="..." DOM 要素や子コンポーネントに直接アクセスするとき
Transition <Transition>, <TransitionGroup> 要素の挿入/削除時にアニメーションが必要なとき
Fetch / Axios fetch(), axios.get() REST API と非同期通信するとき

7. 実践プロジェクトとデプロイ(Real-world Project)

展開 / 折りたたみ

Vue 3 Composition API(<script setup>)+ Vite を基準に記述しています。

7.1 API 連携と CRUD 機能の実装

定義 - CRUD:Create(作成)、Read(読み取り)、Update(更新)、Delete(削除)の略で、REST API と通信するアプリケーションの基本操作 4 種類を指します。 - 実務では API 呼び出しロジックを Composable(useXXX)に分離し、コンポーネントと通信処理を分けるのが一般的です。

サンプルコード

// composables/useTodos.js
import { ref } from 'vue'
import axios from 'axios'

const API_URL = 'https://jsonplaceholder.typicode.com/todos'

export function useTodos() {
  const todos = ref([])
  const loading = ref(false)
  const error = ref(null)

  // Read
  const fetchTodos = async () => {
    loading.value = true
    try {
      const { data } = await axios.get(`${API_URL}?_limit=5`)
      todos.value = data
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  // Create
  const addTodo = async (title) => {
    const { data } = await axios.post(API_URL, { title, completed: false })
    todos.value.unshift(data) // サーバー応答で受け取った項目をリストの先頭に追加
  }

  // Update
  const updateTodo = async (id, payload) => {
    await axios.put(`${API_URL}/${id}`, payload)
    const idx = todos.value.findIndex((t) => t.id === id)
    if (idx !== -1) todos.value[idx] = { ...todos.value[idx], ...payload }
  }

  // Delete
  const deleteTodo = async (id) => {
    await axios.delete(`${API_URL}/${id}`)
    todos.value = todos.value.filter((t) => t.id !== id)
  }

  return { todos, loading, error, fetchTodos, addTodo, updateTodo, deleteTodo }
}
<!-- TodoApp.vue -->
<script setup>
import { onMounted, ref } from 'vue'
import { useTodos } from './composables/useTodos'

const { todos, loading, fetchTodos, addTodo, updateTodo, deleteTodo } = useTodos()
const newTitle = ref('')

onMounted(fetchTodos)

const handleAdd = () => {
  if (!newTitle.value.trim()) return
  addTodo(newTitle.value)
  newTitle.value = ''
}
</script>

<template>
  <input v-model="newTitle" placeholder="ToDo を入力" @keyup.enter="handleAdd" />
  <button @click="handleAdd">追加</button>

  <p v-if="loading">読み込み中...</p>
  <ul v-else>
    <li v-for="todo in todos" :key="todo.id">
      <input
        type="checkbox"
        :checked="todo.completed"
        @change="updateTodo(todo.id, { completed: !todo.completed })"
      />
      <span :style="{ textDecoration: todo.completed ? 'line-through' : 'none' }">
        {{ todo.title }}
      </span>
      <button @click="deleteTodo(todo.id)">削除</button>
    </li>
  </ul>
</template>

実行結果 - ページ表示時にサーバーから ToDo を 5 件取得して一覧表示 - 入力欄にテキストを入力して 追加 をクリック → 新しい項目がすぐ一覧の先頭に反映 - チェックボックスをクリック → 完了項目に取り消し線を表示 - 削除 をクリック → 項目を画面からすぐに削除

⚠️ JSONPlaceholder はデータを実際に保存しないテスト用 API です。再読み込みするとサーバーデータは元に戻り、画面(クライアント状態)のみ変更が反映されます。


7.2 Vue Devtools を使ったデバッグのヒント

定義 - Vue Devtools:Vue アプリケーションをデバッグするブラウザー拡張機能(Chrome/Edge/Firefox)またはスタンドアロンアプリ - コンポーネントツリー、リアクティブ状態、ルーター、Pinia/Vuex ストア、性能(タイムライン)を視覚的に確認できます。

使用例 1. Components タブ:コンポーネントツリーをクリックし、props、data、computed、setup() の戻り値をリアルタイムに確認 2. 状態を直接編集:Components タブでリアクティブ値をダブルクリックして変更できます(例:count を 999 にして画面への即時反映を確認)。 3. Timeline タブ:クリック、イベント、コンポーネント更新などが時系列で記録され、「なぜ再レンダリングしたか」を追跡できます。 4. Pinia タブ:ストアの状態変化やアクション履歴をタイムトラベル方式で確認/巻き戻しできます。

// main.js - 開発環境でのみ Devtools 関連のヒントを有効にする
//(Vite プロジェクトでは通常、開発モードで自動連携される)
if (import.meta.env.DEV) {
  console.log('Devtools 連携モードで実行中です。')
}

実行結果 - ブラウザーの開発者ツールに Vue タブが追加され、クリックすると現在ページのコンポーネントツリーが表示されます。 - 例:TodoApp コンポーネントを選択すると、右側パネルに todos、loading、newTitle などの現在値が表示されます。

参考 - 本番ビルド(npm run build)では通常 Devtools 連携は無効です。 - Vite プロジェクトで表示されない場合は、npm run dev で開発サーバーが実行中か確認してください。


7.3 プロジェクトの最適化とビルド(Vite Build)

定義 - vite build:開発ソースを圧縮(minify)・バンドルし、本番デプロイ用の静的ファイル(dist フォルダー)を作成するコマンド - 内部で Rollup を使用し、tree-shaking、コード分割(code splitting)、アセットハッシュ(cache busting)を自動処理

サンプルコード

# ビルドを実行
npm run build

# ローカルでビルド結果をプレビュー
npm run preview

ルート単位のコード分割(よく使われる最適化手法):

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', component: () => import('../views/Home.vue') },
  { path: '/about', component: () => import('../views/About.vue') }, // ルートにアクセスしたときだけ読み込む
]

export default createRouter({ history: createWebHistory(), routes })

vite.config.js でチャンク分割を設定:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router'], // ライブラリを別チャンクに分割
        },
      },
    },
  },
})

実行結果

vite v5.2.0 building for production...
✓ 34 modules transformed.
dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/vendor-8f2c1a3d.js    58.12 kB │ gzip: 22.90 kB
dist/assets/index-4f3a2b1c.css     1.42 kB │ gzip:  0.74 kB
dist/assets/index-8d3e9f21.js      6.35 kB │ gzip:  2.71 kB
dist/assets/About-7b1e0a2c.js      0.81 kB │ gzip:  0.45 kB
✓ built in 812ms
  • dist/ フォルダーに静的ファイルが生成されます。
  • About.vue が小さな独立チャンク(About-*.js)に分割され、/about にアクセスしたときだけ読み込まれることを確認できます。

参考 - npm run build -- --report や rollup-plugin-visualizer などでバンドルサイズを可視化し、最適化箇所を探せます。 - 画像などの静的アセットは vite-plugin-imagemin などのプラグインでさらに圧縮できます。


7.4 Vercel / Netlify などを使った無料 Web ホスティングへのデプロイ

定義 - Vercel / Netlify:静的サイトやフロントエンドを無料ホスティングするクラウドプラットフォームです。Git リポジトリ(GitHub など)と連携すると、git push だけでビルドとデプロイ(CI/CD)が自動実行されます。

例:Netlify へのデプロイ

# netlify.toml(プロジェクトルートに作成)
[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
# CLI でデプロイする場合
npm install -g netlify-cli
netlify deploy --prod

例:Vercel へのデプロイ

// vercel.json(SPAルーティング用のリライト設定)
{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
# CLI でデプロイする場合
npm install -g vercel
vercel --prod

実行結果 - GitHub リポジトリを Vercel/Netlify に接続すると、main ブランチへの git push ごとにビルドログが出力され、デプロイが実行されます。 - デプロイ後、https://프로젝트명.vercel.app または https://프로젝트명.netlify.app 形式の URL が自動発行されます。 - 以降はコミットごとに新しい URL(プレビュー)と本番 URL が更新されます。

参考 - Vue Router を history モードで使う場合、再読み込み時の 404 を防ぐため rewrite ルールを設定してください(上記の netlify.toml、vercel.json 例を参照)。 - 環境変数(API キーなど)は各プラットフォームのダッシュボードの Environment Variables で登録します。


7.5 参考:ブラウザーの Local Storage と Session Storage

定義 - Web Storage API の一種で、ブラウザーにキーと値のペアとしてデータを保存します。 - サーバーを使わずクライアント(ブラウザー)のみに保存され、origin 単位で分離されます。

項目 localStorage sessionStorage
保存期間 明示的に削除するまで保持 タブ/ウィンドウを閉じると自動削除
共有範囲 同一 origin の全タブ/ウィンドウで共有 該当タブ(セッション)のみ有効
容量 約 5~10 MB(ブラウザーによる) 約 5~10 MB
API setItem、getItem、removeItem、clear setItem、getItem、removeItem、clear(同じ)
主な用途 ログイン状態、ダークモード設定、カートなど フォームの一時入力、タブ単位のセッションデータなど

7.5.1 localStorage オブジェクト

サンプルコード

<script setup>
import { ref, onMounted } from 'vue'

const username = ref('')

function save() {
  localStorage.setItem('username', username.value)
}

onMounted(() => {
  const saved = localStorage.getItem('username')
  if (saved) username.value = saved
})
</script>

<template>
  <input v-model="username" @change="save" placeholder="名前を入力して再読み込みしてください" />
  <p>保存された値:{{ username }}</p>
</template>

実行結果 - 名前を入力すると、ブラウザーを 再読み込みしたり完全に終了して再起動したりしても値が保持されます。 - 開発者ツール → Application タブ → Local Storage で username キーに保存された値を確認できます。

7.5.2 sessionStorage オブジェクト

サンプルコード

<script setup>
import { ref, onMounted } from 'vue'

const draft = ref('')

function save() {
  sessionStorage.setItem('draft', draft.value)
}

onMounted(() => {
  const saved = sessionStorage.getItem('draft')
  if (saved) draft.value = saved
})
</script>

<template>
  <textarea v-model="draft" @input="save" placeholder="下書き"></textarea>
</template>

実行結果 - 同じタブなら 再読み込みしても値が保持されます(localStorage と同様)。 - ただし タブを閉じて再度開くと値は消えます。これが localStorage との主な違いです。 - 開発者ツール → Application タブ → Session Storage で draft キーを確認できます。


まとめ

トピック 主な概念 使用場面
API 連携/CRUD axios + Composable パターン サーバーデータを作成/参照/更新/削除するとき
Vue Devtools コンポーネント/状態のリアルタイム検査 リアクティブデータフローのデバッグ、性能分析
Vite Build vite build、コード分割 デプロイ前のバンドル最適化
Vercel / Netlify Git 連携 CI/CD デプロイ 静的サイトを無料で素早くデプロイするとき
localStorage / sessionStorage Web Storage API ブラウザーに簡単なデータをローカル保存するとき

7.6 参考:AG Grid

AG Grid の基本構造

<script setup lang="ts">
import { ref } from 'vue';
import { AgGridVue } from 'ag-grid-vue3';

  1. 列の定義
const columnDefs = ref([
  { field: "make", headerName: "メーカー" },
  { field: "model", headerName: "モデル名" },
  { field: "price", headerName: "価格" }
]);

  2. 行データ
const rowData = ref([
  { make: "Toyota", model: "Celica", price: 35000 },
  { make: "Ford", model: "Mondeo", price: 32000 },
  { make: "Porsche", model: "Boxster", price: 72000 }
]);

  3. デフォルト列:Settings
const defaultColDef = {
  flex: 1,
  sortable: true,
  filter: true,
};
<//script>

<template>
  <!-- AG Grid コンポーネントを使用 -->
  <ag-grid-vue
    class="ag-theme-alpine"
    style="height: 400px; width: 600px;"
    :columnDefs="columnDefs"
    :rowData="rowData"
    :defaultColDef="defaultColDef"
  >
  <//ag-grid-vue>
<//template>

7.6.1. グリッドの初期化と制御

GridReadyEvent:グリッド読み込み直後に実行される @grid-ready イベントの型です。 GridApi:グリッドをプログラムから制御(データのフィルター、ソート、選択など)する API オブジェクトの型です。 themeQuartz:AG Grid の最新 Quartz テーマを適用するときに使います。

  1. データ加工と書式設定(Column Definition) 列設定(columnDefs)内でデータの表示方法や編集方法を定義するときに使います。 ValueGetterParams:複数フィールドの結合や計算値の表示に(valueGetter とともに)使用します。 ValueFormatterParams:画面に表示するテキスト形式(例:日付形式、3 桁区切り)を指定するときに使います。 ValueSetterParams:ユーザーがセルを編集したときのデータ保存方法を定義します。

  2. イベントとスタイリング CellValueChangedEvent:セル値が変わったときに発生するイベント型で、DB 保存処理などの接続に便利です。 CellClassParams:条件に応じてセルの CSS クラスを動的に変更する際(cellClassRules)に使います。