dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

【Vue.js】算出プロパティの仕組みについて調べてみた

この記事はdely Advent Calendar 2018の21日目の記事です。

Qiita: https://qiita.com/advent-calendar/2018/dely
Adventar: https://adventar.org/calendars/3535

前日は、iOSデザインエンジニアの John が
デザインについてエンジニアなりに意識していること という記事を書きました。 デザイナーとエンジニアがどううまく連携していくかがわかりやすく書かれています。

はじめに

こんにちは、delyでサーバサイドエンジニアをやっている山野井といいます。
普段の業務ではweb版 kurashiru(www.kurashiru.com)のサーバサイド周りを主に担当していて、たまにフロントエンドを触ったりもしています。

web版 kurashiruでは Javascript のフレームワークに Vue.js を使用しています。
通常、Vue.jsは内部実装をそこまで深く理解する必要がなくとも十分に扱えますが、自分のスキルアップの為や、普段の業務で何かヒントになりそうだと思い調べてみました。

本記事では、算出プロパティ(以下computedプロパティ)がどのように結果をキャッシュして依存された値の変更を検知しているか調べて分かったことを、解説していきたいと思います。

(注)解釈が間違っている可能性もございますが、温かい目で見守っていただけると嬉しいです。  

今回解説に使用するサンプルコード

今回は、下記のサンプルコードをベースに解説していきます。
Vue.js@2.5.17

このコードは、マウントしたエレメント(#sampleApp)に対して computed_message を表示するようなシンプルなサンプルになっています。

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
  </head>
  <body>
    <div id="sampleApp"></div>
   
    <script>
      var vue = new Vue({
        data: {
          message: 'Hello, World'
        },
        computed: {
          computed_message() {
            return `Computed ${this.message}`
          }
        },
        render(h) {
          return h('div', this.computed_message)
        }
      })
      vue.$mount('#sampleApp')
    </script>
  </body>
</html> 

このコードをブラウザで開くと、'Computed Hello, World'という文字列がブラウザ上に表示されます。 f:id:yamanoi-y:20181216174114g:plain 作成された Vueインスタンス の $data.message を devTool 等で書き換えると、同時に computed_message の値も更新され、画面上の文字も更新されることがわかると思います。

computedプロパティの基本の動き

computedプロパティは作成されると Vueインスタンス に同名のプロパティが定義されます。

サンプルコードでは computed_message が定義されているため、Vueインスタンスに computed_message という名前のプロパティが生えます。 (this.computed_message として Vueインスタンス内からアクセスすることができるようになります。)

また computedプロパティ は一度アクセスされると、依存されたプロパティが変更されない限りは常に同じ値を返し続けます。
つまりどういうことかというと、computed_message は一度実行された後その結果("Computed Hello, World")を内部で保持しておき、2回目以降はその保持した結果を返し続けます。 これにより、高速に処理を行うことができる様になっています。

依存しているプロパティ(message)の値が "Hello, World" から "hogehoge" に変更されたら結果を更新する必要があるため、再度関数を評価し、その結果("Computed hogehoge")を保持し、返しているわけです。​

そのため computedプロパティ は依存しているプロパティの変更を逐一知る必要があります。

computedプロパティが data の変更を知る仕組み

Vue.js ではこの変更を知る仕組みをデザインパターンで言うオブザーバーパターンで実装されていて、dataプロパティ の各値が更新されると Watcherクラス(src/core/observer/watcher.js)  のインスタンス(以下watcher)へと通知される仕組みになっています。

f:id:yamanoi-y:20181215235428j:plain

各 computedプロパティ は  Vueインスタンス を生成する際にこの watcher を生成しています。 今回のサンプルコードでは computedプロパティ である computed_message が watcher を生成し、message の変更はこの watcher へと通知されます。

通知を受け取った watcher は update() を実行します。

update() 内では dirty を true にして依存されたプロパティに変更があった事を保持しておきます。

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

  今回 message の変更を通知する先は computed_message が所持する watcher のみになりますが、message に依存している複数の computedプロパティ が定義されている場合は全ての watcher に対して mesage の変更を通知させる必要があります。

そこで依存関係を構築する Depクラス(src/core/observer/dep.js ) が登場します。 Depクラス は下記の様な、ユニークidと subs という watcher の配列を持ったクラスになります。

  export default class Dep {
    static target: ?Watcher;
    id: number;
    subs: Array<Watcher>;
 
    ...
    ...
    ...
  
    notify () {
      const subs = this.subs.slice();
      for (let i = 0, l = subs.length; i < l; i++) {
        subs[i].update();
      }
    }
  }

 data の各プロパティは Vueインスタンス を生成する際に Depクラスインスタンス(以下dep) を生成します。
サンプルコードでは message が data として定義されているため、message の dep が生成されます。 この dep の subs に 通知すべきwatcher を格納することで data と 複数の watcher の対応関係を作ることができます。  data(message) が更新されると生成された dep の notify() を通して依存している全ての watcher へ通知されます。

f:id:yamanoi-y:20181215235431j:plain

dataプロパティの変更を検知する

 そもそも dataプロパティ の変更の検知自体はどの様に行われているかと言うと Object.definePropertyを用いて実現されています。

Object.definePropertyを使用すると任意のオブジェクトに対して独自の getter や setter を生やすことができます。

data = {}
Object.defineProperty(data, 'message', {
  enumberable: true,
  configurable: true,
  get: function() {
    console.log('called getter')
    return this._message
  },
  set: function(value) {
    console.log('called setter')
    this._message = value
    return
  }
});
 
data.message = 1 // called setter
data.message  // called getter

これを使用して、各dataプロパティの setter に 値をセットした後にwatcher に通知する処理を書いてあげることで値の変更通知処理を実現することができます。

実際のコードは下の様になっています。

src/core/observer/index.js

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
 
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }
 
  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
 
  let childOb = !shallow && observe(val)
  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
    },
    set: function reactiveSetter (newVal) {
      const 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 (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

setter であるreactiveSetter の中で dep.notify() を呼んでいるのが分かると思います。

依存関係の構築

ここまでで message が変更された時、message の持つ dep を通して subs に対して通知を送れる様になりました。

最後に computedプロパティ と dataプロパティ がどのようにして依存関係を構築しているのかを見ていきます。

これは watcher が computedプロパティ の getter を評価する時と、先程の dataプロパティ の getter である reactiveGetter 内に出てきたdep.depend() が関係してきます。

まずは computedプロパティ の初期化の部分から追ってみます。

 computedプロパティ の初期化は Vueインスタンス の初期化フローにて行われます。

src/core/instance/state.js

function initComputed (vm: Component, computed: Object) {
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()
 
  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }
 
    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }
 
    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}

  for文で定義されている computedプロパティ を1つずつ取り出して Watcherインスタンス を作成しています。
 Watcher を new する際に Vueインスタンス である vm , computedプロパティ の関数本体である getter , その他オプションを渡しています。
その後呼び出されている defineComputed のコードは以下になります。

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.set
      ? userDef.set
      : noop
  }
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
 
function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

  先程登場した Object.defineProperty で target(vm) に対して computedメソッド名 をキーにしてプロパティを定義しています。

これは'computedプロパティの基本の動き'で述べた

computedプロパティは作成されると Vueインスタンス に同名のプロパティが定義されます。

の正体です。

さてこれで Vueインスタンス内から this.computed_message と呼び出すことができるようになったのですが this.computed_messageと呼び出された時の処理は Object.defineProperty によって以下のようになっていますね。

sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
 

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

watcher.dirty の場合 watcher.evaluate() を実行しています。watcher.evaluate() は watcher の作成時に渡したgetter(computedプロパティの関数本体)を評価し、結果を value に代入しています。

一度実行した結果を value に保持していて、 watcher.dirty な状態にならない限りは watcher.value を返し続けています。

こちらも最初に述べました

また computedプロパティ は一度アクセスされると、依存されたプロパティが変更されない限りは常に同じ値を返し続けます。

の部分になります。

このdirtyがtrueになる時というのは watcher.update() が呼び出された時、つまり dataプロパティ に変更があり、 watcher へ通知された時となります。

下の図は message に変更があった時に this.computed_message が呼び出された時の様子です。

f:id:yamanoi-y:20181218153330p:plain

watcher.evaluate()で行っている処理は大きく分けて以下の4ステップになります。

  1. pushTarget
  2. getter.call
  3. popTarget
  4. dirty=falseにする

1.pushTarget

staticな値 Dep.target に対してwatcher自身を代入します。

2.getter.call

getter.call をしてcomputedプロパティの関数を評価します。

関数を評価するということはその中で this.message が呼び出され、this.message の getterの中に定義されていた dep.depend() も呼び出されます。

...
 
depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

...

dep.depend()Dep.Target が存在する時に、 Dep.targetaddDep() を自身を引数にして呼び出します。 初めに Dep.target にはステップ1にて watcher を格納していたため、その watcher の addDep() が呼び出されます。

src/core/observer/watcher.js

class Watcher {
  ... 
  
  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)
      }
    }
  }
  
  ....
}

src/core/observer/dep.js

class Dep {
  ...

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  
  ...
}

渡された dep が watcher の newDepIds にまだ存在しない場合は追加して、その後 dep.addSub(this) としています。

これで

dep.subs = [watcher]

の依存関係を作ることができました。

例えば  computed_message が次の様に定義されているとすると

new Vue({
  data: {
    message: 'Hello, World',
    name: 'tarou',
  },
  computed: {
    computed_message() {
       return `Computed ${this.message} by ${this.name}`
     }
  },
})

下の図のようにそれそれのdepがwatcherと依存関係を構築します。

f:id:yamanoi-y:20181218161325p:plain

そして以下の様な依存関係が構築されることになります。

message => dep => [watcher]
name    => dep => [watcher]

1つのdataプロパティが複数の computedプロパティ に依存している場合は

new Vue({
  data: {
    message: 'Hello, World',
  },
  computed: {
    computed_message() {
      return `Computed ${this.message}`
    },
    introduction() {
      return `My First ${this.message}`
    },
  },
})

以下の様な依存関係になります。

message => dep => [watcher(computed_Message), watcher(introduction)]

この様にして Vue.js では computedプロパティ の依存関係を構築しています。

3.popTarget

Dep.targetに代入されいてたwatcherを取り除きます。

4.dirty = false にする

次回 this.computed_message にアクセスがあった時に計算済みの値 value を返すようにするため、dirtyをfalseにします。

watcher.evaluate() の実行を経て、 data プロパティと watcher の依存関係を構築することができました。

まとめ

長くなりましたが、解説は以上となります。 実際にソースコードレベルで調べることでよりフレームワークへの理解を深めることができたような気がします。
Vue.js 3.0では今回解説したObject.definePropertyを用いた監視方法から変更になるようなので機会があればそちらも解説できたらと思います。
最後までお付き合いありがとうございました。

明日は、弊社の機械学習エンジニアの辻より'Lispの車窓から見る人工知能'の記事がアップされる予定です。 こちらもぜひご覧ください!