テンプレートが大きくなってきた

前章で作ったBookshelfアプリは,すでにそれなりの規模になりました.
テンプレートにはカード,検索バー,タグフィルタが含まれています.

アプリケーションが成長するにつれ,一つのテンプレートにすべてを書き続けるのは辛くなります.
HTMLでも,セクションごとにファイルを分けたくなりますよね.

Vueでは,テンプレートの一部を コンポーネント として切り出すことができます.

コンポーネントとは

コンポーネントは,独自のHTMLタグ を定義するようなものです.

HTMLには <div>, <span>, <input> などの組み込み要素があります.
コンポーネントを使うと,<book-card><search-bar> といった 自分だけの要素 を作れます.

<!-- これが目指す姿 -->
<div id="app">
  <h1>My Bookshelf</h1>
  <search-bar></search-bar>
  <tag-filter></tag-filter>
  <book-card v-for="book in filteredBooks" :book="book"></book-card>
</div>

HTMLのカスタム要素(Web Components)と似た考え方です.
テンプレートの中に <book-card> と書くだけで,カードの中身が展開されます.

コンポーネントを定義する

CDN環境では,app.component() でコンポーネントを登録します.
setup() の中でpropsを受け取り,テンプレートで使う値を返します.

app.component("book-card", {
  props: ["book"],
  setup(props) {
    return { book: props.book };
  },
  template: `
    <div class="card">
      <h2>{{ book.title }}</h2>
      <p>{{ book.description }}</p>
      <span class="tag" v-for="tag in book.tags">
        {{ tag }}
      </span>
    </div>
  `,
});

注目してほしいのはtemplateの中身です.
前章で書いていたカード部分のHTMLを,そのまま 持ってきただけです.

props: 親からデータを受け取る

props: ['book']は,このコンポーネントが外部からbookというデータを受け取ることを宣言しています.
setup(props)で受け取ったpropsリアクティブなので,親のデータが変わればコンポーネントも自動的に更新されます.

<book-card :book="book"></book-card>

:book="book"v-bindです.親のデータを子に渡しています.
これもHTML属性として書いています.

完全なコード

コンポーネントで分割した完全なコードです.

<!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>

      <search-bar :query="searchQuery" @update:query="searchQuery = $event"></search-bar>

      <tag-filter :tags="allTags" :selected="selectedTag" @select="toggleTag"></tag-filter>

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

      <book-card v-for="book in filteredBooks" :book="book"></book-card>
    </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";

      const app = 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,
            allTags,
            filteredBooks,
            toggleTag,
          };
        },
      });

      // 検索バーコンポーネント
      app.component("search-bar", {
        props: ["query"],
        emits: ["update:query"],
        setup(props, { emit }) {
          const onInput = (e) => emit("update:query", e.target.value);
          return { query: props.query, onInput };
        },
        template: `
        <input
          class="search"
          :value="query"
          @input="onInput"
          placeholder="タイトルで検索..."
        />
      `,
      });

      // タグフィルタコンポーネント
      app.component("tag-filter", {
        props: ["tags", "selected"],
        emits: ["select"],
        setup(props, { emit }) {
          const onSelect = (tag) => emit("select", tag);
          return { tags: props.tags, selected: props.selected, onSelect };
        },
        template: `
        <div class="filters">
          <span
            class="tag"
            :class="{ active: selected === tag }"
            v-for="tag in tags"
            @click="onSelect(tag)"
          >
            {{ tag }}
          </span>
        </div>
      `,
      });

      // 本カードコンポーネント
      app.component("book-card", {
        props: ["book"],
        setup(props) {
          return { book: props.book };
        },
        template: `
        <div class="card">
          <h2>{{ book.title }}</h2>
          <p>{{ book.description }}</p>
          <span class="tag" v-for="tag in book.tags">
            {{ tag }}
          </span>
        </div>
      `,
      });

      app.mount("#app");
    </script>
  </body>
</html>

テンプレートを見てみる

ルートのテンプレートはこうなりました.

<h1>My Bookshelf</h1>
<search-bar :query="searchQuery" @update:query="searchQuery = $event"></search-bar>
<tag-filter :tags="allTags" :selected="selectedTag" @select="toggleTag"></tag-filter>
<book-card v-for="book in filteredBooks" :book="book"></book-card>

HTMLのカスタム要素 のように読めます.
アプリの構造が一目で分かります: 検索バーがあって,タグフィルタがあって,本のカードが並ぶ.

各コンポーネントのテンプレートもHTMLです.
コンポーネントの中身を見れば,何がレンダリングされるか一目瞭然です.

emit: 子から親への通信

コンポーネント間のデータの流れは一方向です.

  • 親 → 子: props (:book="book")

  • 子 → 親: emit (@select="toggleTag")

search-barコンポーネントのsetup() を見てみましょう.

setup(props, { emit }) {
  const onInput = (e) => emit('update:query', e.target.value)
  return { query: props.query, onInput }
}

setup() の第二引数からemitを受け取ります.
入力があるたびにupdate:queryイベントを発火し,親がそれを受け取ってsearchQueryを更新します.
HTMLのイベントモデル(addEventListener / dispatchEvent)と同じ考え方です.

まとめ

  • コンポーネントはHTMLのカスタム要素 のようなもの

  • propsで親からデータを受け取る — HTML属性として渡す

  • setup(props, { emit }) でpropsとemitを受け取る

  • emitで子から親にイベントを送る — HTMLのイベントモデルと同じ

  • テンプレートを分割しても,各部分はHTMLのまま

  • アプリの構造がテンプレートから読み取れる

  • ファイルは依然としてindex.html一枚(ただし次の章で…)