perf: avoid unnecessary re-renders when computed property value did not change (#7824)

close #7767
This commit is contained in:
Evan You 2018-03-23 19:03:27 -04:00 committed by GitHub
parent f43ce3a5d8
commit 653aac2c57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 153 additions and 51 deletions

View File

@ -2,7 +2,7 @@
import config from '../config' import config from '../config'
import Watcher from '../observer/watcher' import Watcher from '../observer/watcher'
import Dep, { pushTarget, popTarget } from '../observer/dep' import { pushTarget, popTarget } from '../observer/dep'
import { isUpdatingChildComponent } from './lifecycle' import { isUpdatingChildComponent } from './lifecycle'
import { import {
@ -164,7 +164,7 @@ export function getData (data: Function, vm: Component): any {
} }
} }
const computedWatcherOptions = { lazy: true } const computedWatcherOptions = { computed: true }
function initComputed (vm: Component, computed: Object) { function initComputed (vm: Component, computed: Object) {
// $flow-disable-line // $flow-disable-line
@ -244,13 +244,8 @@ function createComputedGetter (key) {
return function computedGetter () { return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key] const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) { if (watcher) {
if (watcher.dirty) { watcher.depend()
watcher.evaluate() return watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
} }
} }
} }

View File

@ -29,10 +29,11 @@ export default class Watcher {
id: number; id: number;
deep: boolean; deep: boolean;
user: boolean; user: boolean;
lazy: boolean; computed: boolean;
sync: boolean; sync: boolean;
dirty: boolean; dirty: boolean;
active: boolean; active: boolean;
dep: Dep;
deps: Array<Dep>; deps: Array<Dep>;
newDeps: Array<Dep>; newDeps: Array<Dep>;
depIds: SimpleSet; depIds: SimpleSet;
@ -57,16 +58,16 @@ export default class Watcher {
if (options) { if (options) {
this.deep = !!options.deep this.deep = !!options.deep
this.user = !!options.user this.user = !!options.user
this.lazy = !!options.lazy this.computed = !!options.computed
this.sync = !!options.sync this.sync = !!options.sync
this.before = options.before this.before = options.before
} else { } else {
this.deep = this.user = this.lazy = this.sync = false this.deep = this.user = this.computed = this.sync = false
} }
this.cb = cb this.cb = cb
this.id = ++uid // uid for batching this.id = ++uid // uid for batching
this.active = true this.active = true
this.dirty = this.lazy // for lazy watchers this.dirty = this.computed // for computed watchers
this.deps = [] this.deps = []
this.newDeps = [] this.newDeps = []
this.depIds = new Set() this.depIds = new Set()
@ -89,9 +90,12 @@ export default class Watcher {
) )
} }
} }
this.value = this.lazy if (this.computed) {
? undefined this.value = undefined
: this.get() this.dep = new Dep()
} else {
this.value = this.get()
}
} }
/** /**
@ -162,8 +166,24 @@ export default class Watcher {
*/ */
update () { update () {
/* istanbul ignore else */ /* istanbul ignore else */
if (this.lazy) { if (this.computed) {
this.dirty = true // A computed property watcher has two modes: lazy and activated.
// It initializes as lazy by default, and only becomes activated when
// it is depended on by at least one subscriber, which is typically
// another computed property or a component's render function.
if (this.dep.subs.length === 0) {
// In lazy mode, we don't want to perform computations until necessary,
// so we simply mark the watcher as dirty. The actual computation is
// performed just-in-time in this.evaluate() when the computed property
// is accessed.
this.dirty = true
} else {
// In activated mode, we want to proactively perform the computation
// but only notify our subscribers when the value has indeed changed.
this.getAndInvoke(() => {
this.dep.notify()
})
}
} else if (this.sync) { } else if (this.sync) {
this.run() this.run()
} else { } else {
@ -177,47 +197,54 @@ export default class Watcher {
*/ */
run () { run () {
if (this.active) { if (this.active) {
const value = this.get() this.getAndInvoke(this.cb)
if ( }
value !== this.value || }
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may getAndInvoke (cb: Function) {
// have mutated. const value = this.get()
isObject(value) || if (
this.deep value !== this.value ||
) { // Deep watchers and watchers on Object/Arrays should fire even
// set new value // when the value is the same, because the value may
const oldValue = this.value // have mutated.
this.value = value isObject(value) ||
if (this.user) { this.deep
try { ) {
this.cb.call(this.vm, value, oldValue) // set new value
} catch (e) { const oldValue = this.value
handleError(e, this.vm, `callback for watcher "${this.expression}"`) this.value = value
} this.dirty = false
} else { if (this.user) {
this.cb.call(this.vm, value, oldValue) try {
cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
} }
} else {
cb.call(this.vm, value, oldValue)
} }
} }
} }
/** /**
* Evaluate the value of the watcher. * Evaluate and return the value of the watcher.
* This only gets called for lazy watchers. * This only gets called for computed property watchers.
*/ */
evaluate () { evaluate () {
this.value = this.get() if (this.dirty) {
this.dirty = false this.value = this.get()
this.dirty = false
}
return this.value
} }
/** /**
* Depend on all deps collected by this watcher. * Depend on this watcher. Only for computed property watchers.
*/ */
depend () { depend () {
let i = this.deps.length if (this.dep && Dep.target) {
while (i--) { this.dep.depend()
this.deps[i].depend()
} }
} }

View File

@ -216,4 +216,40 @@ describe('Options computed', () => {
}) })
expect(() => vm.a).toThrowError('rethrow') expect(() => vm.a).toThrowError('rethrow')
}) })
// #7767
it('should avoid unnecessary re-renders', done => {
const computedSpy = jasmine.createSpy('computed')
const updatedSpy = jasmine.createSpy('updated')
const vm = new Vue({
data: {
msg: 'bar'
},
computed: {
a () {
computedSpy()
return this.msg !== 'foo'
}
},
template: `<div>{{ a }}</div>`,
updated: updatedSpy
}).$mount()
expect(vm.$el.textContent).toBe('true')
expect(computedSpy.calls.count()).toBe(1)
expect(updatedSpy.calls.count()).toBe(0)
vm.msg = 'baz'
waitForUpdate(() => {
expect(vm.$el.textContent).toBe('true')
expect(computedSpy.calls.count()).toBe(2)
expect(updatedSpy.calls.count()).toBe(0)
}).then(() => {
vm.msg = 'foo'
}).then(() => {
expect(vm.$el.textContent).toBe('false')
expect(computedSpy.calls.count()).toBe(3)
expect(updatedSpy.calls.count()).toBe(1)
}).then(done)
})
}) })

View File

@ -144,26 +144,70 @@ describe('Watcher', () => {
}).then(done) }).then(done)
}) })
it('lazy mode', done => { it('computed mode, lazy', done => {
let getterCallCount = 0
const watcher = new Watcher(vm, function () { const watcher = new Watcher(vm, function () {
getterCallCount++
return this.a + this.b.d return this.a + this.b.d
}, null, { lazy: true }) }, null, { computed: true })
expect(watcher.lazy).toBe(true)
expect(getterCallCount).toBe(0)
expect(watcher.computed).toBe(true)
expect(watcher.value).toBeUndefined() expect(watcher.value).toBeUndefined()
expect(watcher.dirty).toBe(true) expect(watcher.dirty).toBe(true)
watcher.evaluate() expect(watcher.dep).toBeTruthy()
const value = watcher.evaluate()
expect(getterCallCount).toBe(1)
expect(value).toBe(5)
expect(watcher.value).toBe(5) expect(watcher.value).toBe(5)
expect(watcher.dirty).toBe(false) expect(watcher.dirty).toBe(false)
// should not get again if not dirty
watcher.evaluate()
expect(getterCallCount).toBe(1)
vm.a = 2 vm.a = 2
waitForUpdate(() => { waitForUpdate(() => {
expect(getterCallCount).toBe(1)
expect(watcher.value).toBe(5) expect(watcher.value).toBe(5)
expect(watcher.dirty).toBe(true) expect(watcher.dirty).toBe(true)
watcher.evaluate()
const value = watcher.evaluate()
expect(getterCallCount).toBe(2)
expect(value).toBe(6)
expect(watcher.value).toBe(6) expect(watcher.value).toBe(6)
expect(watcher.dirty).toBe(false) expect(watcher.dirty).toBe(false)
}).then(done) }).then(done)
}) })
it('computed mode, activated', done => {
let getterCallCount = 0
const watcher = new Watcher(vm, function () {
getterCallCount++
return this.a + this.b.d
}, null, { computed: true })
// activate by mocking a subscriber
const subMock = jasmine.createSpyObj('sub', ['update'])
watcher.dep.addSub(subMock)
const value = watcher.evaluate()
expect(getterCallCount).toBe(1)
expect(value).toBe(5)
vm.a = 2
waitForUpdate(() => {
expect(getterCallCount).toBe(2)
expect(subMock.update).toHaveBeenCalled()
// since already computed, calling evaluate again should not trigger
// getter
watcher.evaluate()
expect(getterCallCount).toBe(2)
}).then(done)
})
it('teardown', done => { it('teardown', done => {
const watcher = new Watcher(vm, 'b.c', spy) const watcher = new Watcher(vm, 'b.c', spy)
watcher.teardown() watcher.teardown()