リアクティビティ — 静的から動的へ
静的から動的へ
ここまでで,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">
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一枚