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

算出プロパティとウォッチャー — 導出と副作用を分ける

状態から別の値を作りたい場面は 2 通りに分かれます。「この値から計算できる値が欲しい」場合と、「値が変わったときに何かをしたい」場合です。前者が computed、後者が watch です。

見分け方は「結果を返すのか、副作用を起こすのか」です。合計金額を出すのは前者で、値が変わったら API を叩くのは後者です。ここを混ぜると、キャッシュが効かない計算や、意図しない回数だけ走る副作用が生まれます。

この章で学ぶこと

  • computed のキャッシュが何を基準に効くのかを説明できる
  • 書き込める computed を必要な場面で使える
  • watch の監視対象の形と、オプションによる挙動の違いを選べる
  • watchEffectwatch を使い分けられる
  • 非同期処理のクリーンアップを適切に登録できる

computed — 値から値を導く

components/CartSummary.vue
<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 は既定では読み取り専用ですが、getset を渡すと書き込めます。

components/NameField.vue
<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 は監視対象が変わったときにコールバックを呼びます。返り値を使うのではなく、副作用を起こすための仕組みです。

components/UserLoader.vue
<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 は自動で深い監視をします。入れ子のプロパティを書き換えても発火します。

ここに落とし穴があります。この場合 newValueoldValue は同じオブジェクトを指します。 どちらも同じ Proxy なので、変更前の値を比較する用途には使えません。

変更前後を比べたいなら、getter でプリミティブな値まで取り出します() => state.nested のようにオブジェクトを返す getter では、参照が変わらないので入れ子の変更では発火しません。オブジェクトの中身を比べたいなら、コールバックの中で自分でコピーを持ちます。

主なオプション

オプション内容既定
immediate作成時にもコールバックを呼ぶfalse
deep深い監視を強制する。数値を渡すと最大の深さを指定できるfalse
flush呼ぶタイミングを変える ('pre' / 'post' / 'sync')'pre'
once1 度だけ呼び、そのあと自動で停止する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 との主な違いは次のとおりです。

watchwatchEffect
監視対象明示的に書くコールバック内で読んだ値から自動で決まる
初回の実行既定では走らない (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
初回も含めて必ず走らせたいwatchEffectwatch + immediate

computed の中で副作用を起こさないでください。キャッシュが効くので実行回数が読めず、いつ走るかも保証されません。副作用が要るなら watchwatchEffect に寄せます。

まとめ

  • computedリアクティブな依存に基づいてキャッシュします。依存が変わらなければ何度読んでも計算は 1 回です
  • メソッドは再描画のたびに実行されます。差が出るのは計算が重いときです
  • Date.now() のようなリアクティブでない値は依存として記録されません
  • getset を渡すと書き込める computed になり、v-model と組み合わせられます
  • watch の監視対象は ref、getter、reactive なオブジェクト、それらの配列です。プロパティを絞るなら getter を使います
  • reactive なオブジェクトを直接渡すと暗黙で深い監視になり、newValueoldValue が同じオブジェクトを指します。変更前と比べたいなら 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 の後ではコールバックの引数を使います
関連リファレンス

次に読む