Subscribe on changes!

watchEffect() was triggered when the 'property' of array has real changed

avatar
May 16th 2022

What problem does this feature solve?

I put the 'property' in quote, it is a analogy. Specifically, indexOf(T): number method is used here as an example. As we know, Vue helps us filter out 'fake' change events when observing a common property.

const count = ref(0)
watchEffect(() => console.log(count.value))

const click => count.value = 1

Initializing the above code will immediately print a 0. When the first click , a 1 will be printed, but continued clicking will no longer be executed. It was as expected and it worked well.

However, this behavior will not be respected in arrays.

const arr = reactive([])
watchEffect(() => console.log(arr.indexOf('123456')))

const click => arr.push('654321')

When I keep clicking the button, it will keep printing -1. But in fact, the return value of .indexOf('123456') does not change, just as the return value of count.value does not change.

What does the proposed API look like?

I expect the same behavior for 'read methods' in arrays as for getter properties to perform side effects when real changes occur.

avatar
May 16th 2022

It will change in the example.

@liulinboyi Maybe my English is pool, I mean the -1 is being printed repeatedly, which is NOT expected behavior. The change caused by push('654321') does not cause the return value of indexOf('123456') to be changed, so I think the effect should NOT be executed.

avatar
May 16th 2022

It will change in the example.

@liulinboyi Maybe my English is pool, I mean the -1 is being printed repeatedly, which is NOT expected behavior. The change caused by push('654321') does not cause the return value of indexOf('123456') to be changed, so I think the effect should NOT be executed.

I didn't understand correctly😉. About this issue, in fact Vue can't predict the result.

avatar
May 16th 2022

About this issue, in fact Vue can't predict the result.

Yes, to be honest, I found this issue because I was writing a similar reactivity component for WPF (C#) and when I started to implement support for list, I ran into this issue. Unlike properties, read members and write members in collections are completely different and cannot capture them within the same interceptor.

Here is my initial solution:

  1. Intercept read methods, cache method names and return values (or cache parameters, depending on which is the value type, to avoid memory leaks).
  2. Intercept write methods and determine which read methods may be triggered.
  3. Check if the effect to be triggered contains these read methods, and if it does, determine if it was REALLY changed by the cached values.

I think my solution is very cumbersome and likely to have unforeseen vulnerabilities, so I referenced Vue's source code, but found that Vue do nothing about it.

avatar
May 16th 2022

You can process in you source code, like example

<script setup>
import { ref, reactive, watchEffect, computed } from 'vue'

const arr = reactive([])
const res = reactive([])
let oldVal = null
watchEffect(() => {
  debugger
  let newVal = arr.lastIndexOf('123456')
  if (!Object.is(oldVal,newVal)) {
    // update oldVal
    oldVal = newVal
    // do something
    // eg
    console.log(newVal)
    res.push(newVal)
  }
})

const click = () => arr.push((Math.floor(Math.random() * 100)) % 2 ? '654321' : '123456')
</script>

<template>
  <div>
    {{ arr }}
  </div>
  <button @click="click">
    Change
  </button>
  <div>
    {{ res }}
  </div>
</template>

https://user-images.githubusercontent.com/41336612/168605514-ac4b63cb-b488-4222-a980-719ae3a83bc5.mp4

avatar
May 17th 2022

This is expected behavior - watchEffect always runs whenever any of its dependencies change.

If you want to avoid running an effect when a certain condition doesn't change, use watch:

watch(() => arr.indexOf('123456'), index => {
  // only runs if index changes
})
avatar
May 17th 2022

This is expected behavior - watchEffect always runs whenever any of its dependencies change.

If you want to avoid running an effect when a certain condition doesn't change, use watch:

watch(() => arr.indexOf('123456'), index => {
  // only runs if index changes
})

@yyx990803 Thanks for the reply.

Is this a deliberately designed behavior, or a compromise due to the difficulty of implementation?

I tend to favor the latter because the behavior of array is inconsistent with normal properties. If this is by design, I can't understand what kind of consideration was given to it. Can you provide more information? Thank you very much. As an example, if I want to write logs with watchEffect(), then this will result in writing the same messages.

  • For common properties, in the set interceptor, only the same property name is required to obtain old-value and new-value, it is easy to determine if a change has really occurred.
  • But for arrays, names of read and write method are completely inconsistent and come with parameters.

For watch, the old-value can be cached explicitly since it only has a unique return value. However, this workaround will be invalid if there are multiple read methods of the array in a effect.

avatar
May 17th 2022

Your code literally mean "run console.log(arr.indexOf('123456'))" when arr is changed, while your intended behavior is "run console.log(arr.indexOf('123456'))" when arr.indexOf('123456') is changed. I don't think we can/should infer this from watchEffect.

avatar
May 22nd 2022

Your code literally mean "run console.log(arr.indexOf('123456'))" when arr is changed

@Justineo Thanks, I understand the design idea of it.

I don't think we can/should infer this from watchEffect.

For "should", the reason I have this doubt from the behavior of watching properties, I think properties are methods (ignoring syntactic sugar), which are all members of an object.

For "can", if in C# (in fact, I'm not familiar with JavaScript), I think we can cache function () => Reflect.invoke(target, 'indexOf', [args]) (pseudo-code) to Dep while tracing indexOf(args), and then execute it at trigger push().

pseudo-code (I'm not a qualified js user and I'm not sure it needs to handle more cases, dynamic languages are more complex)

// in proxy:
function interceptor(ctx) {
  if (ctx.key === 'indexOf' /* or other read method */) {
    track(ctx.target, ctx.key, getter: () => Reflect.invoke(ctx.target, 'indexOf', ctx.args))
  } else if (ctx.key === 'push' /* or other write method */) {
    const getter = getGetterRelateToThisOperation(ctx.target, ctx.key)
    const oldValue = getter()
    ctx.next() // in JavaScript's Proxy API, not like a middleware pattern? non-CPS.
    const newValue = getter()
    if (oldValue !== newValue) {
      trigger(target, ctx.key)
    }
  }
}