5
头图

In this article, the author will explain the listener-related APIs in Vue3: watchEffect and watch. Before Vue3, watch is a very common option in option writing. It can be used to monitor the changes of a data source very conveniently. In Vue3, with the implementation of Composition API, watch has become a responsive api independently. Today Let's learn how to implement watch-related listeners together.

👇 Reserve knowledge requirements:

Before reading this article, it is recommended that you have studied the seventh article of this series, otherwise you may not understand the relevant part of the side effect.

watchEffect

Since many behaviors in the watch api are consistent with the watchEffect api, the author puts watchEffect in the first place. In order to automatically apply and reapply side effects based on the responsive state, we can use the watchEffect method. It immediately executes a function passed in, while tracking its dependencies responsively, and re-runs the function when it changes.

The implementation of the watchEffect function is very concise:

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

First look at the parameter type:

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

export interface WatchOptionsBase {
  flush?: 'pre' | 'post' | 'sync'
  onTrack?: ReactiveEffectOptions['onTrack']
  onTrigger?: ReactiveEffectOptions['onTrigger']
}

export type WatchStopHandle = () => void

The first parameter, effect, receives the variable of the function type, and the onInvalidate parameter is passed in this function to clear the side effects.

The second parameter options is an object. There are three properties in this object. You can modify flush to change the refresh timing of side effects. The default is pre. When it is modified to post, the side effect listener can be triggered after the component is updated. , Changing to sync will force the synchronization trigger. The onTrack and onTrigger options can be used to debug the behavior of the listener, and the two parameters can only work in development mode.

After the parameters are passed in, the function will execute and return the return value of the doWatch function.

Since the watch api will also call the doWatch function, the specific logic of the doWatch function will be discussed later. First look at the function implementation of watch api.

watch

This independent watch api is completely equivalent to the watch option in the component. The watch needs to listen to a specific data source and execute side effects in the callback function. By default, this listener is lazy, that is, the callback is executed only when the source of the listener changes.

Compared with watchEffect, watch has the following differences:

  • Lazy execution side effects
  • More specifically, state that the state should punish the listener to re-run
  • Ability to access the values before and after the listening state change

The function signature of the watch function has many overload situations, and the number of lines of code is large, so I am not going to analyze each overload situation, let's take a look at the implementation of 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)
}

watch receives 3 parameters, source is the data source for listening, cb callback function, and options is the listening option.

source parameter

The types of source are as follows:

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

It can be seen from the two type definitions that the data source supports the input of a single Ref, Computed reactive object, or a function that returns the same generic type, and the source supports the input of an array, so that multiple data sources can be monitored at the same time.

cb parameter

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

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

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

options

export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
  immediate?: Immediate
  deep?: boolean
}

You can see that the options type WatchOptions inherits WatchOptionsBase, which means that in addition to the two unique parameters of immediate and deep, watch 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 in the function body is almost the same as watchEffect, but it is more in the development environment to check whether the callback function is a function type. If the callback function is not a function, an alarm will be issued.

Compared with watchEffect when passing through doWatch, it has a second parameter callback function.

Now let us unveil the true face of this ultimate boss doWatch.

doWatch

Whether it is watchEffect, watch, or the watch option in the component, the logic in doWatch is ultimately called during execution. This powerful doWatch function is also very long for compatibility with the logic source code of each API, which is about 200 lines, so the old rules The author will separate the long source code. If you want to read the complete source code , please click here .

Let's start with the function signature of doWatch:

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

The signature of this function is basically the same as that of watch, with an additional instance parameter, the default value is currentInstance, currentInstance is a variable exposed by the currently calling component, so that the listener can find its corresponding component.

The type of source here is clearer. It supports a single source or an array, and it is just an ordinary object.

Then three variables will be created. The getter will eventually be passed in as a side-effect function parameter, forceTrigger identifies whether it needs to be forced to update, and isMultiSource identifies whether the incoming data source is a single data source or multiple data sources passed in in the form of an array.

let getter: () => any
let forceTrigger = false
let isMultiSource = false

Then it will start to judge the type of source and reset the values of these three parameters according to different types.

  • ref type

    • Access to the getter function will get the value of source.value and unpack it directly.
    • The forceTrigger flag will be set according to whether it is shallowRef.
  • reactive type

    • Access to the getter function directly returns the source, because the reactive value does not need to be unpacked to get it.
    • Since there are often multiple attributes in reactive, deep will be set to true. It can be seen that setting deep to reactive from the outside is invalid.
  • Array type

    • Set isMultiSource to true.
    • forceTrigger will judge based on whether there is a reactive object in the array.
    • The getter is in the form of an array, which is the result of a single getter for each element in the source.
  • source is the function type

    • If there is a callback function

      • The getter is the result of the execution of the source function. In this case, the data source in the watch api is generally passed in in the form of a function.
    • If there is no callback function, then this is the scenario of watchEffect api.

      • At this time, a getter function will be set for watchEffect. The logic of the getter function is as follows:

        • If the component instance has been uninstalled, do not execute and return directly
        • Otherwise execute cleanup to remove dependencies
        • Execute the source function
  • If the source is not in the above situation, set the getter to an empty function, and report an illegal source warning ⚠️.

The relevant code is as follows. Since the logic has been completely analyzed above, the author is allowed to be lazy and not comment.

if (isRef(source)) { // ref 类型的数据源,更新 getter 与 forceTrigger
  getter = () => (source as Ref).value
  forceTrigger = !!(source as Ref)._shallow
} else if (isReactive(source)) { // reactive 类型的数据源,更新 getter 与 deep
  getter = () => source
  deep = true
} else if (isArray(source)) { // 多个数据源,更新 isMultiSource、forceTrigger、getter
  isMultiSource = true
  forceTrigger = source.some(isReactive)
  // getter 会以数组形式返回数组中数据源的值
  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)) { // 数据源是函数的情况
  if (cb) {
    // 如果有回调,则更新 getter,让数据源作为 getter 函数
    getter = () =>
      callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
  } else {
    // 没有回调即为 watchEffect 场景
    getter = () => {
      if (instance && instance.isUnmounted) {
        return
      }
      if (cleanup) {
        cleanup()
      }
      return callWithAsyncErrorHandling(
        source,
        instance,
        ErrorCodes.WATCH_CALLBACK,
        [onInvalidate]
      )
    }
  }
} else {
  // 其余情况 getter 为空函数,并发出警告
  getter = NOOP
  __DEV__ && warnInvalidSource(source)
}

Then the scene in the watch will be processed. When there is a callback and the deep option is true, traverse will be used to wrap the getter function to monitor the recursive traversal of each attribute in the data source.

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

After that, the cleanup and onInvalidate functions will be declared, and the cleanup function will be assigned during the execution of the onInvalidate function. When the side effect function executes some asynchronous side effects, these responses need to be cleared when it fails, so the function that listens for the incoming side effects can receive one The onInvalidate function is used as an input parameter to register the callback when the cleanup fails. This invalidation callback will be triggered when the following situations occur:

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

Then the oldValue will be initialized and assigned.

Then declare a job function. This function will eventually be passed in as a callback function in the scheduler. Since it is a closure form that depends on many variables in the external scope, it will be described later to avoid undeclared variables. Difficulty in understanding.

According to whether there is a callback function, set the allowRecurse property of the job. This setting is very important to allow the job to be used as an observer's callback so that the scheduler can know that it is allowed to call itself.

Then declare a scheduler object of scheduler, and determine the execution timing of the scheduler according to the parameters passed by flush.

  • When flush is sync synchronization, assign the job to the scheduler directly, so that the scheduler function will be executed directly.
  • When flush is the post and needs to be delayed, pass the job to queuePostRenderEffect, so that the job will be added to a delayed execution queue, which will be executed in the life cycle of the update after the component is mounted.
  • Finally, flush is the default pre-priority execution. This is the scheduler will distinguish whether the component has been mounted. The first call of the side effect must be before the component is mounted, and after mounting, it will be pushed into a priority execution. In the queue of timing.

The source code of this part of the logic is as follows:

// 初始化 oldValue
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => { /*暂时忽略逻辑*/ } // 声明一个 job 调度器任务,暂时不关注内部逻辑

// 重要:让调度器任务作为侦听器的回调以至于调度器能知道它可以被允许自己派发更新
job.allowRecurse = !!cb

let scheduler: ReactiveEffectOptions['scheduler'] // 声明一个调度器
if (flush === 'sync') {
  scheduler = job as any // 这个调度器函数会立即被执行
} else if (flush === 'post') {
  // 调度器会将任务推入一个延迟执行的队列中
  scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
    // 默认情况 'pre'
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      // 在 pre 选型中,第一次调用必须发生在组件挂载之前
      // 所以这次调用是同步的
      job()
    }
  }
}

After processing the above scheduler part, side effects will start to be created.

First declare a runner variable, which creates a side effect and passes the previously processed getter function as a side effect function, and sets the delayed call in the side effect option and sets the corresponding scheduler.

And add the side-effect function to the effects property of the component instance through the recordInstanceBoundEffect function, so that the component can actively stop the execution of these side-effect functions when uninstalling.

Then it will start processing the first execution of the side effect function.

  • If watch has a callback function

    • If the immediate option is set for watch, the job scheduler task is executed immediately.
    • Otherwise, the runner side effect is executed for the first time, and the return value is assigned to oldValue.
  • If the refresh timing of flush is post, the runner is put into the queue of delayed timing and executed after the component is mounted.
  • In other cases, the runner side effects are directly executed for the first time.

Finally, the doWatch function will return a function. The function of this function is to stop listening, so when you use it, you can explicitly call the return value for watch and watchEffect to stop listening.

// 创建 runner 副作用
const runner = effect(getter, {
  lazy: true,
  onTrack,
  onTrigger,
  scheduler
})

// 将 runner 添加进 instance.effects 数组中
recordInstanceBoundEffect(runner, instance)

// 初始化调用副作用
if (cb) {
  if (immediate) {
    job() // 有回调函数且是 imeediate 选项的立即执行调度器任务
  } else {
    oldValue = runner() // 否则执行一次 runner,并将返回值赋值给 oldValue
  }
} else if (flush === 'post') {
     // 如果调用时机为 post,则推入延迟执行队列
  queuePostRenderEffect(runner, instance && instance.suspense)
} else {
  // 其余情况立即首次执行副作用
  runner()
}

// 返回一个函数,用以显式的结束侦听
return () => {
  stop(runner)
  if (instance) {
    remove(instance.effects!, runner)
  }
}

The doWatch function has all finished running here, and now all the variables have been declared, especially the runner side effects declared at the end. We can look back and see what has been done in the job that has been called many times.

The logic of what is done in the scheduler task is relatively clear. First, it will determine whether the runner side effect has been disabled. If it has been disabled, it will return immediately, and no subsequent logic will be executed.

After distinguishing the scene, judge whether it is a watch api call or a watchEffect api call by whether there is a callback function.

If it is a watch api call, the runner side effect will be executed, and its return value will be assigned to newValue as the latest value. If deep requires deep listening, or forceTrigger needs to be forced to update, or the old and new values have changed, the cb callback needs to be triggered in all three cases to notify the listener of the change. Before calling the listener, cleanup is used to clear the side effects, and then the cb callback is triggered, passing the three parameters newValue, oldValue, and onInvalidate into the callback. Update the value of oldValue after the callback is triggered.

And if there is no cb callback function, it is the scenario of watchEffect. At this time, 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
  }
  if (cb) {
    // watch(source, cb) 场景
    // 调用 runner 副作用获取最新的值 newValue
    const newValue = runner()
    // 如果是 deep 或 forceTrigger 或有值更新
    if (
      deep ||
      forceTrigger ||
      (isMultiSource
        ? (newValue as any[]).some((v, i) =>
            hasChanged(v, (oldValue as any[])[i])
          )
        : hasChanged(newValue, oldValue))
    ) {
      // 当回调再次执行前先清除副作用
      if (cleanup) {
        cleanup()
      }
      // 触发 watch api 的回调,并将 newValue、oldValue、onInvalidate 传入
      callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
        newValue,
        // 首次调用时,将 oldValue 的值设置为 undefined
        oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
        onInvalidate
      ])
      oldValue = newValue // 触发回调后,更新 oldValue
    }
  } else {
    // watchEffect 的场景,直接执行 runner
    runner()
  }
}

to sum up

In this article, the author 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 listening through the doWatch function. In the process of explaining, we found that the listener in Vue3 is also implemented through side effects, so before understanding the listener, we need to understand thoroughly what the side effects do.

We see that behind watch and watchEffect are calling and returning the doWatch function. The author disassembled and analyzed the doWatch function, so that readers can clearly know what each line of doWatch does, so that when our listener is not as expected When you work, you can analyze the reasons from the details, instead of guessing and trying.

Finally, if this article can help you understand more about the principle of watch in Vue3 and how it works, I hope I can give this article a little like ❤️. If you want to continue to follow up the follow-up articles, you can also follow my account or follow my github , thank you again for the lovely masters.


Originalix
165 声望63 粉丝

前端工程师,欢迎来 github 互相 follow, originlaix