静的から動的へ

ここまでで,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"></div>

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一枚