🐱

Vue2.xのリアクティブな動きを分かりやすく説明してみる

2020/11/23に公開

つい最近 Vue3 がリリースされましたが、
個人で開発している 時間管理アプリ やお仕事では、まだまだ現役で Vue2 を利用しています。

せっかくなので、裏側でどういう方法でリアクティブを実現しているのか調べてみました。

今回は、data をメインに追っていきます。

下記のサンプルコードを使って見ていきます

今回は下記のコードを使って流れを見ていきましょう。
最初は画面上に EXAMPLE の文字列が表示され、1秒後立つと CHANGED の文字列に変わります。

<!DOCTYPE html>
<html>
<body>
  <div id="target"></div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#target',
      data() {
        return {
          message: 'EXAMPLE'
        }
      },
      mounted() {
        setTimeout(() => this.message = 'CHANGED', 1000)
      },
      render(h) {
        return h('div', this.message)
      },
    })
  </script>
</body>
</html>

DepWatch が裏で仕事をしている

setTimeout により、1秒後に this.message が変更されると、画面上の表示も自動的(リアクティブに)更新されます。内部ではどのように動作しているのか気になりますね。

実は、Vueの内部にある DepWatch というオブジェクトが仕事をしています。

Dep は変更があったことを通知する側です。
今回だと datamessage が該当します。

Watch は変更の通知を受け取り、何らかの処理を実行する側です。
今回だとVueの render が該当します。

この構成はよく Observerパターン と呼ばれています。
Dep が変更を通知して、 Watch が表示内容更新する、というイメージですね。

Dep はどうやって通知先の Watcher を知るのか?

おおまかな流れは以上ですが、Dep は一体どうやって通知先の Watcher を知るのでしょうか。
結論からいうと、JavaScriptの ゲッター という構文と、 Dep.target という変数が活躍しています。

ゲッターって何?

オブジェクトのプロパティを取得しようとした際に、任意の関数を実行できる便利なものです。

下記はゲッターの簡単な例です。
obj.message を呼び出したタイミングで、message() の関数が処理されます。

const obj = {
  get message() {
    console.log("CALLED");
    return 'EXAMPLE';
  }
}

const text = obj.message; // => 'CALLED' とコンソールに表示される
console.log(text);        // => 'EXAMPLE'

data はゲッターになっている

最初に記載したコードをもう一度見てみましょう。

<!DOCTYPE html>
<html>
<body>
  <div id="target"></div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#target',
      data() {
        return {
          message: 'EXAMPLE'
        }
      },
      mounted() {
        setTimeout(() => this.message = 'CHANGED', 1000)
      },
      render(h) {
        return h('div', this.message) // <--
      },
    })
  </script>
</body>
</html>

render(h) のメソッドの中で this.message を呼び出しています。
戻り値はもちろん EXAMPLE なのですが、
実はこの this.message プロパティはゲッターになっています。

Vueのインスタンスを生成する過程で data はゲッター化されます。

単純に EXAMPLE を返している訳ではなく、何らかの処理が裏で走っているのです。
では、実際にゲッターの内部で何をしているか、Vueのコードを確認してみます。

下記はVueが data をゲッター化している箇所です。

//github.com/vuejs/vue/blob/master/src/core/observer/index.js#L129-L186
/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
  ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // 注1
      if (Dep.target) {  
        dep.depend()     
        ...
      }
      return value 
    },
    ...
  })
}

注1で、Dep.target が存在すれば dep.depend() を実行しています。
dep.depend() を見ると...

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L8-L34
/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  ...
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

Dep.targetaddDep を呼んでいます。

後ほど説明するのですが、 Dep.target には呼び出し元の Watcher が入っています。
WatcheraddDep を見ます。

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L117-L129
/**
 * Add a dependency to this directive.
 */
Watcher.prototype.addDep = function addDep (dep) {
  var id = dep.id;
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id);
    this.newDeps.push(dep);
    if (!this.depIds.has(id)) {
      dep.addSub(this);  // <--
    }
  }
};

dep.addSub を呼んでいます。
また Dep に戻って、 addSub を見ます。

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L8-L34
/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub) // <--
  }
  ...
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

Depsubs という配列に Watcher を追加しているようです。
ここの subs というのが 「変更があったときに通知する先の Watcher」 の配列です。

ここまでを図でまとめるとこんな感じです。

ゲッターが呼ばれたタイミングで Dep.target を見て、
Watcher を通知先一覧 (subs) に追加しているのですね。

Dep.target は何処で設定しているの?

では、Dep.target は誰が何処で設定しているのでしょうか。
結論からいうと、Wacher 自身で設定しています。

Watcherget メソッドのコードを見ます。

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L90-L115
get () {
  pushTarget(this)  // 注1
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)  // 注2
  } catch (e) {
    ...
  } finally {
    ...
    popTarget()  // 注3
    this.cleanupDeps()
  }
  return value
}

この get メソッドは Dep から変更の通知がきた場合や、Wacherのインスタンス生成時など、
更新処理が必要になったタイミングで呼ばれます。

注1、注2、注3に注目します。
pushTarget が呼ばれた後に this.getter が呼ばれて、
最後に popTarget が呼ばれています。

この pushTargetpopTargetDep.target を書き換えている箇所です。

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L45-L58
// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null
const targetStack = []

export function pushTarget (_target: Watcher) {
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target  // <--
}

export function popTarget () {
  Dep.target = targetStack.pop() // <--
}

つまり、更新処理をする前に、「今から自分が呼び出します」というのを、
Dep.target の変数を使って Dep に宣言しているのですね。

また、this.getter は実際に更新処理をするメソッドです。
今回の場合だと、巡り巡って最初に記載した render(h) が呼ばれます。

render(h) {
  return h('div', this.message)
},

そして、this.message はゲッターになっており、
DepDep.target を見て、 呼び出し元の Watcher を通知先の一覧に追加する...という流れです。

以上の内容を整理すると下記のイメージです。

data に変更があった時はどう Watcher に通知されるの?

通知先を知る方法はわかりました。

では、data が書き換えられたら、どのようにして Watcher へ通知されるのでしょうか。
最初に貼った画像を再度記載します。 「2. 変更を通知する」 の箇所ですね。

通知の仕組みには、今度は セッター が使われています。

セッターって何?

オブジェクトのプロパティを設定しようとした際に、任意の関数を実行できる便利なものです。

下記はセッターの簡単な例です。
obj.message に値を代入しようとしたタイミングで、message() の関数が処理されます。

const obj = {
  set message(newMessage) {
    console.log(`CALLED:${newMessage}`);
  }
}

obj.message = 'CHANGED' // => CALLED:CHANGED とコンソールに表示される

data はセッターにもなっている

data にはゲッターに加えてセッターも設定されています。

結論からいうと、 Dep はセッターを使って Watcher に値の変更があったことを伝えます。

改めて、最初のコードを記載します。

<!DOCTYPE html>
<html>
<body>
  <div id="target"></div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#target',
      data() {
        return {
          message: 'EXAMPLE'
        }
      },
      mounted() {
        setTimeout(() => this.message = 'CHANGED', 1000)
      },
      render(h) {
        return h('div', this.message)
      },
    })
  </script>
</body>
</html>

setTimeout で1秒後に this.message = 'CHANGED' が実行されますが、
このタイミングでセッターの処理が走ります。

セッターでは何をしているかというと...

//github.com/vuejs/vue/blob/master/src/core/observer/index.js#L129-L186
/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
  ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    ...
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if ("development" !== 'production' && customSetter) {
        customSetter();
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();  // <--
    }
  })
}

最終行で、dep.notify を呼んでいます。
dep.notify では、

//github.com/vuejs/vue/blob/master/src/core/observer/dep.js#L22-L24
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;
  ...
  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update() // <--
    }
  }
}

subs の配列に入っている、全ての Watcherupdate を呼んでいます。
update というメソッドを呼び出して、Watcher に変更があったことを伝えているわけですね。

では、通知を受け取った Watcher は何をしているかというと、

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L156-L165
export default class Watcher {
  ...
  update () {
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this) // <--
    }
  }

Watcher にある、 lazysync というフラグによって、
通知を受け取った後の挙動が変わります。

Watcher は、今回の例のようにコンポーネントの更新のほか、
computedprops の実装にも使用されています。

そのため、通知を受け取ったらすぐに更新処理を実行するか、
もしくは、 dirty というフラグだけを付与しておいて、必要になったタイミングで更新処理を実行するなど、フラグによって更新処理のタイミングを変えることができます。

今回のコンポーネントの更新の場合、 queueWatcher(this) が実行されます。
これは Vueのドキュメント にもある通り、一旦キューに溜めておいて、
次のイベントループでコンポーネントの更新を行います。

いずれのタイミングでも、更新処理を実行する際は Watcherget が呼ばれます。
少し前に見たコードですね。

//github.com/vuejs/vue/blob/master/src/core/observer/watcher.js#L90-L115
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    ...
  } finally {
    ...
    popTarget()  // 注3
    this.cleanupDeps()
  }
  return value
}

pushTarget で宣言して、 this.getter でコンポーネントを更新して、という流れが始まります。
これ以降は、「Dep.target は何処で設定しているの?」 のセクションでお伝えした流れと同じです。

おわりに

今回は data と コンポーネントの更新の流れを追いました。
computedprops の場合も、細かい違いはあるものの、大体の流れは同じになります。

この記事が誰かの参考になれば幸いです!

Discussion