Explain the Vue3 watch/watchEffect source code line by line

Explain the Vue3 watch/watchEffect source code line by line

Explain the Vue3 watch/watchEffect source code line by line

Pre-knowledge: basic principles of responsiveness

Set knowledge: The basic principles of the vue3 reactive system should be known: reactive objects (reactive, ref), effect, track, trigger, what are the general functions, I don t know, I don t know if I give a brief introduction

const obj = reactive({a: 0})
This function will proxy an object (proxy)

Access to any key of obj will trigger the track function, and any key value change to obj will trigger the trigger function

effect(fn)
Used to be triggered by trigger

Then the usage is as follows:

An effect contains a function we passed in. When this function accesses obj.a, it triggers the track function.

const fn = () => { console .log(obj.a) //visited obj.a and was tracked } effect(fn) obj.a = 1 //Trigger will be triggered here to make the above fn call again to copy the code

Track will find the most "new" effect, and then save the obj<-->a<-->effect relationship triangle. 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 rerun the function saved by the effect after finding it. This is the principle of vue3 responsiveness, and the details inside But it's too much, I won't go into it here

Pre-knowledge: usage of watch and watchEffect

Everyone should understand the vue3 watchEffect/watch api, which is to monitor the responsive object and re-execute the specified callback when it changes.

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, the new value again, the old value copy the code

watchEffect (hereafter referred to as we) is direct, just pass in the callback

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

Source code analysis line by line

We enter the source code to see that these two methods actually use the same set of 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 the any , CB, Options) } Copy code

You can see that the second parameter of watchEffect call is null, and the second parameter of watch call is the callback function we passed in

Enter doWatch, the following codes are all in doWatch

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

Seeing that we first defined a getter. The function of the getter is to obtain the data to be monitored. When the source is ref or reactive, the getter is to obtain the corresponding data. The implementation of the getter varies according to different monitoring goals.

PS: callWithErrorHandling is to call the passed-in function, if the 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 a ref, get its value } else if (isReactive(s)) { //here traverse Will recursively get all the key values of the reactive object, //because as long as one key is accessed, the key dependency will be collected, and all keys will be recursively triggered to collect the dependency 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 code

source can be a value that returns a responsive object, or a function that returns a ref object as follows

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

So when source is a function

else if (isFunction(source)) { if (cb) { /**Enter here to indicate that it is watch instead of watchEffect */ getter = () => callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER) } else { /**Enter here to indicate watchEffect instead of watch */ getter = () => { //instance is a global variable, which can be understood as the component currently executing this method if (instance && instance.isUnmounted) { return } //cleanup is the registered cleanup function if (cleanup) { cleanup() } return callWithErrorHandling( source, instance, ErrorCodes.WATCH_CALLBACK, [onInvalidate] ) } } } Copy code

As you can see from the above, if it is a function, our getter is actually to get that value, because access to the reactive object will trace the dependency, and the cleanup function is registered very cleverly.

The place where cleanup is declared is actually in the following lines

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

Now you can see that by calling onInvalidate and passing in a callback function fn, you can register fn to the onStop of the current effect, and pass this callback to cleanup, so when we call cleanup, we can call fn

So how can this onInvalidate be used by our users? It actually passes watch and watchEffect as parameters

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

In this way, this clear function is exposed to our users, and you will surely think of the way react.

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

The react method looks very intuitive, so why doesn't vue use this way of writing? Because this way of writing does not support async await, the functions in useEffect can only be ordinary functions, because async function or generator function, the return value will be wrapped in promise, and vue can directly use async, of course, react thinks You can also use it, just make an IIFE and call the async function in it, but it's ugly

Then go back to the doWatch just now

/**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 code

If cb exists, it means that it is watch instead of watchEffect. Then wrap this baseGetter with another layer, because it is possible that the source we passed in is a reactive and we need to recursively monitor all of his keys, but this situation has not been specially treated just now. Cleanup has already been mentioned above, continue to look down

let oldValue = isArray(source)? []: INITIAL_WATCHER_VALUE const job: SchedulerJob = () => { //Here runner is defined below, js can be linked to undeclared variables in the function, //runner is a The function passed in by the effect is the getter above, which means that we can get the return value of the getter by calling the effect.//When the runner is cancelled, we do nothing directly return if (!runner.active) { return } if (cb) { //There is cb indicating that we are using watch instead of watchEffect const newValue = runner() //Call runner to get the value this time //Determine whether the new and old values are the same, if they are the same, there is no change No need to deal with if (deep || forceTrigger || hasChanged(newValue, oldValue)) { //If the cleanup function is available, 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, there is the function passed in, just call this runner directly } } Copy code

Smart look, hey, this watchEffect does not handle cleanup! ! ! Hurry up to mention PR, actually watchEffect is processed, you can turn to the getter above to see

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

I was so happy, I thought I could make a small PR

Honestly continue to look down

job.allowRecurse = !!cb /**allowRecurse is to allow recursive calls, which means that watch can modify the value in it to achieve the purpose of triggering the watch again For example watch(count, (newVal, oldVal) => { if (newVal% 2) { count.value = newVal + 1 } })*/ let scheduler: ReactiveEffectOptions[ 'scheduler' ] if (flush === 'sync' ) { /*scheduler means scheduler, when effect is triggered by trigger, it will judge whether there is a scheduler, If there is, it will call the scheduler instead of directly 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 it is the first call to pre, call it once, here I guess it is to prevent disturbing the rendering, because the flush of the rendering is pre, //to prevent the job from triggering when mounting later, job() } } } Copy code

Judge the timing of the update according to flush, sync represents synchronous update, we can set the scheduler directly to job, and if it is pre or post, then it must be put into the micro task queue and let the event loop schedule it. Note that when calling here for the first time, according to the document description, if it is pre, the initialization is still called immediately

Now that the scheduler is already there, let s look at the effect

const runner = effect(getter, { lazy : true , onTrack, onTrigger, scheduler }) //This method "binds" the effect to the current component, that is, pushes the effect into the component's effect queue recordInstanceBoundEffect(runner, instance) Copy code

The effect here finally uses the getter and scheduler mentioned before. Here is lazy: true means that this effect will not execute the getter immediately, but needs to be called manually

Let s go back to our doWatch and keep watching

//Initial operation if (cb) { /*Because watch is lazy by default, it will only be triggered after changes. If the incoming immediate is true, it will execute the call job immediately */if (immediate) { job() } else { /*A dependency collection (track) is performed here, and the oldValue is the old value recorded in the watch*/ oldValue = runner() } } else if (flush === 'post' ) { /*If it is not a watch, and flush is a post, it should be placed in the next "tick" to execute this watchEffect This method is to push the effect into the post queue, and then the micro task execution will check the post queue, if there is The task is executed, and then the effect will be executed to track the dependencies*/ queuePostRenderEffect(runner, instance && instance.suspense) } else { /*The default watchEffect, we just call this effect directly*/ runner() } Copy code

Next is the last step, we know that watchEffect will return a function to stop this watchEffect, so it is better to return a function

return () => { stop(runner) //This step will change the active attribute of the effect to false. If it is found to be false in the next call, the corresponding callback will not be executed if (instance) { remove(instance.effects!, runner) //remove is to remove this effect from the component's effect queue } } Copy code

Thank you for seeing this, the complete code is as follows

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 as Ref).value forceTrigger = !!(source as 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 = !!cb let 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 code

reference:

Source code corresponding address