コンポーネント — 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一枚(ただし次の章で…)