详解 Vue3 侦听器

3,009 阅读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 提供了两个侦听函数:watchEffect 和 watch ,下面我们来一一列举他们的使用方式(不涉及 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 至少需要指定两个参数: source 和 callback,其中 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)
      }
    })
  }
  ...
}

可以看到,watchOptions 被 push 到了一个名为 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 函数主要是在处理 key 和 raw 配置,这也印证了上文 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 有两个:watchEffect 和 watch,用法差异在上文有所体现。

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. 如果 source 是 ref 则在生成 getter 函数时返回 source.value
  2. 如果 source 是 reactive 则需要设置 deep = true
  3. 如果 source 是数组,则 getter 返回一个新的数组,新数组由 source 每一项取到的值
  4. 如果 source 是函数,那就区分 watchEffect 和 watch 两个 API ,生成不同的取值函数
  5. 如过 deep 为 true ,则调用 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. 生成调度器

生成调度任务没啥好说的,就是分别处理 watchEffect 和 watch 两个 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. 如果存在 callback 且 immediate === true 则直接执行一次调度任务(调度任务中也会执行副作用函数,获取最新的值)
  2. 如果存在 callback 且 immediate !== 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 函数即对象了,它也拥有很多属性: id 、active 、deps 、 ... 回到函数本身:

  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 添加到微任务队列中去,这里比较难理解的点是 isFlushing 和 isFlushPending 两个值得含义,这里解释下:

  • isFlushing 为 true 表示目前处于执行 flushJobs 阶段
  • isFlushPending 为 true 表示目前处于执行 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 这个队列的,这里也印证了上文说明的 pre 和 post 顺序问题, pre -> component update -> post 。其实在组建更新流程中、挂载前,还会执行 flushPreFlushCbs 清空一次 pendingPreFlushCbs 队列,因为在更新过程中或许会触发侦听器(props update),这里不细说。

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

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

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

总结

关联PR

此文在创作期间提交的 PR

结语

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

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

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