Vue: unexpected triggering of `watch` with deep nested data

Created on 28 May 2017  路  7Comments  路  Source: vuejs/vue

Version

2.3.3

Reproduction link

https://jsfiddle.net/beeplin/s2dLt0qh/1/

Steps to reproduce

vm1 = new Vue({
  el: '#app1',
  data: {
    root: {
      a: {
        key: 1 // root.a is an OBJECT
      }
    }
  },
  watch: {
    'root.a': function () {
      alert('vm1 triggerd')
    }
  }
})

vm1.$set(vm1.root, 'b', 1) // will wrongly trigger the watch

vm2 = new Vue({
  el: '#app2',
  data: {
    root: {
      a: 1 // root.a is NOT an OBJECT
    }
  },
  watch: {
    'root.a': function () {
      alert('vm 2triggerd')
    }
  }
})

vm2.$set(vm2.root, 'b', 1) // will not trigger the watch

root is a data of a Vue instance. root.a is an object, and root.a is under watch.

What is expected?

when adding new properties to root with vm.$set(vm.root, 'b', 1), the watch of root.a will not be triggered.

What is actually happening?

when adding new properties to root with vm.$set(vm.root, 'b', 1), the watch of root.a is wrongly triggered.

Most helpful comment

This is due to a few things:

  1. The watcher root.a keeps track of both root and root.a as dependencies. So when a new reactive property is added to root, the watcher will fire. This is designed to handle the case where root.a may not exist on initial watch and thus root.a won't be collected as a dependency.

  2. When the watcher fires, if the new value and old value are both primitive and strictly equal, the watcher won't trigger the callback. However if the new value and old value are Objects or Arrays, then they may have been mutated, so we trigger the callback to be safe.

Long story short, we "over-fire" in some cases to ensure correctness of the entire system. This is a design constraint we are aware of, but in practice they won't lead to logical errors or critical perf problems.

So this is a wontfix for now, we may check to see if we can improve this when rewriting the reactivity system using Proxies.

All 7 comments

This is due to a few things:

  1. The watcher root.a keeps track of both root and root.a as dependencies. So when a new reactive property is added to root, the watcher will fire. This is designed to handle the case where root.a may not exist on initial watch and thus root.a won't be collected as a dependency.

  2. When the watcher fires, if the new value and old value are both primitive and strictly equal, the watcher won't trigger the callback. However if the new value and old value are Objects or Arrays, then they may have been mutated, so we trigger the callback to be safe.

Long story short, we "over-fire" in some cases to ensure correctness of the entire system. This is a design constraint we are aware of, but in practice they won't lead to logical errors or critical perf problems.

So this is a wontfix for now, we may check to see if we can improve this when rewriting the reactivity system using Proxies.

Got it. It does lead to logic errors when using vuex store as a shared cache: when adding a new item (each item is an object) to the cache, all computed and watch depending upon all other cache items are triggered, which 1) leads to considerable performance problem; 2) for item as an object, the callbacks relating to it are all called, making unwanted logic results.

I understand that it is hard to detect if an object is changed, so there is no easy way to fix this. I will try using rx instead of vue's reactive system. Or do you guys have any suggestions for this use case?

A half-solution is using finer-grained computed/watchers whenever possible (prefer making the final computed / watched value a primitive). I know that might not always be feasible though.

Logic-wise, since a watcher callback will by definition be called multiple times, it should always be able to safely overwrite previous side-effects. If being called with the same value could lead to logical errors, then it would even more likely lead to logical errors when called with different values.

Yes my temporary solution is just like what you suggested -- stringifying the object before saving it into vuex cache. However it has drawbacks since each component that uses this cache item would make a redundant copy of this string and parse it to a new object in memory, which consumes more CPU and memory. Had the object-watching worked fine, there would be just one copy of the object in memory and no stringify/parse needed.

Another possible solution can be this.$watch( -> JSON.stringify(this.$store.state.cache[this.key)) in order to avoid unwanted firing of callbacks. But since this watcher function is to be called super frequently, it might consume much more cpu.

I will dig into my logic to see if possible to make it safe to repeating redundant callbacks.

Do you think rxjs will help in this case?

@yyx990803 I improved my logic and use events instead of watch to notify the change of the cache. Now it works fine. :) Thanks~!

i think this is actually the opposite.. you always triggering the callback just because its an object or an array can and does indeed break some code (it broke some code for me for example).
My code ended up running several times when it shouldn't have run and that would break my application logic (a very complex use case).
I had to end up using a JSON.stringify and return the parsed object i wanted to watch for (thank god its small).

I instead used lodash.isEqual check for this to get rid of the unnecessary handler invocations

Was this page helpful?
0 / 5 - 0 ratings

Related issues

6pm picture 6pm  路  3Comments

seemsindie picture seemsindie  路  3Comments

gkiely picture gkiely  路  3Comments

aviggngyv picture aviggngyv  路  3Comments

paulpflug picture paulpflug  路  3Comments