算出プロパティとウォッチャー — 導出と副作用を分ける
状態から別の値を作りたい場面は 2 通りに分かれます。「この値から計算できる値が欲しい」場合と、「値が変わったときに何かをしたい」場合です。前者が computed、後者が watch です。
見分け方は「結果を返すのか、副作用を起こすのか」です。合計金額を出すのは前者で、値が変わったら API を叩くのは後者です。ここを混ぜると、キャッシュが効かない計算や、意図しない回数だけ走る副作用が生まれます。
この章で学ぶこと
computedのキャッシュが何を基準に効くのかを説明できる- 書き込める
computedを必要な場面で使える watchの監視対象の形と、オプションによる挙動の違いを選べるwatchEffectとwatchを使い分けられる- 非同期処理のクリーンアップを適切に登録できる
computed — 値から値を導く
<script setup lang="ts">
import { computed, ref } from 'vue'
type Item = { name: string; price: number; count: number }
const items = ref<Item[]>([
{ name: 'ペン', price: 120, count: 3 },
{ name: 'ノート', price: 480, count: 1 },
])
// items が変わったときだけ再計算される
const total = computed(() => items.value.reduce((sum, item) => sum + item.price * item.count, 0))
</script>
<template>
<p>合計: {{ total }} 円</p>
<p>合計 (再掲): {{ total }} 円</p>
</template>
このテンプレートは total を 2 回読んでいますが、計算は 1 回しか走りません。computed は依存する値を覚えていて、それが変わらない限り前回の結果を返します。
メソッドとの違い
同じ計算はメソッドでも書けます。違いはキャッシュの有無です。
<template>
<!-- computed: 依存が変わらなければ再計算されない -->
<p>{{ total }}</p>
<p>{{ total }}</p>
<!-- メソッド: 読むたびに実行される -->
<p>{{ calcTotal() }}</p>
<p>{{ calcTotal() }}</p>
</template>
メソッドは再描画のたびに実行されます。計算が軽いなら差は出ませんが、配列を走査するような処理では回数がそのままコストになります。
キャッシュの基準はリアクティブな依存です。computed の中で Date.now() や Math.random() のようなリアクティブでない値を読んでも、それは依存として記録されません。時刻を返す computed が更新されないのは、この理由です。
書き込める computed
computed は既定では読み取り専用ですが、get と set を渡すと書き込めます。
<script setup lang="ts">
import { computed, ref } from 'vue'
const firstName = ref('田中')
const lastName = ref('太郎')
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
const [first = '', last = ''] = value.split(' ')
firstName.value = first
lastName.value = last
},
})
</script>
<template>
<!-- v-model は書き込める computed に対しても使える -->
<input v-model="fullName" />
<p>姓: {{ firstName }} / 名: {{ lastName }}</p>
</template>
v-model は読み書きの両方を要求するので、書き込める computed と組み合わせられます。ストアの値をフォームに直結したいときに使う形です。
set の中では元になっている値を書き換えます。computed 自体には値を保持する場所がないので、書き込みを受けて何を更新するかは自分で決めます。
この例は「空白で区切られた 2 語」を前提にしています。田中 だけ入力すると lastName が空になり、getter は 田中 (末尾に空白) を返すので、入力した文字列と表示が食い違います。実際のフォームでは set の側で入力の形を検証するか、姓と名を別の入力欄に分けます。書き込める computed は変換を挟めますが、変換が非可逆なら表示が入力とずれます。
watch — 変化に反応する
watch は監視対象が変わったときにコールバックを呼びます。返り値を使うのではなく、副作用を起こすための仕組みです。
<script setup lang="ts">
import { ref, watch } from 'vue'
const userId = ref(1)
const userName = ref('')
const fetchUser = async (id: number) => {
const res = await fetch(`/api/users/${id}`)
const user = (await res.json()) as { name: string }
userName.value = user.name
}
// userId が変わるたびに取得し直す
watch(userId, (newId, oldId) => {
console.log(`${oldId} から ${newId} へ`)
void fetchUser(newId)
})
</script>
<template>
<p>{{ userName }}</p>
<button @click="userId += 1">次のユーザー</button>
</template>
監視できるもの
監視対象には ref、getter 関数、reactive なオブジェクト、それらの配列を渡せます。
import { reactive, ref, watch } from 'vue'
const count = ref(0)
const state = reactive({ nested: { value: 0 } })
// ref をそのまま
watch(count, (value) => console.log(value))
// getter で「特定のプロパティ」に絞る
watch(
() => state.nested.value,
(value) => console.log(value),
)
// 配列で複数まとめて
watch([count, () => state.nested.value], ([c, v]) => console.log(c, v))
プロパティを 1 つだけ見たいときは getter を使います。watch(state.nested.value, ...) のように書くと、数値をそのまま渡すことになって監視できません。
reactive を直接渡すと deep になる
const state = reactive({ nested: { count: 0 } })
// 暗黙で deep watcher になり、入れ子の変更でも発火する
watch(state, (newValue, oldValue) => {
// newValue === oldValue が成り立つ
})
reactive なオブジェクトを直接渡すと、Vue は自動で深い監視をします。入れ子のプロパティを書き換えても発火します。
ここに落とし穴があります。この場合 newValue と oldValue は同じオブジェクトを指します。 どちらも同じ Proxy なので、変更前の値を比較する用途には使えません。
変更前後を比べたいなら、getter でプリミティブな値まで取り出します。() => state.nested のようにオブジェクトを返す getter では、参照が変わらないので入れ子の変更では発火しません。オブジェクトの中身を比べたいなら、コールバックの中で自分でコピーを持ちます。
主なオプション
| オプション | 内容 | 既定 |
|---|---|---|
immediate | 作成時にもコールバックを呼ぶ | false |
deep | 深い監視を強制する。数値を渡すと最大の深さを指定できる | false |
flush | 呼ぶタイミングを変える ('pre' / 'post' / 'sync') | 'pre' |
once | 1 度だけ呼び、そのあと自動で停止する | false |
onTrack / onTrigger | 依存の追跡と発火をデバッグ用に受け取る | なし |
flush の既定は 'pre' で、コンポーネントの更新前に呼ばれます。DOM が更新された後の状態を読みたい場合は 'post' にします。
watch(
count,
() => {
// この時点では DOM が更新済み
console.log(document.querySelector('.count')?.textContent)
},
{ flush: 'post' },
)
停止と一時停止
watch の戻り値には操作用のメソッドが付いています。
const handle = watch(count, () => console.log('changed'))
handle.pause() // 一時的に呼ばれなくする
handle.resume() // 再開する
handle.stop() // 完全に止める
コンポーネントに結びついた watcher は、そのコンポーネントが破棄されるときに自動で止まります。結びつくかどうかは、どこで作ったかで決まります。
結びつくのは、コンポーネントの文脈が立っている同期の実行中に作ったものです。setup の実行中と、ライフサイクルフックのコールバックが同期に走っている間がこれに当たります。
script setup のトップレベルは、await をまたいだ後でも結びつきます。コンパイラが await の前後で文脈を保存して復元しているためです。この復元は script setup だけの仕組みで、手書きの async setup() にはありません。
同期の実行が終わった後に作った watcher は結びつかないので、自分で止めます。 ライフサイクルフックの中で await をまたいだ場合と、手書きの async setup() で await の後に作った場合がこれに当たります。
onMounted(async () => {
await nextTick()
// このコンポーネントには結びつかない。unmount しても動き続ける
const handle = watch(count, () => console.log('changed'))
})
フックを実行するとき Vue はコンポーネントの文脈を戻しますが、それが有効なのはフックのコールバックが同期に走っている間だけです。最初の await でその区間が終わり、文脈が外されます。script setup のトップレベルにあるコンパイラの復元は、フックの中には効きません。
この章の後半で扱う onWatcherCleanup() にも「同期に呼ぶ」制約がありますが、探しているものが違います。こちらは「アクティブなコンポーネント」を探し、あちらは「実行中の watcher」を探します。だから onWatcherCleanup() はコンポーネントの中でも、watcher のコールバックの外では登録できません。
条件を満たしたら監視をやめたい場合も、handle.stop() を自分で呼びます。
watchEffect — 依存を自動で拾う
watchEffect は監視対象を書きません。コールバックの中で読んだリアクティブな値が、そのまま依存になります。
import { ref, watchEffect } from 'vue'
const count = ref(0)
const message = ref('hello')
// count と message の両方を読んでいるので、どちらが変わっても再実行される
watchEffect(() => {
console.log(`${message.value}: ${count.value}`)
})
watch との主な違いは次のとおりです。
watch | watchEffect | |
|---|---|---|
| 監視対象 | 明示的に書く | コールバック内で読んだ値から自動で決まる |
| 初回の実行 | 既定では走らない (immediate で変えられる) | 必ず走る |
| 変更前の値 | 受け取れる | 受け取れない |
依存を自分で管理しなくてよいのは楽ですが、どの値に反応しているのかがコードから読み取りにくくなります。条件分岐の中で値を読むと、分岐の結果によって依存が変わります。監視対象をはっきりさせたい場合は watch を選びます。
非同期処理のクリーンアップ
監視対象が短時間に何度も変わると、前の処理が終わる前に次が始まります。古い処理の結果で新しい状態を上書きしないよう、後片付けを登録します。
import { onWatcherCleanup, ref, watch } from 'vue'
const userId = ref(1)
watch(userId, async (newId) => {
const controller = new AbortController()
// 次の実行が始まる前、またはコンポーネントの破棄時に呼ばれる
onWatcherCleanup(() => controller.abort())
const res = await fetch(`/api/users/${newId}`, { signal: controller.signal })
console.log(await res.json())
})
onWatcherCleanup() は Vue 3.5 で入った関数です。上の例では await の前に呼んでいますが、これは意図的です。この関数には呼べる場所の制約があります。
実行中の watcher を暗黙で探して結びつける仕組みなので、await をまたいだ後では対象を見つけられません。開発ビルドでは警告が出て、登録そのものが行われません。
// 登録されない書き方
watch(userId, async (newId) => {
const res = await fetch(`/api/users/${newId}`)
// この時点では対象の watcher が分からない
onWatcherCleanup(() => console.log('登録されない'))
})
await の後で登録したいなら、コールバックの第 3 引数を使います。こちらは対象の watcher が最初から結びついているので、非同期処理の途中でも登録できます。
watch(userId, async (newId, oldId, onCleanup) => {
const res = await fetch(`/api/users/${newId}`)
const controller = new AbortController()
onCleanup(() => controller.abort())
})
watchEffect では第 1 引数として渡されます。
watchEffect((onCleanup) => {
const timer = setInterval(() => console.log('tick'), 1000)
onCleanup(() => clearInterval(timer))
})
登録されたクリーンアップは、どの経路でも次の実行の直前とwatcher の停止時に呼ばれます。違うのは「いつ登録できるか」です。onWatcherCleanup() は同期に実行される区間だけ、引数で渡される関数はコールバックの中ならどこでも使えます。
この制約は、script setup のトップレベル await とは別の話です。あちらはコンパイラが文脈を復元しますが、onWatcherCleanup() が探しているのは「実行中の watcher」で、コールバックの中で await した後にはそれが失われています。
どれを使うか
判断は「結果が欲しいのか、処理を起こしたいのか」で分かれます。
| やりたいこと | 使うもの |
|---|---|
| 状態から別の値を導いてテンプレートで使う | computed |
| フォームと状態の間に変換を挟む | 書き込める computed |
| 値が変わったら API を呼ぶ、ログを出す | watch |
| 変更前の値と比べたい | watch |
| 複数の値に反応するが依存を列挙したくない | watchEffect |
| 初回も含めて必ず走らせたい | watchEffect か watch + immediate |
computed の中で副作用を起こさないでください。キャッシュが効くので実行回数が読めず、いつ走るかも保証されません。副作用が要るなら watch か watchEffect に寄せます。
まとめ
computedはリアクティブな依存に基づいてキャッシュします。依存が変わらなければ何度読んでも計算は 1 回です- メソッドは再描画のたびに実行されます。差が出るのは計算が重いときです
Date.now()のようなリアクティブでない値は依存として記録されませんgetとsetを渡すと書き込めるcomputedになり、v-modelと組み合わせられますwatchの監視対象は ref、getter、reactive なオブジェクト、それらの配列です。プロパティを絞るなら getter を使います- reactive なオブジェクトを直接渡すと暗黙で深い監視になり、
newValueとoldValueが同じオブジェクトを指します。変更前と比べたいなら getter でプリミティブな値まで取り出します。オブジェクトを返す getter では入れ子の変更で発火しません deepは真偽値だけでなく数値でも指定できます。flush: 'post'にすると DOM 更新後に呼ばれますwatchの戻り値にはstop/pause/resumeがあります。コンポーネントに結びついたものは破棄時に自動で止まります。 結びつくのは文脈が立っている同期の実行中に作ったもので、script setupのトップレベルはawaitをまたいでも含まれます- 同期の実行が終わった後に作った watcher は結びつきません。 フックの中で
awaitをまたいだ場合と、手書きのasync setup()でawaitの後に作った場合です。アンマウント後も動き続けるのでstop()を自分で呼びます onWatcherCleanup()の「同期に呼ぶ」制約は別の話です。探しているものが違い、こちらはアクティブなコンポーネント、あちらは実行中の watcher です- 書き込める
computedは変換を挟めますが、変換が非可逆だと表示が入力とずれます watchEffectは必ず初回に走り、依存はコールバック内で読んだ値から決まります。変更前の値は受け取れません- クリーンアップの登録先は
onWatcherCleanup()(3.5 以降) とコールバックの引数の 2 つで、呼ばれるのは次の実行の直前と停止時です。ただしonWatcherCleanup()は同期に実行される区間でしか登録できません。awaitの後ではコールバックの引数を使います
- Vue — 算出プロパティ — キャッシュとメソッドの対比
- Vue — ウォッチャー — 監視対象の形と暗黙の deep
- Vue — リアクティビティ API —
watchのオプションとWatchHandleの仕様 - React ガイド — useEffect — 依存配列を自分で書く設計との比較
次に読む
- script setup とコンポーネントの契約 — 親子の境界を型として宣言する方法
- Composables —
computedとwatchをまとめて切り出す方法