💨

watch() なんでアロー関数で渡すの?

に公開

概要

watch()はなんでアロー関数のreturnで値を渡さないとダメなんだ?
という出発点からリアクティブを掘り下げたら沼った。
Vue.jsにおけるリアクティブの仕組みとwatch()の仕組みを簡単に説明します。

最初の疑問

watch()はなんでアロー関数のreturnで値を渡さないとダメなんだ?
なんでそんな冗長なことするんだ?

.js
watch(() => props.items, (newVal) => {
  // OK
});

watch(props.items, (newVal) => {
  // エラー
});

補足:refオブジェクトはそのままでOK

.js
const count = ref(0);
watch(count, (newVal, oldVal) => {
});

簡単な理解

watch(props.items, ...) では、なぜダメなのか。
props.items中身の値 を即座に評価して渡してしまうから!
例えば

.js
props.items = 1

Vueさんは下のように見える

.js
watch(1, ...)

Vueさん「これは何を監視すればいいの?」

もうちょっと掘り下げる

  • リアクティブなオブジェクトはproxyオブジェクトである
  • proxyオブジェクトとは
  • リアクティブの仕組み(簡易)
  • watch() の仕組み(簡易)
  • effect() の仕組み(簡易)
  • 処理の流れ(全体像)
  • 結論
  • おまけ

リアクティブなオブジェクトは proxyオブジェクト である

Vue.jsにおいて、リアクティブなオブジェクトはproxyオブジェクトである。

.js
// reactiveの実装イメージ
function reactive(target) {
    ・・・
  return new Proxy(target, handler);
}

proxyオブジェクトとは

proxyオブジェクトとはJavaScriptの組み込みオブジェクトであり、対象のオブジェクトの操作をインターセプトして、代わりに任意の操作を行う

.js
const original = {
  message: "これは本来のメッセージです",
};

// handlerオブジェクト:代わりの操作を定義する
const handler = {
  get(target, prop) {
    return "これはProxyが差し替えたメッセージです";
  },
};

const proxy = new Proxy(original, handler);

console.log(proxy.message); // → "これはProxyが差し替えたメッセージです"
console.log(original.message); // → "これは本来のメッセージです"

getトラップ(プロパティの取得)の他にも、setトラップ(プロパティの設定)、applyトラップ(関数呼び出し)とかもある。

補足
propはgetトラップの引数であり、Proxyオブジェクトの内部で自動的に提供されるものです。
propはプロキシを介して取得しようとしているプロパティの名前(キー)です。今回なら"message"。

リアクティブの仕組み(簡易)

リアクティブにしたいオブジェクトをproxyオブジェクトでラップして、値が読み込まれた時と値が更新された時にリアクティブな動作をできるようにする。

get()で、プロパティが読まれたときに track() で「誰が読んだか」を記録しておき、
set()で、変更されたときに trigger() により「読んでた人たち」に再実行してねという通知する。

.js
function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      track(target, key); // 依存を記録
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key); // 変更を通知
      return result;
    }
  };
  return new Proxy(target, handler);
}

補足
Reflectオブジェクトは、proxyが捕まえた本来の動作を代わりに動かしている。
これはJavaScriptの組み込みオブジェクトであり、proxyオブジェクトを補完する目的で設計されている。
詳しくは本記事では触れない。

watch() の仕組み(簡易)

概要

watch(source, callback) は、source が返す値に変化があったときに実行する、callback を定義する。
実際に変更を監視してるのは、Vue のリアクティブシステム(具体的には effect()dep の仕組み)。

source は通常、getter 関数(例: () => props.items
Vue はこの getter を effect() を通して実行し、その実行中にアクセスされたリアクティブ変数を依存として記録する(=依存収集)。

effect()は依存収集エンジン。リアクティブな値を記録する。次にもう少し掘り下げて説明する。
runner()はgetterに依存を監視する機能を持たせた関数。effect()でラップしたgetter()関数。
scheduler()は監視してる値が変わった時に、vueのリアクティブシステムによりコールされる関数。これにより値が変わった時の動作が動く。

.js
function watch(source, callback) {
  let oldValue;

  // getter() を effect で包んで runner() を定義
  const runner = effect(() => {
    return source();
  }, {
    scheduler: () => {
      const newValue = runner();         // 最新の値を取得
      callback(newValue, oldValue);      // コールバック実行
      oldValue = newValue;               // 値を更新
    }
  });

  // <script setup>で実行され oldValue をセット
  // getterであるsource()を実行する。依存収集が起こる。
  oldValue = runner();
}

effect() の仕組み(簡易)

effect()は簡単に言うと依存収集エンジンである。
👉 effect() に渡した関数(getter)を「実行」しながら、
👉 中で使われたリアクティブな変数を「記録」すること!

受け取ったgetterをeffectでラップしたeffectFnを返す。
effectFnはgetter()としての機能に加え、自身を監視対象としてvueのシステムに記録する機能を持つ。

getter(= () => props.items)を一度実行する
その実行中にアクセスされた変数(= props.items)を収集
それらに変更があったら → scheduler() が呼ばれて callback() を実行

.js
function effect(fn, options) {
  const effectFn = () => {
    activeEffect = effectFn;  // 現在の effect(自分自身)を記録。activeEffectはvueが管理するグローバル変数
    const result = fn();      // 実際の getter を実行 → getter 内のアクセスを記録。proxyが動いて監視対象を記録
    activeEffect = null;      //
    return result;            // effectFnが返す値はgetter()が返す値。
  };

  if (!options?.lazy) {
    effectFn();  // 初回実行
  }

  return effectFn; // ← watch()で受け取って const runner = effectFn
}

処理の流れ(全体像)

.js
watch(() => props.items, callback)

↓
watch の中で
・effect()を使って、props.items の「依存を記録」。
・変化した時にコールバックを実行する`scheduler()`を定義

effect(() => props.items, {
  scheduler: () => {
    const newValue = runner();       // getter を実行
    callback(newValue, oldValue);    // 差分があれば通知
    oldValue = newValue;
  }
});

↓
effect の中で

getter(props.items)が実行され、Proxyget が呼ばれる
→ `track()` によって activeEffect が記録される
→ 依存収集が行われる

結論

  • リアクティブなオブジェクトはproxyオブジェクト: Vue.jsでは、リアクティブなデータを管理するためにproxyオブジェクトを使用します。これにより、データの変更を効率的に監視し、リアクティブな動作を実現します。

  • watch()は、監視対象の値が変わった時のコールバックを定義: watch()を使用することで、特定のデータが変更された際に自動的に処理を実行することができます。これにより、アプリケーションの状態管理が容易になります。

  • effect()は、Vue.jsの監視対象の依存収集システム: effect()は、リアクティブな変数の依存関係を記録し、変更があった際に適切な処理を行うための基盤を提供します。これにより、効率的なデータの更新と再描画が可能になります。

props.items に変化が起こると、proxyオブジェクトのsetトラップにより変更が通知され、trigger() が呼ばれます。これにより、対象の key に登録されている effectFn(runner)scheduler() を通じて実行され、最終的に callback(newValue, oldValue) が呼ばれます。

おまけ

単語の補足

  • getter()はなぜゲッターと呼ばれるのか。結果的な効果が依存取得だから。
.js
update() = {
  a2 = a0 + a1
}
  • 購読者(subscriber) : update()のこと。a0, a1の変化についてのニュースを知りたいから購読者って感じ。
  • エフェクト : a2 = a0 + a1のこと。監視対象の値を読んでる人。

原文大事

vue.jsの公式ドキュメント読んでて全然よく分からなくて数時間費やした箇所。

日本語ページ

実行中のエフェクトがあるときに変数が読み込まれた場合、その変数の購読者にエフェクトを実行します。例: update() が実行されているときに A0A1 が読み込まれるので、最初の呼び出し以降は update()A0A1 の両方の購読者になります。

「その変数の購読者にエフェクトを実行します。」の箇所

原文

If a variable is read when there is a currently running effect, make that effect a subscriber to that variable. E.g. because A0 and A1 are read when update() is being executed, update() becomes a subscriber to both A0 and A1 after the first call.

make that effect a subscriber to that variable.
「そのエフェクトを変数の購読者にする」

全然ちゃうやないかい💢💢💢

https://ja.vuejs.org/guide/extras/reactivity-in-depth

Discussion