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 Watcher from '../observer/watcher'
import Dep, { pushTarget, popTarget } from '../observer/dep'
import { pushTarget, popTarget } from '../observer/dep'
import { isUpdatingChildComponent } from './lifecycle'
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) {
// $flow-disable-line
@ -244,13 +244,8 @@ 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.depend()
return watcher.evaluate()
}
}
}

View File

@ -29,10 +29,11 @@ export default class Watcher {
id: number;
deep: boolean;
user: boolean;
lazy: boolean;
computed: boolean;
sync: boolean;
dirty: boolean;
active: boolean;
dep: Dep;
deps: Array<Dep>;
newDeps: Array<Dep>;
depIds: SimpleSet;
@ -57,16 +58,16 @@ export default class Watcher {
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.computed = !!options.computed
this.sync = !!options.sync
this.before = options.before
} else {
this.deep = this.user = this.lazy = this.sync = false
this.deep = this.user = this.computed = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.dirty = this.computed // for computed watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
@ -89,9 +90,12 @@ export default class Watcher {
)
}
}
this.value = this.lazy
? undefined
: this.get()
if (this.computed) {
this.value = undefined
this.dep = new Dep()
} else {
this.value = this.get()
}
}
/**
@ -162,8 +166,24 @@ export default class Watcher {
*/
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
if (this.computed) {
// 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) {
this.run()
} else {
@ -177,47 +197,54 @@ export default class Watcher {
*/
run () {
if (this.active) {
const value = this.get()
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
this.getAndInvoke(this.cb)
}
}
getAndInvoke (cb: Function) {
const value = this.get()
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
this.dirty = false
if (this.user) {
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.
* This only gets called for lazy watchers.
* Evaluate and return the value of the watcher.
* This only gets called for computed property watchers.
*/
evaluate () {
this.value = this.get()
this.dirty = false
if (this.dirty) {
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 () {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
if (this.dep && Dep.target) {
this.dep.depend()
}
}

View File

@ -216,4 +216,40 @@ describe('Options computed', () => {
})
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)
})
it('lazy mode', done => {
it('computed mode, lazy', done => {
let getterCallCount = 0
const watcher = new Watcher(vm, function () {
getterCallCount++
return this.a + this.b.d
}, null, { lazy: true })
expect(watcher.lazy).toBe(true)
}, null, { computed: true })
expect(getterCallCount).toBe(0)
expect(watcher.computed).toBe(true)
expect(watcher.value).toBeUndefined()
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.dirty).toBe(false)
// should not get again if not dirty
watcher.evaluate()
expect(getterCallCount).toBe(1)
vm.a = 2
waitForUpdate(() => {
expect(getterCallCount).toBe(1)
expect(watcher.value).toBe(5)
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.dirty).toBe(false)
}).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 => {
const watcher = new Watcher(vm, 'b.c', spy)
watcher.teardown()