Repository: vuejs/vue
Pull request: perf: avoid unnecessary re-renders when computed property value did not change

close #7767

Changed files:
filename: src/core/instance/state.js
patch: @@ -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()
     }
   }
 }

filename: src/core/observer/watcher.js
patch: @@ -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()
     }
   }
 

filename: test/unit/features/options/computed.spec.js
patch: @@ -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)
+  })
 })

filename: test/unit/modules/observer/watcher.spec.js
patch: @@ -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()