Vue3 Watch /watchEffect

Pre-knowledge: Basic principles of responsiveness

Knowledge: VuE3 responsive system basic principle to know: reactive object (reactive, ref), effect, track, trigger these are about what role to know, do you know my brief introduction

Const obj = reactive({a: 0});

Access to any key of OBj triggers the Track function, and any key value change to OBj triggers the trigger function

Effect (fn) is used to trigger

Here’s how to use it:

An effect has a function that we pass in, and when the function accesses j.a, it triggers the track function,

const fn = () = > {
    console.log(obj.a) // Visit OBj. A, live by track
}
effect(fn)
obj.a = 1 // This triggers the above FN to be called again
Copy the code

Track will find the most “new” effect and save obj<–>a<–>effect. At this time, if I change it: obj. A = 1, then the trigger will look for obj, who is the effect corresponding to a, and then re-run the function saved by this effect. This is the principle of vuE3 response, but there are too many details in it, so I won’t go into it here

Watch effect and watch effect

The Vue3 watchEffect/ Watch API, which you should know about, is to listen for responsive objects and re-execute specified callbacks when changes are made

const state = reactive({
    star: 0
})
watch(state, (newVal, oldVal) = > {
    console.log(state.star)
  console.log(newVal)
  console.log(oldVal)
})
state.star++ // This prints state.star again, with the new value and the old value
Copy the code

WatchEffect (we) is direct, passing in the callback

const state = reactive({
    star: 0
})
watch(() = > {
    console.log(state.star)
})
state.star++ // This will print state.star again
Copy the code

Source code parsing line by line

We go into the source code and see that these two methods actually use the same API

// Simple effect.
export function watchEffect(effect: WatchEffect, options? : WatchOptionsBase) :WatchStopHandle {
  return doWatch(effect, null, options)
}
// watch
export function watch<T = any.Immediate extends Readonly<boolean> = false> (
  source: T | WatchSource<T>,
  cb: any, options? : WatchOptions<Immediate>) :WatchStopHandle {
  return doWatch(source as any, cb, options)
}
Copy the code

As you can see, the watchEffect call takes a second argument of NULL, and the watch call takes a second argument of the callback function we passed in

Enter doWatch, the following code is all in doWatch

  let getter: () = > any
  let forceTrigger = false
  if (isRef(source)) {
    getter = () = > (source asRef).value forceTrigger = !! (sourceas Ref)._shallow
  } else if (isReactive(source)) {
    getter = () = > source
    deep = true
  } else if{... }Copy the code

See, the first thing we’ve defined is a getter, and the getter is going to get the data to listen on, and when the source is reactive or ref, the getter is going to get the data to listen on, and the implementation of the getter is going to vary depending on the target of the listener,

PS: callWithErrorHandling is called to the function passed in, and if an error is handled accordingly, it is treated as

.else if (isArray(source)) {
  getter = () = >
    source.map(s= > {
      if (isRef(s)) {
        return s.value // If it is ref, then get its value
      } else if (isReactive(s)) {
        // Here the traverse recurses to get all the keys of the reactive object,
        // Since accessing a key will collect that key dependency, recursion will trigger dependency collection for all keys
        return traverse(s)
      } else if (isFunction(s)) {
        // Source can also accept a function that returns ref
        return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
      } else {
        __DEV__ && warnInvalidSource(s) // Not in all cases, an error is reported in dev mode}})}...Copy the code

Source can be a value that returns a reactive object, or a function that returns a ref object as follows

const count = ref(0)
watch(() = > count, (newCount, oldCount) = >{... })Copy the code

So when source is a function

else if (isFunction(source)) {
    if (cb) {
      WatchEffect */
      getter = () = >
        callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
    } else {
    WatchEffect */
      getter = () = > {
        // Instance is a global variable and can be understood as the component that currently executes the method
        if (instance && instance.isUnmounted) {
          return
        }
        // Cleanup is a registered cleanup function
        if (cleanup) {
          cleanup()
        }
        return callWithErrorHandling(
          source,
          instance,
          ErrorCodes.WATCH_CALLBACK,
          [onInvalidate]
        )
      }
    }
  }
Copy the code

So you can see that if it’s a function, our getter is really just going to get that value, because accessing the responsive object is going to trace the dependency, and the cleanup function is a neat place to register

The place to declare cleanup is actually a few lines down

let cleanup: () = > void
const onInvalidate: InvalidateCbRegistrator = (fn: () => void) = > {
  / / runner is effect
  cleanup = runner.options.onStop = () = > {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
  }
}
Copy the code

As you can see, calling onInvalidate passes a callback function fn, which registers FN with the current effect’s onStop, and also passes this callback to cleanup, which calls fn

So how can we use this onInvalidate as an argument to watch and watchEffect

// onInvalidate is passed here
watchEffect((onInvalidate) = > {
    window.addEventListener("click", handler)
  onInvalidate(() = > {
    window.removeEventListener("click", handler)
  })
})
Copy the code

This exposes the cleanup function to us users, and you can certainly imagine how React handles it

useEffect(() = > {
  window.addEventListener("click", handler)
    return () = > {
    window.removeEventListener("click", handler)
    }
})
Copy the code

The React method looks pretty intuitive, so why doesn’t Vue write it this way? As async and generator functions can return values in a promise package, vue can use async directly. React can also use async. Make an IIFE call async function from inside can, but very ugly

Let’s go back to the doWatch

/**watch api*/
if (cb && deep) {
  const baseGetter = getter
  getter = () = > traverse(baseGetter())
}

let cleanup: () = > void
const onInvalidate: InvalidateCbRegistrator = (fn: () => void) = > {
  cleanup = runner.options.onStop = () = > {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
  }
}
Copy the code

If cb is present, it’s watch and not watchEffect, then wrap this baseGetter in another layer, because it’s possible that the source we passed in is a reactive source, and we’re going to monitor all of its keys recursively, and we didn’t do any special treatment of this, cleanup of this, as I said, Keep reading

 let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE
 const job: SchedulerJob = () = > {
   // Where runner is defined below, js can link to an undeclared variable in a function,
   // The function passed in to the effect is the getter above, which means that we call effect to get the return value of the getter
   // After the runner is cancelled, do we return nothing
   if(! runner.active) {return
   }
   if (cb) { // Cb indicates that we are using watch instead of watchEffect
     const newValue = runner() // Call runner to get this value
     // Determine if the new and old values are the same, and if they are the same, do not deal with them
     if (deep || forceTrigger || hasChanged(newValue, oldValue)) {
       // If there is a cleanup function, call the cleanup function to prevent memory leaks
       if (cleanup) {
         cleanup()
       }
       callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
         newValue,
         // pass undefined as the old value when it's changed for the first time
         oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
         onInvalidate
       ])
       oldValue = newValue
     }
   } else {
     runner() // For watchEffect, this is the function passed in, just call the runner}}Copy the code

This watchEffect does not handle cleanup!! Go ahead and mention PR, actually watchEffect is handled, and you can see it in the getter above

// no cb -> simple effect
getter = () = >{...if (cleanup) {
    cleanup()
  }
    ...
}
Copy the code

I’m so happy. I thought I’d get a little PR

Keep reading

job.allowRecurse = !! cb/**allowRecurse allows a recursive call to watch. For example watch(count, (newVal, oldVal) => {if (newVal % 2) {count. Value = newVal + 1}})*/
let scheduler: ReactiveEffectOptions['scheduler']
if (flush === 'sync') {
  /* When an effect is triggered, the scheduler will determine if there is a scheduler, and if there is, the scheduler will be called instead of calling the effect itself */
  scheduler = job
} else if (flush === 'post') {
  scheduler = () = > queuePostRenderEffect(job, instance && instance.suspense)
} else {
  // default: 'pre'
  scheduler = () = > {
    if(! instance || instance.isMounted) { queuePreFlushCb(job) }else {
      // If the pre is called for the first time, the pre is called synchronously.
      // To prevent the mount from triggering the job,
      job()
    }
  }
}
Copy the code

Sync means to synchronize the update. We set the scheduler directly to job. If it is a pre or post, we put it in the microtask queue and let the event loop schedule it. Notice that the first time it’s called, according to the documentation, if it’s a pre, the initialization is still called immediately

Now that we have the scheduler, let’s look at effect

const runner = effect(getter, {
  lazy: true,
  onTrack,
  onTrigger,
  scheduler
})

// This method "binds" effect to the current component, i.e. pushes effect into the component's effect queue
recordInstanceBoundEffect(runner, instance)
Copy the code

The effect finally uses the getter, the scheduler, and the lazy: true means that the effect does not perform the getter immediately, but needs to be called manually

Let’s go back to doWatch

// Initial run
if (cb) {
  /* Because watch is lazy by default, it will not be triggered until after the change. If immediate is passed as true, the call job*/ will be executed immediately
  if (immediate) {
    job()
  } else {
    /* this is where the track is done, and the oldValue is the oldValue passed into the watch */
    oldValue = runner()
  }
} else if (flush === 'post') {
  /* If it is not watch and flush is POST, execute the watchEffect method on the next "tick". This method pushes the effect to the POST queue. After that, the microtask execution checks the POST queue and executes the task if there is one. At that point this effect is performed to track the dependency */
  queuePostRenderEffect(runner, instance && instance.suspense)
} else {
  /* The default watchEffect. We'll just call this effect
  runner()
}
Copy the code

Now for the final step, we know that watchEffect will return a function to stop the watchEffect, so it’s better to return one

 return () = > {
   stop(runner) // This step changes effect's active property to false. If the next call is found to be false, the corresponding callback will not be performed
   if(instance) { remove(instance.effects! , runner)// Remove the effect from the component's effect queue}}Copy the code

Thank you for seeing this. Here’s the full code

function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
  instance = currentInstance
) :WatchStopHandle {
  if(__DEV__ && ! cb) {if(immediate ! = =undefined) {
      warn(
        `watch() "immediate" option is only respected when using the ` +
          `watch(source, callback, options?) signature.`)}if(deep ! = =undefined) {
      warn(
        `watch() "deep" option is only respected when using the ` +
          `watch(source, callback, options?) signature.`)}}const warnInvalidSource = (s: unknown) = > {
    warn(
      `Invalid watch source: `,
      s,
      `A watch source can only be a getter/effect function, a ref, ` +
        `a reactive object, or an array of these types.`)}let getter: () = > any
  let forceTrigger = false
  if (isRef(source)) {
    getter = () = > (source asRef).value forceTrigger = !! (sourceas Ref)._shallow
  } else if (isReactive(source)) {
    getter = () = > source
    deep = true
  } else if (isArray(source)) {
    getter = () = >
      source.map(s= > {
        if (isRef(s)) {
          return s.value
        } else if (isReactive(s)) {
          return traverse(s)
        } else if (isFunction(s)) {
          return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
        } else {
          __DEV__ && warnInvalidSource(s)
        }
      })
  } else if (isFunction(source)) {
    /**watch api */
    if (cb) {
      // getter with cb
      getter = () = >
        callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
    } else {
    /**watchEffect api */  
      // no cb -> simple effect
      getter = () = > {
        if (instance && instance.isUnmounted) {
          return
        }
        if (cleanup) {
          cleanup()
        }
        return callWithErrorHandling(
          source,
          instance,
          ErrorCodes.WATCH_CALLBACK,
          [onInvalidate]
        )
      }
    }
  } else {
    getter = NOOP
    __DEV__ && warnInvalidSource(source)
  }

  /**watch api */
  if (cb && deep) {
    const baseGetter = getter
    getter = () = > traverse(baseGetter())
  }

  let cleanup: () = > void
  const onInvalidate: InvalidateCbRegistrator = (fn: () => void) = > {
    cleanup = runner.options.onStop = () = > {
      callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
    }
  }

  // in SSR there is no need to setup an actual effect, and it should be noop
  // unless it's eager
  if (__NODE_JS__ && isInSSRComponentSetup) {
    if(! cb) { getter() }else if (immediate) {
      callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
        getter(),
        undefined,
        onInvalidate
      ])
    }
    return NOOP
  }

  let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE
  const job: SchedulerJob = () = > {
    if(! runner.active) {return
    }
    if (cb) {
      // watch(source, cb)
      const newValue = runner()
      if (deep || forceTrigger || hasChanged(newValue, oldValue)) {
        // cleanup before running cb again
        if (cleanup) {
          cleanup()
        }
        callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
          newValue,
          // pass undefined as the old value when it's changed for the first time
          oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
          onInvalidate
        ])
        oldValue = newValue
      }
    } else {
      // watchEffect
      runner()
    }
  }

  // important: mark the job as a watcher callback so that scheduler knows
  // it is allowed to self-trigger (#1727)job.allowRecurse = !! cblet scheduler: ReactiveEffectOptions['scheduler']
  if (flush === 'sync') {
    scheduler = job
  } else if (flush === 'post') {
    scheduler = () = > queuePostRenderEffect(job, instance && instance.suspense)
  } else {
    // default: 'pre'
    scheduler = () = > {
      if(! instance || instance.isMounted) { queuePreFlushCb(job) }else {
        // with 'pre' option, the first call must happen before
        // the component is mounted so it is called synchronously.
        job()
      }
    }
  }

  const runner = effect(getter, {
    lazy: true,
    onTrack,
    onTrigger,
    scheduler
  })

  recordInstanceBoundEffect(runner, instance)

  // initial run
  if (cb) {
    if (immediate) {
      job()
    } else {
      oldValue = runner()
    }
  } else if (flush === 'post') {
    queuePostRenderEffect(runner, instance && instance.suspense)
  } else {
    runner()
  }

  return () = > {
    stop(runner)
    if(instance) { remove(instance.effects! , runner) } } }Copy the code

Reference:

Source address