Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from Mar 23, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 4 additions & 9 deletions src/core/instance/state.js
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
Expand Down
101 changes: 64 additions & 37 deletions src/core/observer/watcher.js
Expand Up @@ -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;
Expand All @@ -56,15 +57,15 @@ 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
} 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()
Expand All @@ -87,9 +88,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()
}
}

/**
Expand Down Expand Up @@ -160,8 +164,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 {
Expand All @@ -175,47 +195,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()
}
}

Expand Down
36 changes: 36 additions & 0 deletions test/unit/features/options/computed.spec.js
Expand Up @@ -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)
})
})
54 changes: 49 additions & 5 deletions test/unit/modules/observer/watcher.spec.js
Expand Up @@ -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()
Expand Down