Vue 3のwatchを使った処理で、VS CodeやTypeScriptの静的解析ではエラーにならないものの、実行時に無限更新ループが発生するケースを再現しました。
備忘録として、発生する仕組みと静的解析で検知しにくい理由を簡潔にまとめます。
本記事のサンプルコードは、OpenAI Codexを使用して作成しました。
環境
- Vue 3
- TypeScript
- Vite
再現コード
リアクティブな値xを監視する「ウォッチ式A」と、xをインクリメントする「関数B」を用意します。
<script setup lang="ts">
import { ref, watch } from 'vue'
const x = ref<number>(0)
// ウォッチ式A:xの変更を検知して関数Bを呼び出す
watch(x, () => {
incrementX()
})
// 関数B:xをインクリメントする
const incrementX = (): void => {
x.value++
}
</script>
<template>
<button type="button" @click="incrementX">
無限更新ループを開始
</button>
</template>
初期表示ではwatchのコールバックは実行されません。「無限更新ループを開始」ボタンを押してincrementX()を呼び出したときに、更新の連鎖が始まります。
無限更新ループになる仕組み
処理は次の順番で循環します。
- ボタンを押す
- 関数Bの
incrementX()がxを更新する -
xの変更をウォッチ式Aが検知する - ウォッチ式Aが関数Bを呼び出す
- 関数Bが再び
xを更新する - 以降、3〜5を繰り返す
つまり、監視対象を更新する処理を、その監視処理自身から間接的に呼び出していることが原因です。
ボタン → 関数B → xを更新 → ウォッチ式A → 関数B → xを更新 → …
実際にはVueの安全機構が再帰更新を実行時に検知し、ブラウザーのConsoleへ次のエラーを出して処理を停止します。
Maximum recursive updates exceeded
なぜ静的解析で検知しにくいのか
このコードには、while (true)や関数自身の直接呼び出しのような、明白な無限ループはありません。
- 関数B単体は、
xを1回インクリメントするだけ - ウォッチ式A単体は、
xの変更時に関数Bを呼ぶだけ - 更新連鎖はVueのリアクティブシステムを介して実行時に成立する
- ボタンを押すまで問題の処理が発火しない
フレームワークが実行時に登録する監視関係と、関数によるリアクティブな値の書き換えを組み合わせた循環までは、通常の静的解析では判断が難しい場合があります。
そのため、VS Code上では警告やエラーが表示されなくても、実際に操作すると再帰更新エラーが発生します。
対策
watchのコールバック内では、原則として監視対象そのものを無条件に更新しないようにします。
更新が必要な場合は、次のような対応を検討します。
- 条件を追加し、値を更新しても再び同じ処理が呼ばれないようにする
- ある値から計算できる値は、watchで書き換えずに
computedを使って求める - 複数の関数やウォッチ処理をまたぐ場合は、値の読み書きを追跡する
サンプルコード
サンプルコードはGitHubで公開しています。
実行手順やVS CodeからF5でデバッグ起動する方法は、リポジトリのREADMEを参照してください。