第6章: リストレンダリング & イベント

第6章: リストレンダリング & イベント

概要

本章では、Vue.jsのリストレンダリングとイベント処理について学びます。v-forディレクティブを使用したリストの表示方法、イベントハンドリングの基本、そしてこれらを組み合わせた実践的な例について理解を深めます。

1. リストレンダリング(v-for)

v-forディレクティブを使用すると、配列やオブジェクトに基づいて要素のリストをレンダリングできます。

基本的な配列のレンダリング

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

const fruits = ref(['りんご', 'バナナ', 'オレンジ', 'ぶどう', 'いちご'])
</script>

<template>
  <h3>フルーツリスト</h3>
  <ul>
    <li v-for="fruit in fruits">{{ fruit }}</li>
  </ul>
</template>

インデックス付きの配列レンダリング

<template>
  <h3>インデックス付きフルーツリスト</h3>
  <ul>
    <li v-for="(fruit, index) in fruits">
      {{ index + 1 }}. {{ fruit }}
    </li>
  </ul>
</template>

オブジェクトのレンダリング

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

const user = reactive({
  name: '山田太郎',
  age: 28,
  email: 'taro@example.com',
  location: '東京'
})
</script>

<template>
  <h3>ユーザー情報</h3>
  <ul>
    <li v-for="(value, key) in user">
      {{ key }}: {{ value }}
    </li>
  </ul>
  
  <!-- インデックス付き -->
  <h3>ユーザー情報(インデックス付き)</h3>
  <ul>
    <li v-for="(value, key, index) in user">
      {{ index + 1 }}. {{ key }}: {{ value }}
    </li>
  </ul>
</template>

v-forとv-if

v-forv-ifを同時に使用する場合は注意が必要です。Vue 3では、v-ifは常にv-forよりも優先されます。つまり、v-ifの条件はv-forのスコープ内の変数にアクセスできません。

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

const users = ref([
  { name: '山田太郎', active: true },
  { name: '佐藤花子', active: false },
  { name: '鈴木一郎', active: true }
])
</script>

<template>
  <!-- 非推奨: v-forと同じ要素でv-ifを使用 -->
  <ul>
    <li
      v-for="user in users"
      v-if="user.active"
      :key="user.name"
    >
      {{ user.name }}
    </li>
  </ul>
  
  <!-- 推奨: v-forの親要素またはテンプレートでv-ifを使用 -->
  <ul v-if="users.length">
    <li v-for="user in users" :key="user.name">
      {{ user.name }}
    </li>
  </ul>
  <p v-else>ユーザーがいません</p>
  
  <!-- 推奨: 計算プロパティを使用してフィルタリング -->
  <h3>アクティブなユーザー</h3>
  <ul>
    <li v-for="user in activeUsers" :key="user.name">
      {{ user.name }}
    </li>
  </ul>
</template>

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

// アクティブなユーザーのみをフィルタリングする計算プロパティ
const activeUsers = computed(() => {
  return users.value.filter(user => user.active)
})
</script>

キーの使用

v-forを使用する場合は、各要素に一意のkey属性を指定することが重要です。これにより、Vue.jsはDOMの更新を効率的に行うことができます。

<ul>
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>
</ul>

キーには、各要素を一意に識別できる値(通常はIDやユニークな文字列)を使用します。インデックスをキーとして使用することもできますが、配列の要素が追加、削除、順序変更される場合は問題が生じる可能性があります。

範囲のv-for

v-forは、整数の範囲に対しても使用できます。

<template>
  <h3>1から10までのリスト</h3>
  <ul>
    <li v-for="n in 10" :key="n">{{ n }}</li>
  </ul>
</template>

配列変更の検出

Vue.jsは、配列の変更メソッド(pushpopshiftunshiftsplicesortreverse)を監視し、自動的にビューを更新します。

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

const items = ref(['項目1', '項目2', '項目3'])

function addItem() {
  items.value.push('新しい項目')
}

function removeItem() {
  items.value.pop()
}

function sortItems() {
  items.value.sort()
}
</script>

<template>
  <h3>項目リスト</h3>
  <ul>
    <li v-for="(item, index) in items" :key="index">
      {{ item }}
    </li>
  </ul>
  
  <div class="buttons">
    <button @click="addItem">項目を追加</button>
    <button @click="removeItem">最後の項目を削除</button>
    <button @click="sortItems">項目を並べ替え</button>
  </div>
</template>

2. イベントハンドリング

Vue.jsでは、v-onディレクティブ(または@省略記法)を使用してDOMイベントをリッスンします。

基本的なイベントハンドリング

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

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <h3>カウンター: {{ count }}</h3>
  
  <!-- 完全な構文 -->
  <button v-on:click="increment">増やす (v-on)</button>
  
  <!-- 省略記法 -->
  <button @click="increment">増やす (@)</button>
  
  <!-- インラインステートメント -->
  <button @click="count++">増やす (インライン)</button>
</template>

イベント修飾子

イベントハンドラにはイベント修飾子を使用できます。修飾子を使用すると、イベントの伝播や標準的な挙動を制御できます。

<template>
  <h3>イベント修飾子の例</h3>
  
  <!-- イベントの伝播を停止 -->
  <div @click="outerClick" class="outer">
    外側の要素
    <button @click.stop="innerClick" class="inner">
      内側の要素 (.stop)
    </button>
  </div>
  
  <!-- デフォルトの挙動を防止 -->
  <a href="https://example.com" @click.prevent="linkClick">
    リンク (.prevent)
  </a>
  
  <!-- キャプチャモード -->
  <div @click.capture="captureClick" class="outer">
    外側の要素 (.capture)
    <button @click="innerClick" class="inner">
      内側の要素
    </button>
  </div>
  
  <!-- セルフ -->
  <div @click.self="selfClick" class="outer">
    この要素自体をクリックした場合のみ発火 (.self)
    <button @click="innerClick" class="inner">
      内側の要素
    </button>
  </div>
  
  <!-- 一度だけ -->
  <button @click.once="onceClick">
    一度だけクリック (.once)
  </button>
</template>

<script setup>
function outerClick() {
  alert('外側の要素がクリックされました')
}

function innerClick() {
  alert('内側の要素がクリックされました')
}

function linkClick() {
  alert('リンクがクリックされました')
}

function captureClick() {
  alert('キャプチャフェーズ: 外側の要素がクリックされました')
}

function selfClick() {
  alert('この要素自体がクリックされました')
}

function onceClick() {
  alert('このアラートは一度だけ表示されます')
}
</script>

<style scoped>
.outer {
  padding: 20px;
  background-color: #f0f0f0;
  margin-bottom: 10px;
}

.inner {
  padding: 10px;
  background-color: #e0e0e0;
}
</style>

キー修飾子

キーボードイベントをリッスンする場合、特定のキーに対してイベントハンドラを設定できます。

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

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

<template>
  <h3>キー修飾子の例</h3>
  
  <!-- Enterキーを押したときにフォームを送信 -->
  <input
    v-model="message"
    @keyup.enter="alert('Enterキーが押されました')"
    placeholder="テキストを入力し、Enterキーを押してください"
  >
  
  <!-- エスケープキーを押したときに入力をクリア -->
  <input
    v-model="message"
    @keyup.esc="message = ''"
    placeholder="テキストを入力し、Escキーを押してください"
  >
  
  <!-- 他のキー修飾子の例 -->
  <div class="key-examples">
    <p>以下のボックスにフォーカスを当てて、キーを押してください:</p>
    <input
      @keyup.space="alert('スペースキーが押されました')"
      @keyup.delete="alert('デリートキーが押されました')"
      @keyup.tab="alert('タブキーが押されました')"
      @keyup.up="alert('上矢印キーが押されました')"
      @keyup.down="alert('下矢印キーが押されました')"
      placeholder="スペース、デリート、タブ、上下矢印キーを試してください"
    >
  </div>
</template>

マウス修飾子

マウスイベントに特定の修飾子を適用できます。

<template>
  <h3>マウス修飾子の例</h3>
  
  <div class="mouse-area">
    <!-- 左クリック -->
    <button @click.left="alert('左クリックされました')">
      左クリック
    </button>
    
    <!-- 右クリック -->
    <button @click.right="alert('右クリックされました')">
      右クリック
    </button>
    
    <!-- 中クリック -->
    <button @click.middle="alert('中クリックされました')">
      中クリック
    </button>
  </div>
</template>

<style scoped>
.mouse-area {
  display: flex;
  gap: 10px;
}

button {
  padding: 10px 20px;
  background-color: #f0f0f0;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
</style>

イベント引数の受け取り

イベントハンドラ内では、イベントオブジェクトを受け取ることができます。

<script setup>
function handleClick(event) {
  console.log(event.target)  // クリックされた要素
  console.log(event.currentTarget)  // イベントハンドラがアタッチされた要素
  console.log(event.clientX, event.clientY)  // クリック位置の座標
}

function handleWithParam(message, event) {
  alert(message)
  console.log(event)  // ネイティブイベント
}
</script>

<template>
  <h3>イベント引数の例</h3>
  
  <!-- イベントオブジェクトの取得 -->
  <button @click="handleClick">イベントオブジェクトを取得</button>
  
  <!-- カスタム引数とイベントオブジェクト -->
  <button @click="handleWithParam('こんにちは!', $event)">
    引数とイベントオブジェクトを渡す
  </button>
</template>

3. リストレンダリングとイベントハンドリングの組み合わせ

リストレンダリングとイベントハンドリングを組み合わせると、インタラクティブなリスト操作が可能になります。以下に、簡単なTodoリストアプリケーションの例を示します。

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

// Todoリストの状態
const newTodo = ref('')
const todos = ref([
  { id: 1, text: 'Vue.jsを学ぶ', completed: false },
  { id: 2, text: 'コンポーネントを作成する', completed: false },
  { id: 3, text: '状態管理を理解する', completed: false }
])

// 新しいTodoを追加
function addTodo() {
  if (newTodo.value.trim()) {
    const newId = todos.value.length ? Math.max(...todos.value.map(t => t.id)) + 1 : 1
    todos.value.push({
      id: newId,
      text: newTodo.value.trim(),
      completed: false
    })
    newTodo.value = ''
  }
}

// Todoの完了状態を切り替え
function toggleComplete(todo) {
  todo.completed = !todo.completed
}

// Todoを削除
function removeTodo(id) {
  todos.value = todos.value.filter(todo => todo.id !== id)
}
</script>

<template>
  <div class="todo-app">
    <h2>Todo リスト</h2>
    
    <!-- 新規Todo入力フォーム -->
    <div class="add-todo">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="新しいタスクを入力..."
      >
      <button @click="addTodo">追加</button>
    </div>
    
    <!-- Todoリスト -->
    <ul class="todo-list">
      <li v-for="todo in todos" :key="todo.id" :class="{ completed: todo.completed }">
        <input
          type="checkbox"
          :checked="todo.completed"
          @change="toggleComplete(todo)"
        >
        <span class="todo-text">{{ todo.text }}</span>
        <button class="delete-btn" @click="removeTodo(todo.id)">削除</button>
      </li>
    </ul>
    
    <!-- 統計情報 -->
    <div class="todo-stats" v-if="todos.length">
      <p>
        {{ todos.filter(todo => todo.completed).length }} / {{ todos.length }} 完了
      </p>
    </div>
    
    <!-- リストが空の場合 -->
    <p v-if="!todos.length" class="empty-list">
      タスクがありません。新しいタスクを追加してください。
    </p>
  </div>
</template>

<style scoped>
.todo-app {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.add-todo {
  display: flex;
  margin-bottom: 20px;
}

.add-todo input {
  flex-grow: 1;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px 0 0 4px;
  font-size: 16px;
}

.add-todo button {
  padding: 8px 16px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 0 4px 4px 0;
  cursor: pointer;
  font-size: 16px;
}

.todo-list {
  list-style-type: none;
  padding: 0;
}

.todo-list li {
  display: flex;
  align-items: center;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.todo-list li:last-child {
  border-bottom: none;
}

.todo-text {
  flex-grow: 1;
  margin-left: 10px;
}

.completed .todo-text {
  text-decoration: line-through;
  color: #999;
}

.delete-btn {
  padding: 4px 8px;
  background-color: #ff4c4c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.todo-stats {
  margin-top: 20px;
  color: #666;
  font-size: 14px;
}

.empty-list {
  color: #999;
  text-align: center;
  font-style: italic;
}
</style>

4. フォームと入力処理の実践

ここでは、フォーム入力とリストレンダリング、イベントハンドリングを組み合わせた、より実践的な例を見てみましょう。以下は、シンプルなユーザー管理アプリケーションの例です。

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

// ユーザーフォームの初期状態
const initialUserForm = {
  id: null,
  name: '',
  email: '',
  role: 'user',
  active: true
}

// 状態
const users = ref([
  { id: 1, name: '山田太郎', email: 'taro@example.com', role: 'admin', active: true },
  { id: 2, name: '佐藤花子', email: 'hanako@example.com', role: 'user', active: true },
  { id: 3, name: '鈴木一郎', email: 'ichiro@example.com', role: 'user', active: false }
])
const userForm = reactive({ ...initialUserForm })
const editMode = ref(false)
const searchQuery = ref('')

// 検索結果
const filteredUsers = computed(() => {
  const query = searchQuery.value.toLowerCase()
  if (!query) return users.value
  
  return users.value.filter(user => 
    user.name.toLowerCase().includes(query) ||
    user.email.toLowerCase().includes(query) ||
    user.role.toLowerCase().includes(query)
  )
})

// ユーザーを追加/更新
function saveUser() {
  if (!userForm.name || !userForm.email) return
  
  if (editMode.value) {
    // 既存ユーザーの更新
    const index = users.value.findIndex(user => user.id === userForm.id)
    if (index !== -1) {
      users.value[index] = { ...userForm }
    }
  } else {
    // 新規ユーザーの追加
    const newId = users.value.length ? Math.max(...users.value.map(u => u.id)) + 1 : 1
    users.value.push({
      ...userForm,
      id: newId
    })
  }
  
  // フォームをリセット
  resetForm()
}

// ユーザーを編集モードにする
function editUser(user) {
  Object.assign(userForm, user)
  editMode.value = true
}

// ユーザーを削除
function deleteUser(id) {
  if (confirm('このユーザーを削除してもよろしいですか?')) {
    users.value = users.value.filter(user => user.id !== id)
  }
}

// フォームをリセット
function resetForm() {
  Object.assign(userForm, initialUserForm)
  editMode.value = false
}
</script>

<template>
  <div class="user-management">
    <h2>{{ editMode ? 'ユーザー編集' : 'ユーザー追加' }}</h2>
    
    <!-- ユーザーフォーム -->
    <form @submit.prevent="saveUser" class="user-form">
      <div class="form-group">
        <label for="name">名前</label>
        <input
          id="name"
          v-model="userForm.name"
          type="text"
          required
          placeholder="名前を入力"
        >
      </div>
      
      <div class="form-group">
        <label for="email">メールアドレス</label>
        <input
          id="email"
          v-model="userForm.email"
          type="email"
          required
          placeholder="メールアドレスを入力"
        >
      </div>
      
      <div class="form-group">
        <label for="role">役割</label>
        <select id="role" v-model="userForm.role">
          <option value="user">一般ユーザー</option>
          <option value="admin">管理者</option>
          <option value="editor">編集者</option>
        </select>
      </div>
      
      <div class="form-group">
        <label class="checkbox-label">
          <input type="checkbox" v-model="userForm.active">
          アクティブ
        </label>
      </div>
      
      <div class="form-actions">
        <button type="submit" class="save-btn">{{ editMode ? '更新' : '追加' }}</button>
        <button type="button" class="cancel-btn" @click="resetForm">キャンセル</button>
      </div>
    </form>
    
    <h2>ユーザー一覧</h2>
    
    <!-- 検索フィールド -->
    <div class="search-box">
      <input
        v-model="searchQuery"
        placeholder="ユーザーを検索..."
        type="text"
      >
    </div>
    
    <!-- ユーザーテーブル -->
    <table class="user-table" v-if="filteredUsers.length">
      <thead>
        <tr>
          <th>ID</th>
          <th>名前</th>
          <th>メールアドレス</th>
          <th>役割</th>
          <th>状態</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in filteredUsers" :key="user.id" :class="{ inactive: !user.active }">
          <td>{{ user.id }}</td>
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
          <td>{{ user.role === 'admin' ? '管理者' : user.role === 'editor' ? '編集者' : '一般ユーザー' }}</td>
          <td>
            <span :class="user.active ? 'status-active' : 'status-inactive'">
              {{ user.active ? 'アクティブ' : '非アクティブ' }}
            </span>
          </td>
          <td class="actions">
            <button class="edit-btn" @click="editUser(user)">編集</button>
            <button class="delete-btn" @click="deleteUser(user.id)">削除</button>
          </td>
        </tr>
      </tbody>
    </table>
    
    <!-- ユーザーがいない場合 -->
    <p v-else class="no-users">
      {{ searchQuery ? '検索結果がありません。' : 'ユーザーはまだ登録されていません。' }}
    </p>
  </div>
</template>

<style scoped>
.user-management {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.user-form {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 30px;
  background-color: #f9f9f9;
}

.form-group {
  margin-bottom: 15px;
}

.form-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

.checkbox-label {
  display: flex;
  align-items: center;
  font-weight: normal;
}

.checkbox-label input {
  margin-right: 8px;
}

input[type="text"],
input[type="email"],
select {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.form-actions {
  display: flex;
  gap: 10px;
  margin-top: 20px;
}

.save-btn {
  padding: 8px 16px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.cancel-btn {
  padding: 8px 16px;
  background-color: #ccc;
  color: #333;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.search-box {
  margin-bottom: 20px;
}

.search-box input {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.user-table {
  width: 100%;
  border-collapse: collapse;
}

.user-table th,
.user-table td {
  border: 1px solid #ddd;
  padding: 10px;
  text-align: left;
}

.user-table th {
  background-color: #f2f2f2;
}

.user-table tr:nth-child(even) {
  background-color: #f9f9f9;
}

.inactive {
  background-color: #f8f8f8;
  color: #999;
}

.status-active {
  color: #42b983;
  font-weight: bold;
}

.status-inactive {
  color: #ff4c4c;
}

.actions {
  display: flex;
  gap: 5px;
}

.edit-btn {
  padding: 4px 8px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.delete-btn {
  padding: 4px 8px;
  background-color: #ff4c4c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.no-users {
  text-align: center;
  color: #999;
  font-style: italic;
}
</style>

5. コンポーネント間のイベント通信

Vueコンポーネントを使用する場合、親コンポーネントから子コンポーネントへはpropsを通じてデータを渡し、子コンポーネントから親コンポーネントへはイベントを通じてデータを渡します。ここでは簡単な例を示します。

子コンポーネント(ChildComponent.vue)

<script setup>
import { defineProps, defineEmits } from 'vue'

// プロパティの定義
const props = defineProps({
  item: {
    type: Object,
    required: true
  }
})

// イベットの定義
const emit = defineEmits(['update', 'delete'])

// 親コンポーネントにイベントを送信
function updateItem() {
  emit('update', props.item)
}

function deleteItem() {
  emit('delete', props.item.id)
}
</script>

<template>
  <div class="item-card">
    <h3>{{ item.name }}</h3>
    <p>{{ item.description }}</p>
    <div class="item-actions">
      <button @click="updateItem">更新</button>
      <button @click="deleteItem">削除</button>
    </div>
  </div>
</template>

<style scoped>
.item-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 15px;
  margin-bottom: 15px;
}

.item-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 10px;
}

button {
  padding: 5px 10px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:first-child {
  background-color: #42b983;
  color: white;
}

button:last-child {
  background-color: #ff4c4c;
  color: white;
}
</style>

親コンポーネント(ParentComponent.vue)

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

// アイテムリスト
const items = ref([
  {
    id: 1,
    name: '項目1',
    description: 'これは最初の項目です。'
  },
  {
    id: 2,
    name: '項目2',
    description: 'これは2番目の項目です。'
  }
])

// 子コンポーネントからのイベント処理
function handleUpdate(item) {
  console.log('アイテムを更新:', item)
  // ここで更新処理を実装
}

function handleDelete(id) {
  console.log('アイテムを削除:', id)
  items.value = items.value.filter(item => item.id !== id)
}
</script>

<template>
  <div class="parent-component">
    <h2>親コンポーネント</h2>
    
    <div v-if="items.length">
      <!-- 子コンポーネントをループでレンダリング -->
      <ChildComponent
        v-for="item in items"
        :key="item.id"
        :item="item"
        @update="handleUpdate"
        @delete="handleDelete"
      />
    </div>
    
    <p v-else>アイテムがありません。</p>
  </div>
</template>

<style scoped>
.parent-component {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}
</style>

6. イベントと状態管理のベストプラクティス

Vueアプリケーションでイベントと状態を管理する際のベストプラクティスをいくつか紹介します。

7. まとめ

本章では、Vue.jsのリストレンダリングとイベント処理について学びました。v-forディレクティブを使用したリストのレンダリング方法、v-on@)ディレクティブを使用したイベントハンドリングの基本、そしてこれらを組み合わせた実践的な例について理解を深めました。

Todoリストアプリケーションやユーザー管理アプリケーションの例を通じて、リストレンダリングとイベントハンドリングの連携方法を学びました。また、コンポーネント間のイベント通信についても理解しました。

次の章では、Vueのコンポーネントシステムについてより詳しく学び、単一ファイルコンポーネント(SFC)、props、emitsなどについて理解を深めます。

目次に戻る