テンプレートが大きくなってきた
前章で作った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一枚(ただし次の章で…)