Vue.js 2.0 の算出プロパティ周辺のコードリーディング

この記事は Vue.js Advent Calendar 2016 の21日目です。

https://skyronic.com/blog/vuejs-internals-computed-properties の記事に算出プロパティのミニマムな実装例が示されているのですが、実際に Vue のコードでどのように実装されているか調べました(省けるところは都度省いたのですが、けっこう長いです…)。対象の Vue のバージョンは v2.1.6 です。

そもそも算出プロパティとは

そもそも算出プロパティとはなんぞや、という話から。Vue には算出プロパティという機能があり、以下のように data の状態から派生した値をプロパティとして定義できます。

var vm = new Vue({
  el: '#app',
  data: {
    firstName: 'Foo',
    lastName: 'Bar'
  },
  computed: {
    fullName: function () {
      return this.firstName + ' ' + this.lastName
    }
  }
})
<div id="app">
  <p>{{ fullName }}</p>
</div>

この算出プロパティには、依存している状態が変更されない限り、一度計算した値がキャッシュされるという特徴があります。以下、公式ドキュメントからの引用です。

算出プロパティの代わりに、同じような関数をメソッドとして定義することも可能です。最終的には、2つのアプローチは完全に同じ結果になります。しかしながら、算出プロパティは依存関係にもとづきキャッシュされるという違いがあります。算出プロパティは、それが依存するものが更新されたときにだけ再評価されます。これはつまり、message が変わらない限りは、reversedMessage に何度アクセスしても、関数を再び実行することなく以前計算された結果を即時に返すということです。

必要なときだけ再計算を行う。とてもスマートですが、どのように実現されているのでしょうか? 次のような疑問が湧いてきます。

  • 算出プロパティ(computed)と状態(data)の依存関係をどのように管理しているか
  • 再計算をどのようにおこなうか

この記事のコードリーディングを通して疑問を解消する、あるいはその手がかりを示せたらと思います。

リアクティブプロパティ

算出プロパティは、それが依存するものが更新されたときにだけ再評価されます。

とあるので、「算出プロパティが依存している状態」は、自身に依存している算出プロパティを把握する必要があります。
Vue は算出プロパティの評価時に状態の参照を検知して、依存関係を記録する一方で、状態が変更された場合には、その状態に依存している算出プロパティに再評価を促します。ここで述べたような、状態の参照・変更が検知できるプロパティをリアクティブプロパティと呼んでいます。Vue インスタンスの初期化時にdataオプション の各プロパティに getter/setter を定義して、これを実現しています。

それを行っているのが、https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/index.js#L128 です。

具体的な getter / setter の内容については後で説明します。

算出プロパティの定義

算出プロパティを computed オプションから実際に定義しているのは、makeComputedGetter 関数( https://github.com/vuejs/vue/blob/v2.1.6/src/core/instance/state.js#L135 )です。

function makeComputedGetter (getter: Function, owner: Component): Function {
  const watcher = new Watcher(owner, getter, noop, {
    lazy: true
  })
  return function computedGetter () {
    if (watcher.dirty) {
      watcher.evaluate()
    }
    if (Dep.target) {
      watcher.depend()
    }
    return watcher.value
  }
}

Watcher インスタンスを生成して、生成したインスタンスを含んだクロージャを返しています。Watcher は、Vue 内部では $watch の実装も含めて様々な所で使われていますが、算出プロパティの実体も Watcher インスタンスということになります。ここでの Watcher インスタンスは以下の役割を持っています。

  • computed オプションで指定した関数(サンプルコードでの fullName プロパティの値)の保持( getter プロパティ )
  • 上の関数の評価( Watcher.prototype.evaluate )
  • 上の関数の評価結果の保持( value プロパティ )
  • 再評価が必要か判断するフラグの保持( dirty プロパティ )
  • 自身が依存するリアクティブプロパティの把握( deps プロパティ )

クロージャの内容から、再評価が必要なときだけ評価がおこなわれ、それ以外の場合は事前に評価された結果が返されることが分かります。ちなみに dirty は初期レンダリング時も真で、評価がおこなわれます。

依存関係の記録

依存関係の記録は、評価をおこなう過程でリアクティブプロパティが参照されたタイミングでおこなわれます。関数の呼び出しは、Watcher インスタンスの evaluate メソッド(算出プロパティの評価)、computed オプションで指定した関数、さらにその内部でのリアクティブプロパティの getter と続きます。

Watcher.prototype.evaluate は、Watcher.prototype.get を呼び出して、dirty プロパティを偽にするだけの単純な内容です( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/watcher.js#L191 )。
Watcher.prototype.get をみてみましょう。

  get () {
    pushTarget(this)
    const value = this.getter.call(this.vm, this.vm)
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
    return value
  }

pushTarget 関数は、依存するリアクティブプロパティを記録したい Watcher インスタンス(ここでは算出プロパティ)を登録する関数です。push という名前から分かる通り登録した内容はスタックに追加され、pop で取り出されます。スタックのトップは、Dep.target で参照できます。
this.getter は、computed オプションで定義した関数(サンプルでは fullName )です。呼び出すとリアクティブプロパティ(firstName, lastName)が参照されます。ここで、リアクティブプロパティの getter の定義をみてみましょう( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/index.js#L149 )。

  const dep = new Dep()

  // ...

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
        if (Array.isArray(value)) {
          dependArray(value)
        }
      }
      return value
    },
    //...
  });

Dep は、リアクティブプロパティが依存している Watcher インスタンスを記録し、リアクティブプロパティに変更があった時、 Watcher インスタンスに変更を通知する役割を持っています。getter の定義から分かるように、Dep インスタンスは、リアクティブプロパティにつき、必ずひとつ生成されます。
Dep と Watcher の関係はすこし分かりづらいですが、Dep インスタンス(リアクティブプロパティ)は自身「に」依存している Watcher インスタンス(算出プロパティ)を知っており、一方でWatcher インスタンス(算出プロパティ)は自身「が」依存している Dep インスタンス(リアクティブプロパティ)を知っています。

Dep.prototype.depend ( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/dep.js#L30 ) の呼び出しは、定義を調べると分かりますが、Dep.target (この場合は、算出プロパティの Watcher インスタンス) の addDep メソッドの呼び出しです( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/watcher.js#L100 )。これで算出プロパティは自身が依存しているリアクティブプロパティを知ることができたのと、さらに Dep.prototype.addSub の呼び出しでリアクティブプロパティは自身に依存している算出プロパティを知ることができました。Sub は 変更の通知を受け取る Subscription の Sub です。  

  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

depIds, newDepIds という 二種類の配列があるのは、前回の評価から依存関係に変更があったか調べて、適切に後始末を行うためです。後始末を行っているのは、Watcher.prototype.get で呼び出されている Watcher.prototype.cleanupDeps です( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/watcher.js#L120 )。

ここまでで、算出プロパティがどのように依存関係を記録しているか、みてきました。

リアクティブプロパティの変更の検知と算出プロパティの再計算

リアクティブプロパティの変更は、setter が検知します( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/index.js#L162 )。setter では、値の変更がない場合は関数を return して、変更がある場合はプロパティを変更し、Dep.prototype.notify を呼び出します。

    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }

      // ...

      val = newVal
      
      // ...

      dep.notify()
    }

Dep.prototype.notify は、リアクティブプロパティ( Depインスタンス )に依存している算出プロパティ( Watcher インスタンスの配列 subs )に更新を通知します( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/dep.js#L36 )。 通知は Watcher インスタンスの update メソッドの呼び出しで行います。Subscription や update から分かるように Watcher と Dep には Observer パターンが適用されています。

  notify () {
    // stablize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }

Watcher.prototype.update をみてみましょう( https://github.com/vuejs/vue/blob/v2.1.6/src/core/observer/watcher.js#L142 )。算出プロパティの場合は lazy プロパティが真なので、再評価が必要か判断するフラグ dirty プロパティに true を代入します。これで次回のレンダリング時、算出プロパティが参照されると、再評価がおこなわれることになります。

  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

まとめ

Vue の算出プロパティは、以下の要素で成り立っていることが分かりました。Vue に限らず、自身で同じような仕組みを実装するときに参考になりそうですね。

  • プロパティの参照と変更をgetter/setterで把握できる仕組み
  • 計算と評価結果、再評価が必要か判断するフラグを保持するオブジェクト
  • 参照が行われたときに、依存関係を記録する仕組み
  • 変更が行われたときに、変更を通知する仕組み(Observer パターンの適用)