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
Observerクラスは単純な値にgetter/setterを定義することで、変更などを検知して依存している対象へ通知する役割を持っているようです。
この依存している対象は Dep
オブジェクトでラップされています。
Dep
は更に subs
というプロパティに配列で Watcher
オブジェクトを格納しています。
Watcher
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 }
何故こんなことをしているかと言うと、Observer
は Dep.target
に Watcher
が入っていると一度だけ依存対象として登録するようになっています。
要するに
- Wacther
computed
が実行される中でObserver化された値data
が取り出される時に - Watcher
computed
がObserverの内部でSubscriberとして登録されるので - Observer
data
が変更(setterが実行)されるとSubscribercomputed
に通知されて - Watcher
computed
が再計算される
という仕組みのようです。
仮説
あくまでソースコードを読んだ僕の見解ですので、これが正しいかどうかを試しに仮説をたてて検証してみたいと思います。
computed
を一度でも実行する前は、いくらdata
を変更しても再計算されないcomputed
を実行中にDep.target
を覗いてみると自分自身を内包したWatcher
オブジェクトを確認できる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ループしてるところもあったので、単に書いたエンジニアの好みかもしれない