blog

あなたは本当にVueの計算を理解している?

次のようなコードがあるとします。renderメソッドのパラメータcは頻繁に変化しますが、aとbは頻繁に変化しません。このコードを分析すると、render関数が呼び出されるたびにcalc関数が呼び出され...

Jun 23, 2020 · 8 min. read
シェア

ニーモニック関数で計算されたVue

初志貫徹

次のようなコードがあるとします。renderメソッドのパラメータcは頻繁に変化しますが、aやbは頻繁に変化しません。このコードを分析すると、render関数が呼び出されるたびにcalc関数が呼び出され、aやbのパラメータが変化しない場合は不要な計算が追加されていると結論づけることができます。

function calc (a, b) {
 console.log('calc', a, b)
 // 複雑な計算
 return a ** b
}
function render (a, b, c) {
 console.log('render', a, b, c)
 return {
 status: c % 10,
 result: calc(a, b)
 }
}

このような問題を解決する方法はたくさんありますが、キャッシュを使ってスマートに解決する方法を思いついたのではないでしょうか。ここでは、この問題を解決する方法として、メモライズ機能をご紹介します。

メモリ機能

その原理は、関数のパラメータ、計算結果をキャッシュし、再度呼び出して比較します。パラメータが変更されていれば再計算が必要ですが、パラメータが変更されていなければ、前回の計算結果が返されます:

function createMemo (targetFunc) {
 let lastArgs = null
 let lastResult = null
 return function (...args) {
 if (!argumentsShallowlyEqual(lastArgs, args)) {
			lastResult = targetFunc(...args)
 }
 lastArgs = args
 return lastResult
 }
}
function argumentsShallowlyEqual (prev, next) {
 if (prev === null || next === null || prev.length !== next.length) {
 return false
 }
 const length = prev.length
 for (let i=0; i<length; i++) {
 if (prev[i] !== next[i]) {
 return false
 }
 }
 return true
}

calcメソッドをラップして、もう一度テストしてください:

calc = createMemo(calc)
render(1,2,3)
// render 1 2 3
// calc 1 2
render(1,2,3)
// render 1 2 3
render(2,2,3)
// render 2 2 3
// calc 2 2
render(2,2,3)
// render 2 2 3

カプセル化されたメソッドcreateMemoは十分な拡張性がなく、場合によっては、オブジェクトを受け取った場合の関数のように、キャッシュするかどうかをきめ細かく制御する必要があります:

function calc (option) {
 // ここで多くのパフォーマンスを消費する
 console.log('calc')
}
calc({ value: 0 })
// calc
calc({ value: 0 })
// calc
calc({ value: 1 })
// calc

ニモニック関数のより適切な使用方法は、次のようなものです:

const memoizedRender = createMemo(option => option.value, render)
memoizedRender()
// calc
memoizedRender()
memoizedRender()

次に、createMemoメソッドを修正してみましょう:

function createMemo (...funcs) {
 const targetFunc = funcs.pop()
 const dependencies = [...funcs]
 
	const memoizedTargetFunc = defaultMemoize(targetFunc)
 const selector = defaultMemoize(function (...args) {
 const params = []
 const length = dependencies.length
 
 for (let i=0; i<length; i++) {
 params.push(dependencies[i](...args))
 }
 return memoizedTargetFunc(...params)
 })
 return selector
}
function defaultMemoize (func) {
 let lastArgs = null
 let lastResult = null
 
 return function (...args) {
 if (!argumentsShallowlyEqual(lastArgs, args)) {
 lastResult = func(...args)
 }
 lastArgs = args
 return lastResult
 }
}
function argumentsShallowlyEqual (prev, next) {
 if (prev === null || next === null || prev.length !== next.length) {
 return false
 }
 const length = prev.length
 for (let i=0; i<length; i++) {
 if (prev[i] !== next[i]) {
 return false
 }
 }
 return true
}

Reduxは派生データを計算します:

再選択 ...

Vueには同様の効果を得る計算ジェネリックスがありますが、その実装と使い方は少し異なります。

計算されたビュー

基本的な使い方

const Demo1 = new Vue({
 template: '<div>{{b}}</div>',
 data () {
 return {
 a: 1
 }
 },
 computed: {
 b () {
 return a + 1
 }
 }
})
const Demo2 = new Vue({
 template: '<div>{{b}}{{c}}</div>',
 data () {
 return {
 a: 1
 }
 },
 computed: {
 b () {
 console.log('b')
 return a + 1
 },
 c () {
 console.log('c')
 return b + a
 }
 }
})

この時点でaを変更した場合、cとbは計算プロパティに何回表示されますか?

原則

Vueコンポーネントのインスタンス化は、以下のプロセスを経て行われます。以下の簡略化されたコードからわかるように、computedプロパティは主にinitComputedメソッドで初期化されます。

https://.////////.#L8
function Vue (options) {
 this._init(options)
}
Vue.prototype._init = function (options) {
 // ...
 // https://.////////.#52
 initLifecycle(vm)
 initEvents(vm)
 initRender(vm)
 callHook(vm, 'beforeCreate')
 initInjections(vm) // resolve injections before data/props
 initState(vm)
}
// https://.////////.#48
function initState (vm) {
 vm._watchers = []
 const opts = vm.$options
 if (opts.props) initProps(vm, opts.props)
 if (opts.methods) initMethods(vm, opts.methods)
 if (opts.data) {
 initData(vm)
 } else {
 observe(vm._data = {}, true /* asRootData */)
 }
 if (opts.computed) initComputed(vm, opts.computed)
 if (opts.watch && opts.watch !== nativeWatch) {
 initWatch(vm, opts.watch)
 }
}

Computedの初期化フェーズでは、VueはComputedオブジェクトを走査し、各プロパティに対して遅延Watcherをインスタンス化し、各プロパティに対してComputedを定義することが重要です。

const computedWatcherOptions = { lazy: true }
function initComputed (vm: Component, computed: Object) {
 // $flow-disable-line
 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)
 }
 }
 }
}

最初に少しまとめておくと、compute属性はインスタンス生成時に主な処理を行います:

  1. 各プロパティを繰り返し処理し、各プロパティに対して遅延ウォッチャーをインスタンス化します。
  2. 各プロパティに対するDefineComputed

まず、defineComputedが何をするのかを見てみましょう。

defineComputed

このプロセスは単純で、オブジェクトをReactiveとして定義するのと似ています。

const sharedPropertyDefinition = {
 enumerable: true,
 configurable: true,
 get: noop,
 set: noop
}
export function defineComputed (
 target: any,
 key: string,
 userDef: Object | Function
) {
 const shouldCache = !isServerRendering()
 if (typeof userDef === 'function') {
 sharedPropertyDefinition.get = shouldCache
 ? createComputedGetter(key)
 : createGetterInvoker(userDef)
 sharedPropertyDefinition.set = noop
 } else {
 sharedPropertyDefinition.get = userDef.get
 ? shouldCache && userDef.cache !== false
 ? createComputedGetter(key)
 : createGetterInvoker(userDef.get)
 : noop
 sharedPropertyDefinition.set = userDef.set || noop
 }
 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
 }
 }
}

ここでは主にcomputedGetterメソッドに焦点を当てます。 ここで、ウォッチャーのプロパティとメソッドのいくつかを見てみましょう:

  • watcher.dirtyは、ウォッチャーを再評価する必要があるかどうかのフラグで、依存関係が変更されると、再評価する必要があるためdirtyがtrueに代入されます。
  • watcher.evaluateが行うのは評価で、評価が終わるとdirtyにfalseを代入します。
  • watcher.dependは、現在のDep.targetに依存します。例えば、現在レンダリング処理の途中で、Dep.targetがレンダリングウォッチャーである場合、現在計算されているプロパティのウォッチャーは、レンダリングウォッチャーによって収集されます。

computedプロパティの値を取得する場合、computedGetterメソッドがトリガーされ、最初の呼び出しによってwatcher.evalute計算がトリガーされます。

ここで少し混乱するかもしれませんが、実はWatcherがコンピューテッド・プロパティの実装の鍵であり、コンピューテッド・プロパティを理解するには、Watcherの実装を深く掘り下げる必要があります。

怠惰なウォッチャー

単純化すると、属性の計算に関連するコードは次のようになります:

// https://.////////.#26
class Watcher {
 constructor(
 vm: Component,
 expOrFn: string | Function,
 cb: Function,
 options?: ?Object,
 isRenderWatcher?: boolean
 ) {
		this.vm = vm
 this.lazy = !!options.lazy
		this.dirty = this.lazy
 this.getter = expOrFn
 // lazy watcher 即時ではない
 this.value = this.lazy ? undefined : this.get()
 }
 
 get () {
 pushTarget(this)
 let value
 value = this.getter.call(vm, vm)
 if (this.deep) {
 traverse(value)
 }
 popTarget()
 this.cleanupDeps()
 return value
 }
 
 update () {
 /* istanbul ignore else */
 // 計算プロパティの依存関係が更新されたとき
 if (this.lazy) {
 this.dirty = true
 } else if (this.sync) {
 this.run()
 } else {
 queueWatcher(this)
 }
 }
 
 // 計算された属性がトリガされる。
 evaluate () {
 this.value = this.get()
 this.dirty = false
 }
}

コードのこの部分は3つのプロセスで説明できます。ウォッチャーのインスタンス化プロセス、属性の値を計算するプロセス、依存関係を更新するプロセスです。

  1. ウォッチャーのインスタンス化プロセス

計算プロパティのlazyプロパティはfalseにコピーされます。つまり、lazy Watcherがインスタンス化され、それが現在lazy Watcherである場合は、すぐに評価されません。

  1. 属性値の計算処理

computedプロパティのcomputedGetterフェッチ関数がトリガーされると、watcher.evaluateメソッドが呼び出され、実際にゲッター関数が呼び出されて結果が計算され、watcher.valueにキャッシュされます。

watcher.getが呼び出されると、Dep.targetはこの計算プロパティの現在のウォッチャーに変更されるため、関数内のすべての依存関係は、this.getterが呼び出されたときに現在のウォッチャーによって収集されます。

watcher.evaluateの呼び出しが完了すると、dirtyは即座にfalseに設定され、計算された属性の値の後続のトリガが再計算されないようになり、キャッシュの効果が得られます。

  1. 依存関係の更新プロセス

計算プロパティの依存関係が更新されると、計算プロパティ・ウォッチャー.update メソッドがトリガーされます。このメソッドは評価せず、単に現在のダーティを false に代入して、現在のウォッチャーの依存関係が変更されたことを示し、次に計算プロパティが呼び出されたときに再評価をトリガーします。このため、計算プロパティの依存関係が更新されても、計算プロパティはすぐに再計算されず、計算プロパティが呼び出されたときにのみ再計算されます。

まとめ

何より、この記事ではVueの計算特性とメモリ機能について詳しく分析し、Vueの計算特性とVue独自のレスポンス機能を非常に巧みに組み合わせて実現し、Reduxもパフォーマンスの最適化によってシンプルなメモリ機能を実現することができます。

Read next

ゼロからのAndroid - ハンドラー

プライオリティ・キューのデータ格納構造。 このキューの実装は連鎖テーブルですが、ルックアップや挿入などはキューの特徴に準拠しています。 新しいメッセージがキューに入力されると、forループして時間順の判定を行い、...

Jun 23, 2020 · 2 min read