静的から動的へ
ここまでで,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.getElementByIdもinnerHTMLも不要です.
データを変えれば,画面が変わる.
これが リアクティビティ です.
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() は データから導出される値 を定義します.
searchQueryやbooksが変わると,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>
何が起きているか
v-model="searchQuery"— 入力欄とデータが双方向に同期する@click="toggleTag(tag)"— タグをクリックするとフィルタが切り替わるcomputed(() => ...)— 検索クエリとタグに応じて本が自動的にフィルタされるv-if="filteredBooks.length === 0"— 結果がなければメッセージを表示する: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一枚