In this article I’ll cover the listener apis in Vue3: watchEffect and Watch. Before Vue3, Watch was a very common option in option writing, and it was very convenient to use it to monitor the changes of a data source. In Vue3, with the implementation of Composition API, Watch became a responsive API independently. Today we will learn how watch-related listeners are implemented.

👇 Reserve knowledge requirements:

Before reading this article, it is recommended that you have studied the effect side effects function in article 7 of this series, otherwise you may not understand the side effects section.

watchEffect

Since many of the behaviors in the Watch API are consistent with the watchEffect API, I’ll start with the watchEffect method, which we can use to automatically apply and reapply side effects based on reactive state. It executes a function passed in immediately, tracing its dependencies responsively, and rerunking the function when it changes.

The watchEffect function is implemented very succinctly:

export function watchEffect(effect: WatchEffect, options? : WatchOptionsBase) :WatchStopHandle {
  return doWatch(effect, null, options)
}
Copy the code

Let’s look at the parameter types first:

export type WatchEffect = (onInvalidate: InvalidateCbRegistrator) = > void

export interfaceWatchOptionsBase { flush? :'pre' | 'post' | 'sync'onTrack? : ReactiveEffectOptions['onTrack'] onTrigger? : ReactiveEffectOptions['onTrigger']}export type WatchStopHandle = () = > void
Copy the code

The first argument, effect, takes a variable of type function and passes in the onInvalidate argument to clear up the side effects.

The second argument, Options, is an object with three properties. You can modify Flush to change the flush timing of side effects. Default is Pre. The onTrack and onTrigger options can be used to debug the listener’s behavior, and both parameters can only work in development mode.

When the argument is passed in, the function executes and returns the return value of the doWatch function.

Since the Watch API also calls the doWatch function, the specific logic of the doWatch function will be covered later. Let’s look at the function implementation of the Watch API.

watch

This separate Watch API is identical to the Watch option in the component, which listens for specific data sources and performs side effects in callbacks. This listening is lazy by default, meaning that the callback is executed only when the source being listened to changes.

Compared to watchEffect, Watch has the following differences:

  • Lazy executive side effects
  • More specifically, the state should penalize the listener for rerun
  • The ability to access values before and after listening for state changes

The function signature of the Watch function can be overloaded in many ways, and the number of lines of code is quite large, so I’m not going to analyze every overloaded case, but let’s look at the implementation of the Watch API.

export function watch<T = any.Immediate extends Readonly<boolean> = false> (
  source: T | WatchSource<T>,
  cb: any, options? : WatchOptions<Immediate>) :WatchStopHandle {
  if(__DEV__ && ! isFunction(cb)) { warn(`\`watch(fn, options?) \` signature has been moved to a separate API. ` +
        `Use \`watchEffect(fn, options?) \` instead. \`watch\` now only ` +
        `supports \`watch(source, cb, options?) signature.`)}return doWatch(source as any, cb, options)
}
Copy the code

Watch receives three parameters: source to listen to the data source, cb callback function, and options to listen to the options.

The source parameters

The source type is as follows:

export type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() = > T)
type MultiWatchSources = (WatchSource<unknown> | object) []Copy the code

As you can see from the two type definitions, data sources support passing in a single Ref, Computed responsive object, or a function that returns the same generic type, and source supports passing in arrays so that you can listen to multiple data sources at the same time.

Cb parameters

In this most general declaration, cb is of type any, but the cb callback has its own type:

export type WatchCallback<V = any, OV = any> = (value: V, oldValue: OV, onInvalidate: InvalidateCbRegistrator) = > any
Copy the code

In the callback function, the latest value, the old value, and the onInvalidate function are provided to clear up side effects.

options

export interface WatchOptions<Immediate = boolean> extendsWatchOptionsBase { immediate? : Immediate deep? :boolean
}
Copy the code

WatchOptions inherits WatchOptionsBase from WatchOptions. This is watch. You can also pass all the parameters in WatchOptionsBase to control the behavior of side effects.

After analyzing the parameters, you can see that the logic inside the function is almost the same as the watchEffect, except that the development environment checks whether the callback is a function type and raises an alarm if it is not.

The second parameter callback function is added to watchEffect when doWatch is executed.

Let’s take a look at the ultimate boss, doWatch.

doWatch

Whether it is watchEffect, Watch or the Watch option in the component, the logic in doWatch is eventually called when executing. This powerful doWatch function is quite long with about 200 lines of source code in order to be compatible with the logic of various apis. I’ll break down the long source code. To read the full source code, please click here.

Start with doWatch’s function signature:

function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
  instance = currentInstance
) :WatchStopHandle
Copy the code

This function has the same signature as Watch, with an additional instance parameter, currentInstance, which is a variable exposed by the currently invoked component, allowing the listener to find its corresponding component.

The type of source here is relatively clear, supporting a single source or array, also just a common object.

Three variables are then created, the getter is eventually passed in as a function parameter for side effects, the forceTrigger flag identifies whether an update is required forcibly, and the isMultiSource flag identifies whether a single data source is passed in or multiple data sources are passed in as an array.

let getter: () = > any
let forceTrigger = false
let isMultiSource = false
Copy the code

It then determines the type of source and resets the values of these three parameters based on the different types.

  • Ref type
    • Access the getter function to obtain the source.value value, direct unpacking.
    • The forceTrigger tag is set depending on whether or not it is a shallowRef.
  • Reactive type
    • Access the getter function to return the source directly because the value of Reactive does not need to be unpacked.
    • Because reactive often has multiple properties, it is true to set deep to true. This shows that setting deep to Reactive externally does not work.
  • Array Array type
    • Set isMultiSource to true.
    • ForceTrigger is judged by the presence of reactive objects in the array.
    • A getter is an array form that is the result of a single getter for each element in a source.
  • Source is the function type
    • If there is a callback function
      • A getter is the result of the execution of the source function, which is typically the case when the data source in the Watch API is passed in as a function.
    • If there is no callback function, this is the scenario for the watchEffect API.
      • The getter function for watchEffect is set with the following logic:
        • If the component instance has been unmounted, it does not execute and returns directly
        • Otherwise, run cleanup to remove dependencies
        • Execute source function
  • If source is not, the getter is set to empty and a warning is issued that source is not valid ⚠️.

The relevant code is as follows, because the logic has been complete in the above analysis, so let me steal a lazy, not to add comments.

if (isRef(source)) { // Data source of type ref, update getter and forceTrigger
  getter = () = > (source asRef).value forceTrigger = !! (sourceas Ref)._shallow
} else if (isReactive(source)) { // Reactive data source that updates getters and deep
  getter = () = > source
  deep = true
} else if (isArray(source)) { // Multiple data sources, update isMultiSource, forceTrigger, getter
  isMultiSource = true
  forceTrigger = source.some(isReactive)
  // The getter returns the value of the data source in an array
  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)) { // The data source is a function
  if (cb) {
    // If there is a callback, the getter is updated to make the data source the getter
    getter = () = >
      callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
  } else {
    // No callback is the watchEffect scenario
    getter = () = > {
      if (instance && instance.isUnmounted) {
        return
      }
      if (cleanup) {
        cleanup()
      }
      return callWithAsyncErrorHandling(
        source,
        instance,
        ErrorCodes.WATCH_CALLBACK,
        [onInvalidate]
      )
    }
  }
} else {
  In other cases the getter is empty and a warning is issued
  getter = NOOP
  __DEV__ && warnInvalidSource(source)
}
Copy the code

The scenario in Watch is then processed, and when there is a callback and the deep option is true, the getter function is wrapped with traverse, listening for a recursive traverse of each property in the data source.

if (cb && deep) {
  const baseGetter = getter
  getter = () = > traverse(baseGetter())
}
Copy the code

We then declare the cleanup and onInvalidate functions, and assign values to the cleanup function during the execution of the onInvalidate function. When the side effects function performs some asynchronous side effects, these responses need to be cleared when they fail. So a function that listens for incoming side effects can receive an onInvalidate function as an input parameter to register a callback in the event of a cleanup failure. This invalidation callback is triggered when:

  • When the side effect is about to be re-executed.
  • The listener is stopped (or when the component is unloaded if watchEffect is used in setup() or the lifecycle hook function).
let cleanup: () = > void
let onInvalidate: InvalidateCbRegistrator = (fn: () => void) = > {
  cleanup = runner.options.onStop = () = > {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
  }
}
Copy the code

OldValue is then initialized and assigned.

We then declare a job function, which is eventually passed in as a callback function in the scheduler. Since it is a closure that depends on many variables in the external scope, we will cover it later to avoid undeclared variables.

The allowRecurse property of the job is set based on whether or not there is a callback function. This setting is important to make the job an observer callback so that the scheduler knows that it is allowed to call itself.

A scheduler object is then declared to determine when the scheduler executes based on flush’s pass-through.

  • When Flush is synchronized to sync, the job is assigned to the scheduler directly so that the scheduler function executes directly.
  • When Flush is POST and requires delayed execution, the job is passed to queuePostRenderEffect so that the job is added to a delayed execution queue that executes during the updated life cycle of the component after it is mounted.
  • Finally, there are cases where flush is the default pre priority execution, where the scheduler differentiates whether or not the component is already mounted, where the side effect must be first called before the component is mounted, and then pushed into a priority execution queue.

The source code for this section of logic is as follows:

// Initialize oldValue
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () = > { /* Ignore logic */ for now } // Declare a job scheduler task

// Important: Make the scheduler task the listener's callback so that the scheduler knows it can be allowed to issue updates itselfjob.allowRecurse = !! cblet scheduler: ReactiveEffectOptions['scheduler'] // Declare a scheduler
if (flush === 'sync') {
  scheduler = job as any // The scheduler function is executed immediately
} else if (flush === 'post') {
  // The scheduler pushes the task into a delayed queue
  scheduler = () = > queuePostRenderEffect(job, instance && instance.suspense)
} else {
	// Default 'pre'
  scheduler = () = > {
    if(! instance || instance.isMounted) { queuePreFlushCb(job) }else {
      // In pre selection, the first call must occur before the component is mounted
      // So this call is synchronous
      job()
    }
  }
}
Copy the code

After the scheduler part above is processed, side effects are created.

We start by declaring a Runner variable that creates a side effect and passes in the previously processed getter function as the side effect function, setting the call delay in the side effect option, and setting the corresponding scheduler.

And through recordInstanceBoundEffect function to add the side effect function to the effects of the properties of component instance, so that components can take the initiative to stop these side effects when unloading function implementation.

It then starts processing the first execution of the side effect function.

  • If watch has a callback function
    • If watch is set to immediate, the Job scheduler task is executed immediately.
    • Otherwise the runner side effect is first executed and the returned value is assigned to oldValue.
  • If flush is post, the runner is placed in a delay-timed queue for execution after the component is mounted.
  • The rest of the cases are direct first runner side effects.

Finally, the doWatch function returns a function that stops listening, so you can explicitly call the return value for watch and watchEffect to stop listening.

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

// Add runner to the instance.effects array
recordInstanceBoundEffect(runner, instance)

// Initialize the callback side effects
if (cb) {
  if (immediate) {
    job() // Execute the scheduler task immediately with a callback function and the imeediate option
  } else {
    oldValue = runner() // Otherwise execute runner once and assign the returned value to oldValue}}else if (flush === 'post') {
 	// If the call time is POST, the deferred execution queue is pushed
  queuePostRenderEffect(runner, instance && instance.suspense)
} else {
  // In other cases the first side effect is immediately performed
  runner()
}

// Returns a function to explicitly end the listening
return () = > {
  stop(runner)
  if(instance) { remove(instance.effects! , runner) } }Copy the code

At this point, the doWatch function is all run, and now all variables are declared, especially the runner side effects declared at the end. We can go back and look at what happens in jobs that are called multiple times.

The scheduler task does things with clear logic. First, it determines whether the runner side effect is disabled. If it has been disabled, it immediately returns and does not execute subsequent logic.

Then, the scenario is differentiated and the watch API call or watchEffect API call is determined by whether there is a callback function.

If it is a Watch API call, the Runner side effect is performed, assigning its return value to newValue as the latest value. If a deep needs to listen deeply, or if a forceTrigger needs to force an update, or if the old and new values have changed, all three need to trigger a CB callback to notify the listener of the change. Cleanup is done before the listener is called, and then the CB callback is triggered, passing the newValue, oldValue, and onInvalidate arguments to the callback. Update oldValue after the callback is triggered.

Without the CB callback, which is the watchEffect scenario, the scheduler task only needs to execute the Runner side effect function.

The specific code logic in the Job scheduler task is as follows:

const job: SchedulerJob = () = > {
  if(! runner.active) {// Return directly if the side effect is discontinued
    return
  }
  if (cb) {
    // Watch (source, CB) scenario
    // Call the runner side effect to get the latest value newValue
    const newValue = runner()
    // If it is deep or forceTrigger or has a value update
    if (
      deep ||
      forceTrigger ||
      (isMultiSource
        ? (newValue as any[]).some((v, i) = >
            hasChanged(v, (oldValue as any[])[i])
          )
        : hasChanged(newValue, oldValue))
    ) {
      Clear the side effects before the callback is executed again
      if (cleanup) {
        cleanup()
      }
      // Trigger the watch API callback and pass in newValue, oldValue, onInvalidate
      callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
        newValue,
        // When first called, oldValue is set to undefined
        oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
        onInvalidate
      ])
      oldValue = newValue // Update oldValue after the callback is triggered}}else {
    // watchEffect scenario, execute runner directly
    runner()
  }
}
Copy the code

conclusion

In this paper, I explained in detail the implementation of the watch and watchEffect APIS provided in Vue3, and the watch in the option option of the component is actually monitored by the doWatch function. Along the way, we discovered that listeners in Vue3 are also implemented through side effects, so understanding listeners requires a thorough understanding of what side effects do.

We see that behind watch and watchEffect, doWatch function is called and returned. We disassemble and analyze doWatch function, so that readers can clearly know what doWatch does in each line of code, so that when our listener does not work as expected, The reasons can be analyzed in detail rather than guesswork.

If this article can help you understand the principle of Watch and its working mode in Vue3, I hope you can give this article a like ❤️. If you want to continue to follow the following articles, you can also follow my account, thank you again for reading so far.