もふもふ技術部

IT技術系mofmofメディア

Vue.jsはどのようにComputed Propertyの依存関係を解決しているのか

connecting--medium

photo by Josh Hallett

Vue.jsのcomputed property(以下computedと表記)がどのように依存しているdataが更新されたときだけ再計算されるのかを調べてみました。

基本的なcomputed propertyの使い方

まずcomputed propertyがどの様に使われるものなのかを簡単に説明します。
通常、コンポーネント内で状態は data というオブジェクトに格納します。

const TodoApp = {
  data: () => {
    return {
      todos: [],
      doneCount: 0,
      newTask: '',
    }
  }
}

ここではTodoタスクのデータを全て格納する todos と、完了したタスクの数を格納する doneCount それからユーザーが入力する新しいタスク文字列 newTodo をそれぞれ初期化しています。
このコードの問題点があり、 todos に変更をかけるたびに doneCount の数も再計算して格納しておかないといけないというところです。めんどくさいですね。

addTodo (newTodo) {
  // newTodoを追加する処理...
  this.doneCount = this.todos.filter(it => it.done).length
},

doneTodo (index) {
  // indexに該当するタスクを完了させる処理...
  this.doneCount = this.todos.filter(it => it.done).length
}

上記のように追加や更新のたびに同じコードが登場することになります。当然ながらDRYではないのでそのうちバグが生まれそうです。

Vue.jsではcomputedを使ってこのような問題をすっきり解決できます。

computed: {
  doneCount () {
    return this.todos.filter(it => id.done).length
  }
}

目的の異なるcomputedが複数あるとします

さて、例として登場したTodoAppにdoneCountの他にも quotedNewTodo というcomputedが存在したとします。
これはユーザーが入力している新しいタスクの文字列を"で囲って返すものです。

computed: {
  doneCount () {
    return this.todos.filter(it => id.done).length
  },

+  quotedNewTodo () {
+    return `"${this.newTodo}"`
+  },
}

このような場合、当然ながら todos を更新すると doneCount の返す内容も変わるわけですが、computedには以下のような特徴があります。

  • todos が更新された直後に doneCount が再計算される
  • doneCount の結果はキャッシュされているので、何回呼び出してもキャッシュを返す
  • todosが更新されればまた doneCount が再計算される

この仕組みのおかげでcomputedの計算コストが最小限で済むようになっています。

依存関係が違う場合は実行されない

さらにもう一つ大きな特徴として、依存していないdataが変更された時は再計算されないというものがあります。
今回の例だと todos を更新しただけでは quotedNewTodo は再計算されないし、
newTodo を更新しても doneCount は再計算されません。

この挙動を確認できるコードをjsfiddleで書きました。

フォームに入力をすると quotedNewTodo のみが呼ばれますが、Enterしてタスクを追加した場合は doneCount のみが呼ばれています。

どのように実装しているのか

さて、やっと本題の実装を見ていくところまで来ました。
今回はこのブログを書いている今日(2019-08-23)のdevブランチ最新のcommit状態から処理を見ていきました。 https://github.com/vuejs/vue/tree/369dbe711a037b04612bca2f2e961282bdbb9153

全ての処理を解説するわけにもいかないので、重要と思われるオブジェクトと関数を紹介していきます。

Observer

https://github.com/vuejs/vue/blob/369dbe711a037b04612bca2f2e961282bdbb9153/src/core/observer/index.js#L37

Observerクラスは単純な値にgetter/setterを定義することで、変更などを検知して依存している対象へ通知する役割を持っているようです。
この依存している対象は Dep オブジェクトでラップされています。
Dep は更に subs というプロパティに配列で Watcher オブジェクトを格納しています。

Watcher

https://github.com/vuejs/vue/blob/369dbe711a037b04612bca2f2e961282bdbb9153/src/core/observer/watcher.js

Watcherクラスはcomputed等の関数をラップしています。
ユーザーが定義した関数をそのままgetterとして使用していますが、getという関数の中でgetterを実行しています。

この関数で pushTarget() popTarget() をgetter呼び出しの前後で実行しています。 これによってget関数の呼び出しの最中だけ Dep.target の中身が実行中の Wacther オブジェクトで置き換わっているようです。

get () {
  pushTarget(this) // <= 最初にDep.targetを自分自身にする
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget() // <= 最後にDep.targetを空にしておく
    this.cleanupDeps()
  }
  return value
}

何故こんなことをしているかと言うと、ObserverDep.targetWatcher が入っていると一度だけ依存対象として登録するようになっています。

要するに

  1. Wacther computed が実行される中でObserver化された値 data が取り出される時に
  2. Watcher computed がObserverの内部でSubscriberとして登録されるので
  3. Observer data が変更(setterが実行)されるとSubscriber computed に通知されて
  4. Watcher computed が再計算される

という仕組みのようです。

仮説

あくまでソースコードを読んだ僕の見解ですので、これが正しいかどうかを試しに仮説をたてて検証してみたいと思います。

  1. computed を一度でも実行する前は、いくら data を変更しても再計算されない
  2. computed を実行中に Dep.target を覗いてみると自分自身を内包した Watcher オブジェクトを確認できる
  3. data を変更しなくても dep.notify() を実行すれば computed が再計算される

1. computed を一度でも実行する前は、いくら data を変更しても再計算されない

Lifecycle Diagram を参考に beforeCreated created mounted にそれぞれ newTodo を変更するコードを仕込んでみました。

beforeCreated () {
  this.newTodo = 'beforeCreated'
},
created () {
  this.newTodo = 'created'
},
mounted () {
  this.newTodo = 'mounted'
},
computed: {
  quotedNewTodo () {
    log(`quotedNewTodo ${this.newTodo}`)
    return `"${this.newTodo}"`
  }
},

すると beforeCreated の時だけログが出力されませんでしたので仮説1は正しいようです

2. computed を実行中に Dep.target を覗いてみると自分自身を内包した Watcher オブジェクトを確認できる

ローカルのVue.jsアプリのcomputedに以下のコードを仕込んで確認しました。

computed: {
  quotedNewTodo () {
    console.log(this.$data.__ob__.dep.constructor.target.expression)
    return `"${this.newTodo}"`
  }
}

consoleにはこの関数を toString() した文字列が出力されたのでやはりグローバルにアクセスできる Date.target を書き換えているようです。

3. data 変更をしなくても dep.notify() を実行すれば computed が再計算される

こちらもローカルのVue.jsアプリのcomputedに以下のコードを仕込んで確認しました。

created () {
  const sub = this.items.__ob__.dep.subs.find(it => /quotedNewTodo/.test(it.expression))
  sub.lazy = false // lazyがtrueだとdirtyフラグが建つだけで再計算されない
  sub.sync = true // syncがfalseだとqueueに入るだけで再計算されない
  this.items.__ob__.dep.notify()
},

computed: {
  quotedNewTodo () {
    console.log('quotedNewTodo has been called')
    return `"${this.newTodo}"` 
  }
}

lazy / sync の値を弄る必要がありましたが、なんとかできました。 newTodo の値は変更されていませんが、computedが再計算されています。

全ての仮説が証明されましたので、やはり上記の方法でcomputedの依存解決機能を実装しているようです。

まとめ

Vue.jsのソースコードは小難しい感じはあんまりなくて読みやすかったです。
関数の名前も直感的だし、引数はflowで型が付けられているので何が渡ってくるのかもすぐに分かりました。

学んだこと:

  • computed / watcher は内部的には同じもの
    => 監視対象の変更をきっかけに再計算するという点では確かに同じもの
  • Dep.target というグローバル変数的なものを使って実装されていた
    => オブジェクトを疎結合に設計するためにはある程度仕方ないという判断かなと思う
  • 今回の件とは関係ないけど、forループの代わりにwhileを使って配列のループ処理が書かれていた
    => ベンチマークを取ってみたところおそらくforループのほうが速いので、多分だけど文字数を少しでも減らしてライブラリの容量を減らしたかったのかもしれない
'for(let i=0;i<a.length;i++){var b=a[i];}'.length
=> 40
'var i=a.length;while(i--){var b=a[i];}'.length
=> 38

でも普通にforループしてるところもあったので、単に書いたエンジニアの好みかもしれない