Progressive Vue

リアクティビティ — 静的から動的へ

静的から動的へ

ここまでで,HTML のテンプレートをデータで駆動できるようになりました.
しかしまだ「動的」とは言えません.ページを開いた瞬間のデータが表示されるだけです.

この章では,ユーザーの操作に応じて 画面がリアルタイムに更新される 仕組みを導入します.

これが Vue のリアクティビティシステムです.

v-on: イベントハンドリング

まず,ユーザーの操作を受け取るために v-on ディレクティブを使います.

<button v-on:click="count++">
  クリック回数: {{ count }}
</button>

省略記法として @ で書けます.

<button @click="count++">
  クリック回数: {{ count }}
</button>

@click — これも HTML の属性です.onclick を書くのと同じ場所です.

<div id="app">
  <button @click="count++">
    クリック回数: {{ count }}
  </button>
</div>

<script type="importmap">
  { "imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" } }
</script>
<script type="module">
  import { createApp, ref } from 'vue'

  createApp({
    setup() {
      const count = ref(0)
      return { count }
    }
  }).mount('#app')
</script>

ボタンをクリックするたびに,表示が自動的に更新されます.
DOM を手動で操作する必要はありません.document.getElementByIdinnerHTML も不要です.

データを変えれば,画面が変わる.
これが リアクティビティ です.

ref() で作ったデータは,値が変わると Vue が自動的に検知して画面を再描画します.
テンプレートの中では .value を付けずにそのまま使えます(setup() の中では count.value ですが,テンプレートでは count だけで OK).

v-model: 双方向バインディング

検索機能を付けてみましょう.v-model を使います.

<input v-model="searchQuery" placeholder="検索..." />

v-model<input> の値とリアクティブなデータを 双方向に 同期します.
入力するたびに searchQuery が更新され,searchQuery を変えれば入力欄も更新されます.

これも HTML の属性位置に書くだけです.<input> タグに属性を一つ足しただけ.

computed(): 算出プロパティ

検索クエリに応じて本をフィルタリングするには,computed() を使います.

import { createApp, ref, computed } from 'vue'

createApp({
  setup() {
    const searchQuery = ref('')
    const books = ref([ /* ... */ ])

    const filteredBooks = computed(() => {
      if (!searchQuery.value) return books.value
      const q = searchQuery.value.toLowerCase()
      return books.value.filter(book =>
        book.title.toLowerCase().includes(q)
      )
    })

    return { searchQuery, books, filteredBooks }
  }
}).mount('#app')

computed()データから導出される値 を定義します.
searchQuerybooks が変わると,filteredBooks も自動的に再計算されます.

テンプレート側では books の代わりに filteredBooks を使うだけです.

<div class="card" v-for="book in filteredBooks">

Bookshelf アプリを動的にする

すべてを組み合わせた完全なコードです.

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>My App</title>
  <style>
    body {
      font-family: sans-serif;
      max-width: 600px;
      margin: 40px auto;
      padding: 0 20px;
    }
    .search {
      width: 100%;
      padding: 8px 12px;
      border: 1px solid #e2e8f0;
      border-radius: 8px;
      font-size: 1em;
      margin-bottom: 16px;
      box-sizing: border-box;
    }
    .card {
      border: 1px solid #e2e8f0;
      border-radius: 8px;
      padding: 20px;
      margin-bottom: 16px;
    }
    .card h2 { margin-top: 0; }
    .tag {
      display: inline-block;
      background: #edf2f7;
      border-radius: 4px;
      padding: 2px 8px;
      font-size: 0.85em;
      margin-right: 4px;
      cursor: pointer;
    }
    .tag:hover { background: #e2e8f0; }
    .tag.active { background: #4299e1; color: white; }
    .filters { margin-bottom: 16px; }
    .no-results {
      text-align: center;
      color: #a0aec0;
      padding: 40px 0;
    }
  </style>
</head>
<body>

  <div id="app">
    <h1>My Bookshelf</h1>

    <input
      class="search"
      v-model="searchQuery"
      placeholder="タイトルで検索..."
    />

    <div class="filters">
      <span
        class="tag"
        :class="{ active: selectedTag === tag }"
        v-for="tag in allTags"
        @click="toggleTag(tag)"
      >
        {{ tag }}
      </span>
    </div>

    <div v-if="filteredBooks.length === 0" class="no-results">
      該当する本が見つかりませんでした
    </div>

    <div class="card" v-for="book in filteredBooks">
      <h2>{{ book.title }}</h2>
      <p>{{ book.description }}</p>
      <span class="tag" v-for="tag in book.tags">
        {{ tag }}
      </span>
    </div>
  </div>

  <script type="importmap">
    {
      "imports": {
        "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
      }
    }
  </script>
  <script type="module">
    import { createApp, ref, computed } from 'vue'

    createApp({
      setup() {
        const searchQuery = ref('')
        const selectedTag = ref(null)
        const books = ref([
          {
            title: 'リーダブルコード',
            description: '読みやすいコードを書くための実践的な技法.',
            tags: ['programming', 'practices']
          },
          {
            title: 'プログラミング言語の基礎概念',
            description: '言語の設計を支える根本的な概念を学ぶ.',
            tags: ['cs', 'language']
          },
          {
            title: 'Web ブラウザセキュリティ',
            description: 'ブラウザに関するセキュリティを体系的に解説.',
            tags: ['security', 'browser']
          }
        ])

        const allTags = computed(() => {
          const tags = new Set()
          for (const book of books.value) {
            for (const tag of book.tags) {
              tags.add(tag)
            }
          }
          return [...tags]
        })

        const filteredBooks = computed(() => {
          return books.value.filter(book => {
            const matchesSearch = !searchQuery.value ||
              book.title.toLowerCase().includes(searchQuery.value.toLowerCase())
            const matchesTag = !selectedTag.value ||
              book.tags.includes(selectedTag.value)
            return matchesSearch && matchesTag
          })
        })

        const toggleTag = (tag) => {
          selectedTag.value = selectedTag.value === tag ? null : tag
        }

        return { searchQuery, selectedTag, books, allTags, filteredBooks, toggleTag }
      }
    }).mount('#app')
  </script>

</body>
</html>

何が起きているか

  1. v-model="searchQuery" — 入力欄とデータが双方向に同期する
  2. @click="toggleTag(tag)" — タグをクリックするとフィルタが切り替わる
  3. computed(() => ...) — 検索クエリとタグに応じて本が自動的にフィルタされる
  4. v-if="filteredBooks.length === 0" — 結果がなければメッセージを表示する
  5. :class="{ active: selectedTag === tag }" — 選択中のタグにスタイルを適用する

DOM を直接触るコードは一行もありません.
データを変えるだけで,Vue が画面を更新します.

テンプレートを見返してみる

テンプレート部分だけを見てみましょう.

<h1>My Bookshelf</h1>
<input class="search" v-model="searchQuery" placeholder="タイトルで検索..." />
<div class="filters">
  <span class="tag" :class="{ active: selectedTag === tag }" v-for="tag in allTags" @click="toggleTag(tag)">
    {{ tag }}
  </span>
</div>
<div v-if="filteredBooks.length === 0" class="no-results">
  該当する本が見つかりませんでした
</div>
<div class="card" v-for="book in filteredBooks">
  <h2>{{ book.title }}</h2>
  <p>{{ book.description }}</p>
  <span class="tag" v-for="tag in book.tags">{{ tag }}</span>
</div>

これは HTML です.
v-model, v-for, v-if, @click, :class — これらは HTML の属性として自然に溶け込んでいます.
テンプレートの構造を読むのに,JavaScript の知識はほとんど不要です.

setup() を振り返る

JavaScript 側も見返してみましょう.

setup() {
  const searchQuery = ref('')
  const selectedTag = ref(null)
  const books = ref([ /* ... */ ])
  const allTags = computed(() => { /* ... */ })
  const filteredBooks = computed(() => { /* ... */ })
  const toggleTag = (tag) => { /* ... */ }

  return { searchQuery, selectedTag, books, allTags, filteredBooks, toggleTag }
}

setup() はただの関数です.
ref() でリアクティブなデータを作り,computed() で導出値を定義し,普通の関数でロジックを書く.
this は登場しません.すべてが明示的な変数と関数です.

まとめ

  • v-on (@) でイベントを処理する — HTML 属性として書く
  • v-model で入力とデータを双方向に同期する
  • ref() でリアクティブなデータを作る
  • computed() でデータから導出される値を自動計算する
  • setup() の中で定義して return するだけ — this は不要
  • データを変えれば画面が変わる — これがリアクティビティ
  • DOM を直接操作するコードは不要
  • テンプレートは動的になっても HTML のまま
  • ファイルは依然として index.html 一枚

関連コンテンツ

本に戻る