From 531cea5814a37c598b08bac0227ebc9e3544ca86 Mon Sep 17 00:00:00 2001 From: Evan You Date: Sun, 11 Mar 2018 16:13:03 -0400 Subject: [PATCH] perf: avoid unnecessary re-renders when computed property value did not change close #7767 --- src/core/instance/state.js | 13 +-- src/core/observer/watcher.js | 101 +++++++++++++------- test/unit/features/options/computed.spec.js | 36 +++++++ test/unit/modules/observer/watcher.spec.js | 54 ++++++++++- 4 files changed, 153 insertions(+), 51 deletions(-) diff --git a/src/core/instance/state.js b/src/core/instance/state.js index b1549b0dcc4..326f7f36bf7 100644 --- a/src/core/instance/state.js +++ b/src/core/instance/state.js @@ -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() } } } diff --git a/src/core/observer/watcher.js b/src/core/observer/watcher.js index 48a2e612e28..f2f2ebb71ed 100644 --- a/src/core/observer/watcher.js +++ b/src/core/observer/watcher.js @@ -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; newDeps: Array; depIds: SimpleSet; @@ -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() @@ -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() + } } /** @@ -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 { @@ -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() } } diff --git a/test/unit/features/options/computed.spec.js b/test/unit/features/options/computed.spec.js index edc20bad3bf..fc606072e7d 100644 --- a/test/unit/features/options/computed.spec.js +++ b/test/unit/features/options/computed.spec.js @@ -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: `
{{ a }}
`, + 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) + }) }) diff --git a/test/unit/modules/observer/watcher.spec.js b/test/unit/modules/observer/watcher.spec.js index 724a3cc8637..ba96b28ffce 100644 --- a/test/unit/modules/observer/watcher.spec.js +++ b/test/unit/modules/observer/watcher.spec.js @@ -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()