メインコンテンツまでスキップ

テンプレート構文とディレクティブ — 状態を画面へ写す

<template> に書けるのは HTML そのままではありません。値を差し込む記法と、要素の属性や表示を制御するディレクティブが足されています。どれも「状態を画面へ写す」ための道具で、命令的に DOM を触る代わりに宣言として書けるようにするものです。

この章では、その記法を目的別に整理します。とくに v-forkey は、指定を間違えても表示は正しく見えるのに操作が壊れるという性質があるので、何のための指定なのかを掘ります。

この章で学ぶこと

  • 値の埋め込みと属性のバインディングを目的に応じて書き分けられる
  • Vue が自動でエスケープする範囲と、そこから外れる経路を知って使える
  • v-ifv-show をコストの違いから選べる
  • key が何をしているかを説明でき、インデックスを渡してはいけない理由が分かる

値を画面に出す

テキストとして出すならマスタッシュ構文、属性に渡すなら v-bind です。

components/LinkCard.vue
<script setup lang="ts">
const url = 'https://vuejs.org'
const label = '公式サイト'
const isExternal = true
</script>

<template>
<!-- テキストの位置には二重波括弧で差し込む -->
<p>{{ label }}</p>

<!-- 属性の位置には v-bind を使う -->
<a v-bind:href="url">{{ label }}</a>

<!-- コロンだけの省略記法。実務ではこちらを使う -->
<a :href="url" :target="isExternal ? '_blank' : undefined">{{ label }}</a>
</template>

属性の位置にマスタッシュ構文は書けません。逆にテキストの位置に v-bind は書けません。差し込む場所で記法が変わるという切り分けです。

中に書けるのは式です。文は書けないので、iffor はそのまま置けません。三項演算子や関数呼び出しは使えます。

属性名そのものを動的にする

角括弧で囲むと、属性名を式で決められます。

<template>
<!-- attributeName の値が属性名になる -->
<a :[attributeName]="url">リンク</a>

<!-- イベント名も同じように書ける -->
<button @[eventName]="handle">実行</button>
</template>

制約が 3 つあります。1 つ目は、式は文字列か null に評価される必要がある点です。null を渡すとバインディングそのものが外れ、それ以外の非文字列は警告になります。2 つ目は、角括弧の中に空白を書けない点です。:[ 'data-' + name ] はコンパイルエラーになるので、組み立てが必要なら computed に寄せます。3 つ目は、.vue ファイルではなく HTML に直接テンプレートを書く場合に属性名へ大文字を使えない点です。ブラウザが属性名を小文字に変換してしまうためです。

使う場面は限られます。属性名が実行時に決まる汎用コンポーネントを書くときの道具で、通常は素直に :href と書きます。

エスケープが守る範囲

Vue はテキストの補間と属性のバインディングを自動でエスケープします。ブラウザの標準 API (textContentsetAttribute) を通すので、HTML の構造を壊す文字は文字として扱われます。

components/EscapeDemo.vue
<script setup lang="ts">
// どちらもユーザーが入力した文字列だと想定する
const htmlLike = '<img src=x onerror="alert(1)">'
const attrInjection = '" onclick="alert(1)'
</script>

<template>
<!-- タグとして解釈されず、文字列として表示される -->
<p>{{ htmlLike }}</p>

<!-- 引用符を閉じて別の属性を足す攻撃も通らない -->
<h1 :title="attrInjection">タイトル</h1>
</template>

エスケープが防いでいるのは「文字列が HTML の構造として解釈されること」です。値そのものが実行されうる位置では、エスケープは働きません。 代表的な経路を挙げます。

v-html は HTML として解釈する

<template>
<!-- 出どころを保証できる内容にだけ渡す -->
<div v-html="trustedHtml"></div>
</template>

渡した文字列を HTML として解釈するので、信頼できない内容を渡すと任意のスクリプトが動きます。中では Vue のテンプレート構文が処理されないので、{{ }} が入っていてもただの文字として表示されます。

URL は値そのものが実行されうる

<template>
<!-- url が javascript: で始まっていると、クリックで実行される -->
<a :href="url">リンク</a>
</template>

javascript:alert(1) のような文字列には HTML のメタ文字が含まれないので、エスケープする対象がありません。属性値としてそのまま設定され、クリックで実行されます。公式ドキュメントは、ユーザーが入力した URL はデータベースへ保存する前にバックエンドでサニタイズすべきだとしています。フロントエンドで URL のサニタイズをしている時点ですでに問題がある、という立場です1

style とイベントも経路になる

:style にユーザーの入力を渡すと、透明な要素をボタンの上に重ねるような CSS を注入できます。表示を乗っ取る攻撃の入口です。

属性名を動的にすると経路が増える

前に触れた :[attributeName] には落とし穴があります。属性名が onclick のように小文字だけの on 系の名前になった場合、Vue はイベントリスナーではなく属性として設定します。 setAttribute('onclick', '...') になるので、値が JavaScript として実行されます。

Vue がイベントリスナーとして扱うのは onClick のように 3 文字目が小文字でない名前だけです。この判定の隙間があるので、属性名をユーザーの入力から組み立てる設計は避けます

判断の基準は「その値の出どころを自分が保証できるか」です。保証できない値を、HTML・URL・CSS・イベントのいずれかとして解釈される位置に渡さないことです。属性名そのものも同じ扱いにします。

クラスとスタイルのバインディング

classstyle は特別扱いされていて、文字列以外にオブジェクトと配列を渡せます。

components/StatusBadge.vue
<script setup lang="ts">
import { computed, ref } from 'vue'

const isActive = ref(true)
const hasError = ref(false)

// 条件が増えたら computed に寄せる
const badgeClass = computed(() => ({
'badge--active': isActive.value,
'badge--error': hasError.value,
}))
</script>

<template>
<!-- オブジェクト構文: 真偽値でクラスの有無を決める -->
<span :class="{ 'badge--active': isActive }">オブジェクト構文</span>

<!-- computed を渡すと条件をテンプレートから追い出せる -->
<span :class="badgeClass">computed を渡す</span>

<!-- 静的な class と併記できる。両方が適用される -->
<span class="badge" :class="badgeClass">静的クラスとの併記</span>

<!-- style はキャメルケースで書ける -->
<p :style="{ color: 'navy', fontSize: '16px' }">インラインスタイル</p>
</template>

静的な class 属性と :class を同時に書いても打ち消し合いません。Vue が両方をまとめます。

条件が 2 つを超えたら computed に移すと、テンプレートが読みやすくなります。テンプレートに書ける式は 1 つなので、複雑な条件を押し込むと結局読めなくなります。

条件付きレンダリング

v-if は要素ごと出し入れする

components/ScoreLabel.vue
<script setup lang="ts">
const score = 75
</script>

<template>
<p v-if="score >= 80">優秀</p>
<p v-else-if="score >= 60">合格</p>
<p v-else>不合格</p>
</template>

v-elsev-else-if は、v-if を持つ要素の直後に置く必要があります。間に別の要素を挟むと対応が切れます。

複数の要素をまとめて出し入れしたいときは <template> に付けます。この要素自身は描画されないので、余分な div が DOM に残りません。

<template>
<template v-if="isLoggedIn">
<h1>ようこそ</h1>
<button @click="logout">ログアウト</button>
</template>
<template v-else>
<h1>ゲスト</h1>
<button @click="login">ログイン</button>
</template>
</template>

v-show は CSS で隠す

<template>
<p v-show="isVisible">display で切り替わる</p>
</template>

要素は常に描画され、display プロパティだけが切り替わります。<template> には付けられず、v-else とも組み合わせられません。

どちらを選ぶか

コストのかかり方が逆になっています。

項目v-ifv-show
初期描画のコスト低い。条件が false なら描画しない高い。条件に関係なく描画する
切り替えのコスト高い。要素の破棄と再生成が走る低い。CSS が変わるだけ
初回の扱い条件が初めて true になるまで描画を遅らせる最初から描画する
<template>使える使えない
v-else使える使えない
向いている場面条件が変わりにくい頻繁に切り替える

v-if は「本物の」条件分岐です。切り替えのたびにイベントリスナーと子コンポーネントを破棄して作り直すので、状態も初期化されます。タブの中身を切り替えるときに前の入力が残らないのは、この破棄が走るためです。

v-show は要素を残したまま隠すので、中の状態も保たれます。開閉するパネルのように何度も切り替える箇所ではこちらが向いています。

リストレンダリング

配列を展開するのが v-for です。

components/FruitList.vue
<script setup lang="ts">
import { ref } from 'vue'

type Fruit = {
id: number
name: string
}

const fruits = ref<Fruit[]>([
{ id: 1, name: 'りんご' },
{ id: 2, name: 'ばなな' },
])

let nextId = 3

const addFruit = () => {
// id を先に確定させてから両方に使う
const id = nextId
nextId += 1
fruits.value.push({ id, name: `果物 ${id}` })
}
</script>

<template>
<ul>
<li v-for="fruit in fruits" :key="fruit.id">{{ fruit.name }}</li>
</ul>

<!-- インデックスも受け取れる -->
<ul>
<li v-for="(fruit, index) in fruits" :key="fruit.id">
{{ index }}: {{ fruit.name }}
</li>
</ul>

<button @click="addFruit">追加</button>
</template>

key が何をしているか

key は Vue が「前回のどの要素が今回のどれに対応するか」を判断するための目印です。指定の有無で更新の戦略が変わります。

key がない場合、Vue は要素の移動を最小限にしようとして、同じ位置の要素をそのまま使い、中身だけを書き換えます。1 番目の li の中身を書き換え、2 番目の li の中身を書き換える、という進め方です。要素そのものは作り直されないので、テキストだけを並べたリストなら結果は正しくなります。

key がある場合、Vue は key を見て「前回のどの要素が今回のどれに当たるか」を決めます。key が一致する要素は作り替えずに再利用し、なくなった key の要素は破棄します。要素が再利用される以上、その要素が抱えている状態も残ります。

この差は表示だけを見ると分かりません。壊れるのは要素が状態を持っているときです。

インデックスを key にすると何が起きるか

壊れる書き方
<template>
<ul>
<!-- インデックスは並びが変わると別の項目を指す -->
<li v-for="(fruit, index) in fruits" :key="index">
<input type="text" />
{{ fruit.name }}
</li>
</ul>
</template>

各行に入力欄があるとします。りんごの行に「甘い」と入力したあと、先頭に項目を追加すると、りんごは 2 番目に下がります。しかし key はインデックスなので、りんごの key は 0 から 1 に変わります。Vue から見ると「key 0 の要素は残っていて中身が変わった」「key 1 の要素も残っていて中身が変わった」ように見えるので、入力欄はその場に留まります。結果として「甘い」が別の果物の行に付いたままになります。

keyfruit.id を渡していれば、りんごの key は変わりません。Vue は key が一致する要素をそのまま再利用するので、りんごの li は作り替えられません。新しい要素が前に挿入された結果としてりんごは 2 番目の位置になり、入力欄はその要素に付いたまま一緒に付いてきます。

key に渡す値の条件は 2 つです。兄弟の間で一意であることと、文字列か数値であることです (仕様上は symbol も使えます)。毎回新しいオブジェクトを作って渡すと、前回と今回で別物と判定されて再利用されません。

まとめ

  • テキストの位置には二重波括弧、属性の位置には v-bind (省略記法は :) を使います。場所で記法が決まります
  • 中に書けるのは式だけです。文は書けません
  • 属性名を動的にできますが、式は文字列か null に評価される必要がありますnull はバインディングを外します
  • Vue はテキストと属性を自動でエスケープします。ただし守られるのは「HTML の構造として解釈されること」だけです。v-html、URL、CSS、イベント、動的な属性名は経路として残りますjavascript: URL はエスケープすべき文字を含まないので素通しし、小文字の onclick は属性として設定されます
  • classstyle はオブジェクトや配列を受け取れます。条件が増えたら computed に寄せます
  • v-if は初期描画が軽く切り替えが重い。v-show はその逆です。v-show<template>v-else に使えません
  • key は前回と今回の要素の対応を決める目印です。key がなければ位置で対応させ、key があれば key で対応させます。どちらでも要素は再利用されるので、違いは「何を基準に対応させるか」だけです
  • key に位置 (インデックス) を渡すと、key があっても位置で対応させるのと同じになります。要素側の状態が項目についていきません
  • インデックスを key にすると、並びが変わったときに要素と中身の対応がずれます。 表示だけでは気づけず、入力欄やフォーカスのような要素側の状態で露見します
関連リファレンス

次に読む

Footnotes

  1. 出典: Security — Potential Dangers。"User-provided URLs should always be sanitized by your backend before even being saved to a database" と書かれています。さらに "if you're ever doing URL sanitization on the frontend, you already have a security issue" とも述べています。:style によるクリックジャッキングとイベント属性への JavaScript のバインドも同じ節が扱っています。