详解 Vue3 侦听器

2,480 阅读16分钟

侦听器是常用的 Vue API 之一,它用于监听一个数据并在数据变动时做一些自定义逻辑,本文将先列举侦听器在 Vue 中的使用方式,然后再分析源码讲述为什么可以这样使用、以及侦听器的实现原理。

侦听器用法

在 Vue3 中使用侦听器有两种方式,一种是传统的 Options API ,另一种则是提供给 Composition API 使用的 Function API。

注意:这里这是为了方便描述用法差异才将使用方式拆分,其实两种方式可以按需任意混用。

在 Options API 中使用

我是个怀旧的懒人,所以我一直喜欢 Options API 更多,那就先列举 watch 选项的用法(不涉及 WatchOptions 配置)。

对齐 Options API 中与 watch 相关的术语:

  • key: 配置侦听选项时的属性,也就是被侦听的响应式变量,示例代码中的 firstName
  • raw: 配置侦听选项时 key 对应的值,示例代码中的匿名函数
  • handler/callback: 侦听值改变时 Vue 要执行的回调函数

示例代码:

  watch: {
    firstName:function(nv) {
      this.fullName = `${nv} ${this.lastName}`
    }

函数

raw 是函数时,handler 就等于 raw

export default {
  data() {
    return {
      firstName: 'Mo',
      lastName: 'Deyan',
      fullName: 'Mo Deyan'
    }
  },
  watch: {
    firstName(nv) {
      this.fullName = `${nv} ${this.lastName}`
    }
 }
}

字符串

raw 是字符串时,Vue 会从上下文(当前组件实例)上获取同名的函数作为 handler

export default {
  data() {
    return {
      firstName: 'Mo',
      lastName: 'Deyan',
      fullName: 'Mo Deyan'
    }
  },
  methods: {
    watchHandle(nv) {
      console.log(this)
      this.fullName = `${nv} ${this.lastName}`
    },
  },  
  watch: {
    firstName: 'watchHandle'
 }
}

对象

row 是对象时,需要在对象上显式配置 handler

  1. 如果配置的 handler 是函数,则使用该函数。

    export default {
      data() {
        return {
          firstName: 'Mo',
          lastName: 'Deyan',
          fullName: 'Mo Deyan'
        }
      },
      watch: {
        firstName: {
          handler: function (nv) {
            this.fullName = `${nv} ${this.lastName}`
          }
        }
     }
    }
    
  2. 如果配置的 handler 是字符串,则从上下文(当前组件实例)上获取同名的函数作为 handler

    export default {
      data() {
        return {
          firstName: 'Mo',
          lastName: 'Deyan',
          fullName: 'Mo Deyan'
        }
      },
      methods: {
        watchHandle(nv) {
          console.log(this)
          this.fullName = `${nv} ${this.lastName}`
        },
      },  
      watch: {
        firstName: {
          handler: 'watchHandle'
        }
     }
    }
    

数组

raw 是数组时,Vue 会遍历这个数组把当前配置拆分为多个侦听器,新创建的侦听器的 key 不变,raw 变为数组的项,这里有一个递归逻辑,所以数组的每一项不仅可以是函数、字符串、对象,还可以是数组,但一般不建议继续使用数组,因为层级上去了,即耗费内存又降低了代码的可维护性。

有些年轻人不讲码德,来绕,来套娃,欺负我一个刚出生的小框架,这好吗?这不好,我劝这些年轻人耗子尾汁,好好反思,以后不要再犯这样的聪明,小聪明,啊...码林要以和为贵,要讲码德,不要乱搞套娃,谢谢朋友们。

export default {
  data() {
    return {
      firstName: 'Mo',
      lastName: 'Deyan',
      fullName: 'Mo Deyan'
    }
  },
  methods: {
    watchHandle(nv) {
      console.log(this)
      this.fullName = `${nv} ${this.lastName}`
    },
  },  
  watch: {
    firstName: ['watchHandle', function (nv) {
      console.log(this)
      console.log(`firstName: ${nv}`)
    }]
 }
}

在 Composition API 中使用

虽然 Options API 很好用,但也无法忽视其逻辑复用能力较弱的缺点,或许这个不太好用的 Composition API 会成为主流。针对 Composition API ,Vue 提供了两个侦听函数:watchEffectwatch ,下面我们来一一列举他们的使用方式(不涉及 WatchOptions 配置)。

对齐 Composition API 中与 watch 相关的术语:

  • source: 有如下两种解释
    • 使用 watchEffect 时,表示被侦听的函数,示例代码中的匿名函数
    • 使用 watch 时,表示被侦听的响应式变量,示例代码中的 count
  • callback: 侦听值变化时 Vue 要执行的回调函数,示例中的 (count, prevCount) => {}
  • effect: 侦听值变化时 Vue 要执行的副作用函数
    • 使用 watchEffect 时,可以等同于 source 函数(被 effect 包裹)
    • 使用 watch 时,可以等同于 callback 参数(被 effect 包裹)

示例代码:

  const count = ref(0)
  watchEffect(() => {
    console.log(count)
  })
  
  watch(count, (count, prevCount) => {})

watchEffect

一种相对简单的监听 API,你不用显式指定要监听的响应式数据,你只需传入一个函数 source ,然后 Vue 会在初始化时执行 source,执行过程中会触发函数中响应式数据的 get 并收集当前 effect 为依赖。

setup() {
  const count = ref(0)
  watchEffect(() => console.log(count.value) // -> logs 0

  setTimeout(() => {
    count.value++ // -> logs 1
  }, 100)
}

watch

watch API 至少需要指定两个参数: sourcecallback,其中 callback 被明确指定只能为函数,所以不同是用方式的差别其实只在 source

响应式数据

source 是由 ref/reactive 初始化的响应式数据时,使用方式如下:

  setup() {
    // ref
    const count = ref(0)
    watch(count, (nv, ov) => {
      console.log(`watch count: ${nv}`)
    })
    
    // reactive
    const info = reactive({
      firstName: 'mo',
      lastName: 'dy'
    })    
    watch(info, (nv, ov) => {
      console.log(`watch info fullName: ${nv.firstName} ${nv.lastName}`)
    })
  },

函数

source 是函数时,使用方式如下:

  setup() {
    const count = ref(0)
    watch(() => count.value, (nv, ov) => {
      console.log(`watch count: ${nv}`)
    })
  },

数组

source 是数组时,数组元素可以是函数和响应式数据之一(不能为数组,限制套娃),使用方式如下:

  setup() {
    const count = ref(0)
    const info = reactive({
      firstName: 'mo',
      lastName: 'dy'
    })
    watch([() => count.value, info], ([newCount, newInfo], [oldCount, oldInfo]) => {
      console.log(`watch array: \n newCount: ${newCount}, newFullName: ${newInfo.firstName} ${newInfo.lastName}`)
    })
  },

侦听器源码

由于在用法中我们区分了 Options API 和 Composition API ,所以在源码我们也做如下区分:

  • Watch Options API 源码阅读
  • Watch Function API 源码阅读
  • Watch Common 源码阅读

Options API

在上一篇文章看懂 Hello World(Vue3)中我们说过,在 mountComponent 函数中,我们会调用 applyOptions 处理 Options API 的配置,所以我们来看看 applyOptions 函数中关于侦听器的处理。

applyOptions

packages/runtime-core/src/componentOptions.ts

function applyOptions(...) {
  ...
  // 如果存在 watch 配置项,则将其 push 到 deferredWatch
  if (watchOptions) {
    deferredWatch.push(watchOptions)
  }
  // 如果当前不是在处理 mixin 的 Options API 配置项,则开始逐个处理 watch 配置
  if (!asMixin && deferredWatch.length) {
    deferredWatch.forEach(watchOptions => {
      for (const key in watchOptions) {
        createWatcher(watchOptions[key], ctx, publicThis, key)
      }
    })
  }
  ...
}

可以看到,watchOptionspush 到了一个名为 deferredWatch 的数组中,然后等到确认当前处理的 Options 是不是 Mixins 提供的,就开始遍历 deferredWatch 逐个调用 createWatcher 处理之前收集的 Watch Options 。

Watch Options 包括两类:

  1. Mixins 中配置的侦听器
  2. 当前组件中配置的侦听器

createWatcher

packages/runtime-core/src/componentOptions.ts

function createWatcher(
  raw: ComponentWatchOptionItem,
  ctx: Data,
  publicThis: ComponentPublicInstance,
  key: string
) {
  // 处理 key ,可以看到 key 是可以使用点号访问符的
  const getter = key.includes('.')
    ? createPathGetter(publicThis, key)
    : () => (publicThis as any)[key]
  if (isString(raw)) {
    // 如果 raw 是字符串,则从当前上下文上获取侦听器的回调函数
    const handler = ctx[raw]
    if (isFunction(handler)) {
      watch(getter, handler as WatchCallback)
    } else if (__DEV__) {
      warn(`Invalid watch handler specified by key "${raw}"`, handler)
    }
  } else if (isFunction(raw)) {
    // 如果 raw 是函数,则调用 watch API 执行侦听
    watch(getter, raw.bind(publicThis))
  } else if (isObject(raw)) {
    if (isArray(raw)) {
      // 如果 raw 是数组,则递归创建侦听器
      raw.forEach(r => createWatcher(r, ctx, publicThis, key))
    } else {
      // 如果 raw 是非数组对象,则获取显示的 handler 配置,配置可以是函数也可以是字符串
      const handler = isFunction(raw.handler)
        ? raw.handler.bind(publicThis)
        : (ctx[raw.handler] as WatchCallback)
      if (isFunction(handler)) {
        watch(getter, handler, raw)
      } else if (__DEV__) {
        warn(`Invalid watch handler specified by key "${raw.handler}"`, handler)
      }
    }
  } else if (__DEV__) {
    warn(`Invalid watch option: "${key}"`, raw)
  }
}

可以看到 createWatcher 函数主要是在处理 keyraw 配置,这也印证了上文 Options API 中侦听器的几种使用方式:

  1. 如果 raw 是字符串,则从当前上下文上获取侦听器的回调函数
  2. 如果 raw 是函数,则调用 watch API 执行侦听
  3. 如果 raw 是非数组对象,则获取显式配置的 handler 选项
    • 如果配置的 handler 是函数,则使用该函数
    • 如果配置的 handler 是字符串,则从上下文(当前组件实例)上获取同名的函数作为 handler
  4. 如果 raw 是数组,Vue 会遍历这个数组把当前配置拆分为多个侦听器,新创建的侦听器的 key 不变,raw 变为数组的项

实际执行侦听的 watch 函数,就是下面我们要继续阅读的 Watch Function API 之一。

Function API

Vue3 目前提供的 Watch Function API 有两个:watchEffectwatch,用法差异在上文有所体现。

watchEffect

packages/runtime-core/src/apiWatch.ts

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

函数 watchEffect 未做任何处理,直接调用了 doWatch ,它属于 Watch Common 所以我们之后再看。

watch

packages/runtime-core/src/apiWatch.ts

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 在开发环境下,如果传入的 callback 不是函数会打印警告提示,然后也调用了 doWatch 函数。

Common

接下来我们来看 Watch 相关的公共逻辑,即不管开发者使用的是 Options API 还是 Composition API ,都会使用到的逻辑,从上文可以看到,它们之间的关系是这样的:

doWatch

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

这个函数是实现侦听器的核心函数,所以内容也比较多,为了更好分析,我将函数功能拆分为以下五个:

  1. 生成取值函数 getter,需要提醒的是响应式数据在取值时会收集依赖(依赖就是副作用函数 runner
  2. 生成调度器 scheduler,它用于决定执行 callback 的方式(上文说过 callback 是侦听值变化时执行的回调函数)
  3. 生成副作用函数 runner (叫 effect 或许更好)
  4. 侦听器初始化
  5. 返回一个停止侦听的函数

生成取值函数

  // 根据不同入参类型,生成统一标准的 getter 函数
  let getter: () => any
  let forceTrigger = false
  if (isRef(source)) {
    // 如果是 ref 则返回 ref.value
    getter = () => (source as Ref).value
    forceTrigger = !!(source as Ref)._shallow
  } else if (isReactive(source)) {
    // 如果是 reactive 则返回 reactive 并设置 deep 为 true
    getter = () => source
    deep = true
  } else if (isArray(source)) {
    // 如果是数组则则返回一个新的数组,新数组由 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)) {
    if (cb) {
      // 如果是函数,并且调用的是 watch API ,则给 source 包裹一个错误处理函数
      // getter with cb
      getter = () =>
        callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
    } else {
      // 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)
  }

  // 如果 deep 为 true 则调用 traverse 递归访问 Array/Object/Set/Map 的子级
  // 访问响应式对象(get trap)就会收集依赖
  // 所以重点有两个:
  // 1. getter 在何时调用的
  // 2. 收集的依赖和谁关联
  if (cb && deep) {
    const baseGetter = getter
    getter = () => traverse(baseGetter())
  }

可以看到在生成 getter 时的一系列判断,也印证了上文侦听器在 Composition API 中使用的几种使用方式:

  1. 如果 sourceref 则在生成 getter 函数时返回 source.value
  2. 如果 sourcereactive 则需要设置 deep = true
  3. 如果 source 是数组,则 getter 返回一个新的数组,新数组由 source 每一项取到的值
  4. 如果 source 是函数,那就区分 watchEffectwatch 两个 API ,生成不同的取值函数
  5. 如过 deeptrue ,则调用 traverse 函数返回一个新的 getter

相信前四点都不会有疑问,唯有第五点处理 deep 的逻辑还需要更多一点的解释。让我们先思考一下 deep 的作用,是为了让响应式数据子级发生变动时通知执行 callback,既然存在通知,就需要先让每个子级都能收集到依赖,那么 traverse 是如何实现的呢?

function traverse(value: unknown, seen: Set<unknown> = new Set()) {
  if (!isObject(value) || seen.has(value)) {
    return value
  }
  seen.add(value)
  if (isRef(value)) {
    traverse(value.value, seen)
  } else if (isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      traverse(value[i], seen)
    }
  } else if (isSet(value) || isMap(value)) {
    value.forEach((v: any) => {
      traverse(v, seen)
    })
  } else {
    for (const key in value) {
      traverse(value[key], seen)
    }
  }
  return value
}

可以看到 traverse 函数的主要功能是递归取响应是对象的值,因为是响应是对象,所以在取值时就能完成依赖收集,并且维护一个 Set 来避免重复获取同一个对象的值。

生成调度器

  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()
        }
        pauseTracking()
        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
        ])
        resetTracking()
        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']
  // 判断 flush 选项绝对调度器行为
  // sync: 数据变化时同步直接调用 cb
  // post: 推迟调用 cb 到更新后
  // pre: 默认行为,在更新前调用,如果组件不存在或没挂载则立即执行
  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()
      }
    }
  }

生成调度器这段代码也可以分为两段:

  1. 生成调度任务
  2. 生成调度器

生成调度任务没啥好说的,就是分别处理 watchEffectwatch 两个 API ,前者就是副作用函数 runner 本身(在内部执行上文生成的取值函数 getter),后者则执行 callback

生成调度器时就和 watchOptions 中的 flush 配置项相关了:

  1. flush === 'sync' 时,同步,表示直接执行调度任务 job
  2. flush === 'post' 时,异步,表示把调度任务塞到 pendingPostFlushCbs 全局数组中,在本次渲染完成后才去执行它们
  3. flush === 'post' 时,默认值、异步,表示把调度任务塞到 pendingPreFlushCbs 全局数组中,在更新前去执行它们。当然还有一种特殊情况,即组件还是未挂载的状态,那么就会同步执行调度任务,等同于 sync

生成副作用函数

  // 生成 effect 函数(对象)
  const runner = effect(getter, {
    lazy: true,
    onTrack,
    onTrigger,
    scheduler
  })

调用 effect 生成副作用函数,下一小节再针对 effect 函数作详细解析。

侦听器初始化

  // initial run
  if (cb) {
    if (immediate) {
      // 如果 immediate 为 true 则执行 watch 的 callback
      job()
    } else {
      // 否则执行副作用函数
      oldValue = runner()
    }
  } else if (flush === 'post') {
    queuePostRenderEffect(runner, instance && instance.suspense)
  } else {
    runner()
  }

可以看到初始化流程其实很简单:

  1. 如果存在 callbackimmediate === true 则直接执行一次调度任务(调度任务中也会执行副作用函数,获取最新的值)
  2. 如果存在 callbackimmediate !== true 则执行副作用函数,副作用函数会执行上方生成的 getter 方法,并收集当前的副作用函数
  3. 如果不存在 callback(是 watchEffect API)且 flush === 'post' 则把副作用函数添加到 pendingPostFlushCbs 全局数组中
  4. 如果不存在 callback(是 watchEffect API)且 flush !== 'post' 则直接执行副作用函数

返回一个停止侦听的函数

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

可以看到,停止侦听做了两件事:

  1. 调用 stop 处理副作用函数
  2. 把副作用函数从当前组件实例中移除
// 使副作用失效
function stop(effect: ReactiveEffect) {
  if (effect.active) {
    cleanup(effect)
    if (effect.options.onStop) {
      effect.options.onStop()
    }
    effect.active = false
  }
}

// 从赖管理器中移除自身
// effect.deps 存放的是和自身相关的依赖管理器
// 当依赖管理器中存在这个副作用函数时,该副作用函数也会反向引用这个依赖管理器
function cleanup(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

可以看到 stop 函数的功能并不复杂:

  1. 改变 effect.active 的值为 false,使副作用函数失效
  2. 调用 cleanup 从赖管理器中移除自身
  3. 调用 onStop 回调

关于副作用函数上的 deps 属性,它存放的元素是 Set 集合 ,每个 Set 集合都是一个响应式数据的依赖收集器,它们又存放着和对应响应式数据相关的副作用函数,所以这里是一个双向引用的关系。那么这个关系是怎么形成的呢?在响应式数据收集这些副作用函数的同时,副作用函数也会把当前 Set 集合(响应式数据的依赖收集器)存放到它的 deps 数组中,这样做的意义是减少 cleanup 时的遍历移除依赖的性能损耗。

targetMap 是全局依赖管理器,deps{N} 是单个响应式数据关联的依赖收集器,effect 是副作用函数,他们之间的关系描述如下:

effect

Vue3 总是在说副作用,那么副作用究竟是什么呢?这里也分享一下我的浅薄之见,希望能对你理解 Vue3 的响应式有一定帮助,为了更好地解释,我将其拆分为两个点:

副作用函数的定义:副作用函数可以认定为是一个代理函数,它在执行原始函数前会记录一些运行时标记,并且把这些标记存放在代理函数上(比如 id),而这些原始函数不相关的额外逻辑就是副作用。

副作用函数扮演的角色:副作用函数是响应式数据收集的依赖,即代表响的两大能力(依赖收集、派发更新),都只是在决定如何处置副作用函数,所以副作用函数承单了 Vue2 中的 Watcher 对象的部分角色。Watcher 在 Vue2 中的其他职责也被拆分成了独立的模块,比如依赖收集和派发更新在 Vue3 中被封装成了独立的 track/trigger 方法,再比如依赖收集器现在被存放在一个全局维护的 WeakMap 中。

接下来我们来分析生成副作用函数的 effect 函数实现。

packages/reactivity/src/effect.ts

function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  const effect = createReactiveEffect(fn, options)
  // 如果不是滞后的、异步的副作用函数,则立即执行
  if (!options.lazy) {
    effect()
  }
  return effect
}

可以看到 effect 函数其实是调用 createReactiveEffect 函数生成的,并且如果没有配置 lazy 属性,就会立即执行生成的副作用函数。

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    // 如果副作用函数失效就返回原函数执行结果,因为没有开始跟踪就不会进行依赖收集
    // 这里还有个 scheduler 的判断,应该是为了处理特殊情况  
    if (!effect.active) {
      return options.scheduler ? undefined : fn()
    }
    if (!effectStack.includes(effect)) {
      cleanup(effect)
      try {
        enableTracking()
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
  effect.id = uid++
  effect.allowRecurse = !!options.allowRecurse
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}

这里就可以看到副作用函数的真容了,也更能理解 Javascript 函数即对象了,它也拥有很多属性: idactivedeps 、 ... 回到函数本身:

  1. 如果副作用函数失效就返回原函数执行结果,抛弃副作用
  2. 如果副作用函数未被处理,则在执行原始函数前开启依赖追踪,这意味着如果原始函数中有使用响应式数据,就会收集当前的副作用函数
  3. 在副作用函数上存放一些运行时标记

trigger

通过上文对侦听器和副作用函数的介绍,我们知道 watch 在初始化时收集了关联的副作用函数,但这些函数在什么时候被执行呢?我们还知道如果侦听值发生变化,要重新执行的是 callback 函数,但我们收集的是副作用函数,它又是怎么关联执行 callback 的呢?

关于第一个问题,我们需要了解响应式的原理或者规则,即收集的依赖会在数据变化时被处理,所以先来看看在响应式数据的 set trap 吧。

packages/reactivity/src/ref.ts

  set value(newVal) {
    if (hasChanged(toRaw(newVal), this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : convert(newVal)
      trigger(toRaw(this), TriggerOpTypes.SET, 'value', newVal)
    }
  }

可以看到在给响应式数据设置新值时,如果新值有改变,则会调用 trigger 函数,会传入参数 TriggerOpTypes.SET

我们来继续阅读 trigger 函数,补上侦听流程的最后一环。

packages/reactivity/src/effect.ts

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  // 获取单个数据的依赖管理器,一个存放依赖的 Map
  const depsMap = targetMap.get(target)
  ...
  // 临时收集器,存放要本次 trigger 要处理的依赖
  const effects = new Set<ReactiveEffect>()
  // 向临时收集器中添加依赖
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }
  ...
  // 把依赖管理器中对应 key 值的依赖添加到临时收集器
  if (key !== void 0) {
    add(depsMap.get(key))
  }  
  switch (type) {
    ...
    case TriggerOpTypes.SET:
      if (isMap(target)) {
        add(depsMap.get(ITERATE_KEY))
      }
  }
  ...
  const run = (effect: ReactiveEffect) => {
    // 触发 onTrigger 回调
    if (__DEV__ && effect.options.onTrigger) {
      effect.options.onTrigger({
        effect,
        target,
        key,
        type,
        newValue,
        oldValue,
        oldTarget
      })
    }
    // 如果存在调度器,则执行调度器
    // 不存在则执行副作用函数
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }

  // 遍历依赖并执行 run
  effects.forEach(run)
}

可以看到 trigger 函数的主要职责就是执行副作用函数,有一点需要注意的是如果副作用函数有配置调度器 scheduler ,而本文讨论的侦听器在生成副作用函数时都是存在调度器的,所以上面提到的问题也有了答案:副作用函数通过调度器和 callback 产生关联。让我们回顾一下 scheduler 的实现,以默认值 flush === 'pre' 举例分析:

// 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()
  }
}

调度任务 job 就不再放代码了,忘记了的可以往上翻翻,总之就是一个执行 callback 的函数,调度器会在组件挂载的情况下,调用 queuePreFlushCb 把调度任务加入到 pendingPreFlushCbs 全局队列(数组)。

queuePreFlushCb

function queuePreFlushCb(cb: SchedulerCb) {
  queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}

// 把调度任务加入到预刷新队列、并触发队列刷新
function queueCb(
  cb: SchedulerCbs,
  activeQueue: SchedulerCb[] | null,
  pendingQueue: SchedulerCb[],
  index: number
) {
  if (!isArray(cb)) {
    if (
      !activeQueue ||
      !activeQueue.includes(
        cb,
        (cb as SchedulerJob).allowRecurse ? index + 1 : index
      )
    ) {
      // 把调度任务加入到队列
      pendingQueue.push(cb)
    }
  } else {
    // if cb is an array, it is a component lifecycle hook which can only be
    // triggered by a job, which is already deduped in the main queue, so
    // we can skip duplicate check here to improve perf
    pendingQueue.push(...cb)
  }
  // 异步执行队列中的调度任务
  queueFlush()
}

可以看到 queueCb 函数有两个职责:

  1. 把调度任务加入到队列(比如 pendingPreFlushCbs
  2. 调用 queueFlush 异步执行队列中的调度任务

queueFlush

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    // 异步执行队列中的调度任务
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

这里的异步,是把 flushJobs 添加到微任务队列中去,这里比较难理解的点是 isFlushingisFlushPending 两个值得含义,这里解释下:

  • isFlushingtrue 表示目前处于执行 flushJobs 阶段
  • isFlushPendingtrue 表示目前处于执行 queueFlush 但还未进入 flushJobs (也就是说主线程还未执行完毕,还不到执行微任务) 之所以需要判断这两个时机,是为了确保 flushJobs 只被执行一次。多次调用 queueFlush 是很正常的情况(当调度任务中存在调度器时),没必要多次添加同一个微任务,当然即使执行多次目前看来也不会造成太多性能损耗,但调度器究竟会嵌套多少次也是无法预知的,避免添加无意义的微任务也让代码更具健壮性。

flushJobs

function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  if (__DEV__) {
    seen = seen || new Map()
  }
  
  // 清空 pendingPreFlushCbs 
  flushPreFlushCbs(seen)

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child so its render effect will have smaller
  //    priority number)
  // 2. If a component is unmounted during a parent component's update,
  //    its update can be skipped.
  queue.sort((a, b) => getId(a) - getId(b))

  // 清空 queue
  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job) {
        if (__DEV__) {
          checkRecursiveUpdates(seen!, job)
        }
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    flushIndex = 0
    queue.length = 0

    // 清空 pendingPostFlushCbs
    flushPostFlushCbs(seen)

    isFlushing = false
    currentFlushPromise = null
    // some postFlushCb queued jobs!
    // keep flushing until it drains.
    if (queue.length || pendingPostFlushCbs.length) {
      flushJobs(seen)
    }
  }
}

本函数会执行所有调度任务,包括三个队列:

  1. pendingPreFlushCbs
  2. queue
  3. pendingPostFlushCbs

组件更新的副作用函数的调度器就是 queueJob ,即关于组件更新的调度任务是放到 queue 这个队列的,这里也印证了上文说明的 prepost 顺序问题, pre -> component update -> post 。其实在组建更新流程中、挂载前,还会执行 flushPreFlushCbs 清空一次 pendingPreFlushCbs 队列,因为在更新过程中或许会触发侦听器(props update),这里不细说。

创建组件更新副作用函数时使用的配置项如下:

const prodEffectOptions = {
  scheduler: queueJob,
  allowRecurse: true
}

至此,Vue3 侦听器流程已经完成了闭环。

总结

关联PR

此文在创作期间提交的 PR

结语

写文章真的是耗费时间的一件事,并且即使我花了大量时间去描述(有时候一周都写不完一篇),也并不一定能帮助到别人,我认为这其中的阻碍有两点:

  1. 思维差异
  2. 理解不足

但这两点都不是很容易解决的问题,所以我虽然尽量在写,但前几篇文章也反馈寥寥,我想或许应该有点改变。所以希望看到这里的朋友,能针对本文给点建议,希望最终它能成为一篇合格的侦听器原理入门文章。