Progressive Vue

コンポーネント — HTML を分割する

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

前章で作った 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 一枚(ただし次の章で…)

関連コンテンツ

本に戻る