はじめに
私は普段Vueを使用して開発をしています。開発をする中で、再レンダリングについても特に意識しなくとも効率的に更新が行われているなと感じることがあるので、本記事ではどのようにそれが実現されているのかということについて公式ドキュメントを参考にまとめます。
効率的な更新について
まず、この記事でいう効率的な更新とは以下のことを指します
- 必要最小限の再計算が行われること
- データの変更をした際にその変更に依存する処理のみを実行する
- 精密な依存関係の追跡
- 変更があった際に、その変更に依存している処理のみを特定して実行する
- 自動的な最適化
- 開発者が明示的に最適化を指定しなくとも、自動的に効率的な処理をシステムが選択する
これらはVueのリアクティブシステムによって実現されています。
Vueのリアクティブシステム
Vueが効率的な更新を実現できる理由は、リアクティブシステムにあります。
このシステムは、変更検知の仕組みと依存関係の自動追跡という2つの柱によって、必要最小限の処理のみを実行することを可能にしています。
変更検知の仕組み
Vue 3では、リアクティブなデータを作成する主な方法としてreactive()とref()の2つがあります。それぞれ異なる仕組みで変更を検知しています。
reactive():Proxyによる変更検知
reactive()はProxyを使用してオブジェクト全体をリアクティブにします。
Proxyとは
Proxyは、あるオブジェクトに対する操作(プロパティの取得、設定、列挙、関数の実行など)をインターセプトして、カスタムの動作を定義できる仕組みです。
const proxy = new Proxy(target, handler);
-
target: ラップする元のオブジェクト -
handler: どの操作がインターセプトされ、どのように再定義されるかを定義するオブジェクト
Proxyを使うと何ができるか
- 新しいプロパティの検知
const obj = new Proxy({}, {
set(target, prop, value) {
console.log(`プロパティ ${prop} が追加されました`);
target[prop] = value;
return true;
}
});
obj.name = "太郎"; // 検知される
obj.age = 30; // 新しいプロパティも検知される
- 配列操作の検知
const arr = new Proxy([], {
set(target, prop, value) {
console.log(`配列が変更されました: ${prop} = ${value}`);
target[prop] = value;
return true;
}
});
arr.push(1); // push操作も検知される
arr[0] = 'changed'; // インデックスアクセスも検知される
補足:set関数の引数について
// obj.name = "太郎" を実行した時
set(target, prop, value) {
// target: 元のオブジェクト({})
// prop: 設定されるプロパティ名("name")
// value: 設定される値("太郎")
console.log(`プロパティ ${prop} が追加されました`);
target[prop] = value; // target["name"] = "太郎" と同じ
return true;
}
これにより、従来のObject.definePropertyでは難しかった「動的に追加されるプロパティ」や「配列の変更」も自動で検知できるようになります。
Vueでの実装イメージ
Vue 3では、reactive()関数内部でProxyが生成され、リアクティブなオブジェクトが作成されます。公式ドキュメントから抜粋した簡略版は下記です:
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key) // 依存関係を記録
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key) // 依存している処理を実行
return true
}
})
}
実際のVue 3のソースコードを追いたい場合、packages/reactivity/src/reactive.tsが参考になります。
ref():getter/setterによる変更検知
ref()はProxyではなく、getter/setterを使用して変更を検知します:
function ref(value) {
const refObject = {
get value() {
track(refObject, 'value') // 依存関係を記録
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value') // 依存している処理を実行
}
}
return refObject
}
ref()は単一の値を扱うため、Proxyではなくシンプルなgetter/setterで十分に対応できます。
依存関係の追跡について
次に最も重要な部分である「誰が」「何を」使っているかを自動的に記録し、変更時に影響を受ける処理のみを実行する依存関係追跡の仕組みについてです。
基本的な概念
公式ドキュメントでは、この仕組みについて以下のように説明されています:
- effect(副作用): プログラムの状態を変更する処理。例えば、計算結果を更新する関数
- dependencies(依存関係): effectが使用する値。計算に必要なデータ
- subscriber(購読者): effectは、その依存関係の購読者と言われる
スプレッドシートのセルの計算を例にすると:
let A0 = 1; // 値1
let A1 = 2; // 値2
let A2; // 計算結果
function update() {
A2 = A0 + A1; // この関数がeffect(副作用)
}
この例では:
-
update()関数がeffect -
A0とA1がdependencies(依存関係) -
update()はA0とA1のsubscriber(購読者)
となります。
activeEffectの役割
依存関係の自動追跡を実現する上で、中心的な役割を果たすのがactiveEffectという仕組みです。
以下は公式ドキュメントに記載されているコードです:
// 現在実行中のeffectを追跡する変数
let activeEffect
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect // 1. 実行前に「今からこのeffectを実行する」と記録
update() // 2. 実際の処理を実行(この間にgetterが呼ばれる)
activeEffect = null // 3. 実行後にリセット
}
effect() // 4. 初回実行して依存関係を記録
}
なぜactiveEffectが必要なのか
プロパティが読み取られた時(getter実行時)に、「どのeffectがこのプロパティを使っているのか」を知る必要があります。activeEffectは、現在実行中のeffectを示す目印として機能します。
実際の動作の流れ:
-
effect開始:
activeEffect = effectで「今からこのeffectを実行する」と宣言 -
プロパティアクセス:
update()内でA0やA1を読み取ると、getterが動作 -
依存関係記録: getterが
activeEffectを確認し、「このeffectはこのプロパティに依存している」と記録 -
effect終了:
activeEffect = nullでリセット
これによって、どのeffectがどのプロパティに依存しているかが自動的に把握できるようになります。
track()とtrigger():依存関係の記録と実行
依存関係の追跡は、track()とtrigger()という2つの関数によって実現されています。
track():依存関係の記録
公式ドキュメントでは、track()について以下のように説明されています:
track() の内部では、現在実行中のエフェクトがあるかどうかをチェックします。ある場合は、追跡されているプロパティの購読者であるエフェクト(Set に格納)を検索し、実行中のエフェクトを Set に追加します:
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
つまり、プロパティが読み取られた際(getter実行時)に、現在実行中のeffectがそのプロパティの依存関係として自動的に記録される仕組みです。
trigger():依存している処理の実行
逆に、プロパティが変更された時はtrigger()が呼ばれ、そのプロパティに依存しているすべてのeffectが実行されます:
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach(effect => effect())
}
この2つの関数により、「読み取り時に記録、変更時に実行」という依存関係の追跡が完結します。
Vueコンポーネントでの実際の動作
<template>
<div>{{ state.count }}</div>
</template>
<script setup>
import { reactive } from 'vue';
const state = reactive({ count: 0, name: 'Vue' });
// このコンポーネントのレンダリング中に state.count が読み取られるため、
// コンポーネントのレンダー関数が state.count に依存していることが記録される
</script>
実際の動作例
<template>
<div>
<p>カウント: {{ state.count }}</p>
<button @click="increment">Increment</button>
<button @click="changeName">名前を変更</button>
</div>
</template>
<script setup>
import { reactive } from 'vue';
const state = reactive({
count: 0,
message: 'Hello'
});
const increment = () => {
state.count++; // この変更により、countを使用しているeffectのみが再実行される
};
const changeName = () => {
state.message = 'World'; // messageは使用されていないため、何も起こらない
};
// レンダリング中に state.count が読み取られる
// → このコンポーネントのレンダー関数が state.count の依存関係として記録される
// state.message は使用されないため、依存関係に含まれない
</script>
この例では:
- 初回レンダリング時に
state.countが読み取られ、コンポーネントのレンダー関数がstate.countに依存していることが記録される -
state.messageはテンプレートで使用されていないため、依存関係には含まれない -
increment()が実行されてstate.countが変更されると、その依存関係にあるレンダー関数のみが再実行される -
changeName()でstate.messageが変更されても、どのeffectも依存していないため、再レンダリングは発生しない
まとめ
VueはProxyやgetter/setterによる変更検知と、自動的な依存関係の追跡(track/trigger)によって、本当に必要な場合にのみ再計算や再レンダリングを実行します。これによってVueは効率的な更新を実現しています。
また、今回記事を作成するにあたって、Vueのソースコード等を読むことで非常に勉強になったので今後も丁寧に理解していきたいです。