【vue3源码】五、watch源码解析

【vue3源码】五、watch源码解析

参考代码版本:vue 3.2.37

官方文档:https://vuejs.org/

watch用来监听特定数据源,并在单独的回调函数中执行副作用。默认是惰性的——即回调仅在侦听源发生变化时被调用。
文件位置:packages/runtime-core/src/apiWatch.ts

使用示例

监听一个getter函数:

const state = reactive({ count: 0 })
watch(
  () => state.count,
  (newVal, oldVal) => {
    //... 
  }
)

监听一个ref

const count = ref(0)
watch(
  count,
  (newVal, oldVal) => {
    //... 
  }
)

监听多个数据源:

const foo = ref('')
const bar = ref('')
watch(
  [ foo, bar ],
  ([ newFoo, newBar ], [ oldFoo, oldBar ]) => {
    // ...
  }
)

深度监听:

const state = reactive({ count: 0 })
watch(
  () => state,
  () => {
    // ...
  },
  { deep: true }
)

// or
watch(state, () => {
  // ...
})

源码分析

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接收三个参数:source监听的源、cb回调函数、options监听配置,watch函数返回一个停止监听函数。。

watch中调用了一个叫做doWatch的函数,与watch作用相似的watchEffectwatchPostEffectwatchSyncEffect内部也都使用了这个doWatch函数。

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

export function watchPostEffect(
  effect: WatchEffect,
  options?: DebuggerOptions
) {
  return doWatch(
    effect,
    null,
    (__DEV__
      ? Object.assign(options || {}, { flush: 'post' })
      : { flush: 'post' }) as WatchOptionsBase
  )
}

export function watchSyncEffect(
  effect: WatchEffect,
  options?: DebuggerOptions
) {
  return doWatch(
    effect,
    null,
    (__DEV__
      ? Object.assign(options || {}, { flush: 'sync' })
      : { flush: 'sync' }) as WatchOptionsBase
  )
}

可见doWatchwatch API的核心,接下来重点研究doWatch的实现。

doWatch

doWatch源码过长,这里就不搬运了,在分析过程中,会展示相关代码。

doWatch函数接收三个参数:source监听的数据源,cb回调函数,options:监听配置。doWatch返回一个停止监听函数。

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

首先需要对immediatedeep做校验,如果cbnullimmediatedeep不为undefined进行提示。

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.`
  )
}

// 当前组件实例
const instance = currentInstance
// 副作用函数,在初始化effect时使用
let getter: () => any
// 强制触发监听
let forceTrigger = false
// 是否为多数据源。
let isMultiSource = false

然后根据传入的soure确定getterforceTriggerisMultiSource。这里分了5个分支:

  • 如果sourceref类型,getter是个返回source.value的函数,forceTrigger取决于source是否是浅层响应式。
if (isRef(source)) {
  getter = () => source.value
  forceTrigger = isShallow(source)
}
  • 如果sourcereactive类型,getter是个返回source的函数,并将deep设置为true
if (isReactive(source)) {
  getter = () => source
  deep = true
}
  • 如果source是个数组,将isMultiSource设为trueforceTrigger取决于source是否有reactive类型的数据,getter函数中会遍历source,针对不同类型的source做不同处理。
if (isArray(source)) {
  isMultiSource = true
  forceTrigger = source.some(isReactive)
  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)
      }
    })
}
  • 如果source是个function。存在cb的情况下,getter函数中会执行source,这里source会通过callWithErrorHandling函数执行,在callWithErrorHandling中会处理source执行过程中出现的错误;不存在cb的话,在getter中,如果组件已经被卸载了,直接return,否则判断cleanupcleanup是在watchEffect中通过onCleanup注册的清理函数),如果存在cleanup执行cleanup,接着执行source,并返回执行结果。source会被callWithAsyncErrorHandling包装,该函数作用会处理source执行过程中出现的错误,与callWithErrorHandling不同的是,callWithAsyncErrorHandling会处理异步错误。
if (isFunction(source)) {
  if (cb) {
    getter = () =>
      callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
  } else {
    // watchEffect
    getter = () => {
      // 如果组件实例已经卸载,直接return
      if (instance && instance.isUnmounted) {
        return
      }
      // 如果清理函数,则执行清理函数
      if (cleanup) {
        cleanup()
      }
      // 执行source,传入onCleanup,用来注册清理函数
      return callWithAsyncErrorHandling(
        source,
        instance,
        ErrorCodes.WATCH_CALLBACK,
        [onCleanup]
      )
    }
  }
}

callWithErrorHandling函数可以接收四个参数:fn待执行的函数、instance组件实例、typefn执行过程中出现的错误类型、argsfn执行所需的参数。

export function callWithErrorHandling(
  fn: Function,
  instance: ComponentInternalInstance | null,
  type: ErrorTypes,
  args?: unknown[]
) {
  let res
  try {
    res = args ? fn(...args) : fn()
  } catch (err) {
    handleError(err, instance, type)
  }
  return res
}

callWithAsyncErrorHandling的参数与callWithErrorHandling类似,与callWithErrorHandling不同的是,callWithAsyncErrorHandling可以接受一个fn数组。

export function callWithAsyncErrorHandling(
  fn: Function | Function[],
  instance: ComponentInternalInstance | null,
  type: ErrorTypes,
  args?: unknown[]
): any[] {
  if (isFunction(fn)) {
    const res = callWithErrorHandling(fn, instance, type, args)
    if (res && isPromise(res)) {
      res.catch(err => {
        handleError(err, instance, type)
      })
    }
    return res
  }

  const values = []
  for (let i = 0; i < fn.length; i++) {
    values.push(callWithAsyncErrorHandling(fn[i], instance, type, args))
  }
  return values
}
  • 其他情况,getter会被赋为一个空函数
getter = NOOP
__DEV__ && warnInvalidSource(source)

接下来会对vue2的数组的进行兼容性处理,breaking-changes/watch

if (__COMPAT__ && cb && !deep) {
  const baseGetter = getter
  getter = () => {
    const val = baseGetter()
    if (
      isArray(val) &&
      checkCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance)
    ) {
      traverse(val)
    }
    return val
  }
}

如果存在cb并且deeptrue,那么需要对数据进行深度监听,这时,会重新对getter赋值,在新的getter函数中递归访问之前getter的返回结果。

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

traverse实现,递归遍历所有属性,seen用于防止循环引用问题。

export function traverse(value: unknown, seen?: Set<unknown>) {
  // 如果value不是对象或value不可被转为代理(经过markRaw处理),直接return value
  if (!isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
    return value
  }
  // sean用于暂存访问过的属性,防止出现循环引用的问题
  // 如:
  // const obj = { a: 1 }
  // obj.b = obj
  seen = seen || new Set()
  // 如果seen中已经存在了value,意味着value中存在循环引用的情况,这时return value
  if (seen.has(value)) {
    return value
  }
  // 添加value到seen中
  seen.add(value)
  // 如果是ref,递归访问value.value
  if (isRef(value)) {
    traverse(value.value, seen)
  } else if (isArray(value)) { // 如果是数组,遍历数组并调用traverse递归访问元素内的属性
    for (let i = 0; i < value.length; i++) {
      traverse(value[i], seen)
    }
  } else if (isSet(value) || isMap(value)) { // 如果是Set或Map,调用traverse递归访问集合中的值
    value.forEach((v: any) => {
      traverse(v, seen)
    })
  } else if (isPlainObject(value)) { // 如果是原始对象,调用traverse递归方位value中的属性
    for (const key in value) {
      traverse((value as any)[key], seen)
    }
  }
  // 最后需要返回value
  return value
}

到此,getter函数(getter函数中会尽可能访问响应式数据,尤其是deeptrue并存在cb的情况时,会调用traverse完成对source的递归属性访问)、forceTriggerisMultiSource已经被确定,接下来声明了两个变量:cleanuponCleanuponCleanup会作为参数传递给watchEffect中的effect函数。当onCleanup执行时,会将他的参数通过callWithErrorHandling封装赋给cleanupeffect.onStopeffect在后文中创建)。

let cleanup: () => void
let onCleanup: OnCleanup = (fn: () => void) => {
  cleanup = effect.onStop = () => {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
  }
}

紧接着是一段SSR处理过程:

if (__SSR__ && isInSSRComponentSetup) {
  // we will also not call the invalidate callback (+ runner is not set up)
  onCleanup = NOOP
  if (!cb) {
    getter()
  } else if (immediate) {
    callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
      getter(),
      isMultiSource ? [] : undefined,
      onCleanup
    ])
  }
  return NOOP
}

然后声明了一个oldValuejob变量。如果是多数据源oldValue是个数组,否则是个对象。

job函数的作用是触发cb(watch)或执行effect.run(watchEffect)。job函数中会首先判断effect的激活状态,如果未激活,则return。然后判断如果存在cb,调用effet.run获取最新值,下一步就是触发cb,这里触发cb需要满足以下条件的任意一个条件即可:

  1. 深度监听deep===true
  2. 强制触发forceTrigger===true
  3. 如果多数据源,newValue中存在与oldValue中的值不相同的项(利用Object.is判断);如果不是多数据源,newValueoldValue不相同。
  4. 开启了vue2兼容模式,并且newValue是个数组,并且开启了WATCH_ARRAY

只要符合上述条件的任意一条,便可已触发cb,在触发cb之前会先调用cleanup函数。执行完cb后,需要将newValue赋值给oldValue

如果不存在cb,那么直接调用effect.run即可。

let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => {
  if (!effect.active) {
    return
  }
  if (cb) {
    const newValue = effect.run()
    if (
      deep ||
      forceTrigger ||
      (isMultiSource
        ? (newValue as any[]).some((v, i) =>
          hasChanged(v, (oldValue as any[])[i])
        )
        : hasChanged(newValue, oldValue)) ||
      (__COMPAT__ &&
        isArray(newValue) &&
        isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
    ) {
      if (cleanup) {
        cleanup()
      }
      callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
        newValue,
        // 如果oldValue为INITIAL_WATCHER_VALUE,说明是第一次watch,那么oldValue是undefined
        oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
        onCleanup
      ])
      oldValue = newValue
    }
  } else {
    effect.run()
  }
}
job.allowRecurse = !!cb

接下来声明了一个调度器scheduler,在scheduler中会根据flush的不同决定job的触发时机:

let scheduler: EffectScheduler
if (flush === 'sync') {
  scheduler = job as any 
} else if (flush === 'post') {
  // 延迟执行,将job添加到一个延迟队列,这个队列会在组件挂在后、更新的生命周期中执行
  scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
  // 默认 pre,将job添加到一个优先执行队列,该队列在挂载前执行
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      job()
    }
  }
}

此时,getterscheduler准备完成,创建effect实例。

const effect = new ReactiveEffect(getter, scheduler)

创建effect实例后,开始首次执行副作用函数。这里针对不同情况有多个分支:

  • 如果存在cb的情况
    • 如果immediatetrue,执行job,触发cb
    • 否则执行effect.run()进行依赖的收集,并将结果赋值给oldValue
  • 如果flush===post,会将effect.run推入一个延迟队列中
  • 其他情况,也就是watchEffect,则会执行effect.run进行依赖的收集
if (cb) {
  if (immediate) {
    job()
  } else {
    oldValue = effect.run()
  }
} else if (flush === 'post') {
  queuePostRenderEffect(
    effect.run.bind(effect),
    instance && instance.suspense
  )
} else {
  effect.run()
}

最后,返回一个函数,这个函数的作用是停止watch对数据源的监听。在函数内部调用effect.stop()effect置为失活状态,如果存在组件实例,并且组件示例中存在effectScope,那么需要将effecteffectScope中移除。

return () => {
  effect.stop()
  if (instance && instance.scope) {
    remove(instance.scope.effects!, effect)
  }
}

watchEffect、watchSyncEffect、watchPostEffect

watchEffectwatchSyncEffectwatchPostEffect的实现均是通过doWatch实现。

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

export function watchPostEffect(
  effect: WatchEffect,
  options?: DebuggerOptions
) {
  return doWatch(
    effect,
    null,
    (__DEV__
      ? Object.assign(options || {}, { flush: 'post' })
      : { flush: 'post' }) as WatchOptionsBase
  )
}

export function watchSyncEffect(
  effect: WatchEffect,
  options?: DebuggerOptions
) {
  return doWatch(
    effect,
    null,
    (__DEV__
      ? Object.assign(options || {}, { flush: 'sync' })
      : { flush: 'sync' }) as WatchOptionsBase
  )
}

watch与watchEffect的区别

watch只会追踪在source中明确的数据源,不会追踪回调函数中访问到的东西。而且只在数据源发生变化后触发回调。watch会避免在发生副作用时追踪依赖(当发生副作用时,会执行调度器,在调度器中会将job推入不同的任务队列,达到控制回调函数的触发时机的目的),因此,我们能更加精确地控制回调函数的触发时机。

watchEffect,会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式property

示例分析

为了更好地理解watchwatchEffect的流程,我们以下面几个例子来理解watchwatchEffect

例1

const state = reactive({ str: 'foo', obj: { num: 1 } })
const flag = ref(true)

watch(
  [ flag, () => state.obj ],
  ([ newFlag, newObj ], [ oldFlag, oldObj ]) => {
    console.log(newFlag)
    console.log(newObj.num)
    console.log(oldFlag)
    console.log(oldObj && oldObj.num)
  },
  {
    immediate: true,
    flush: 'sync'
  }
)

state.obj.num = 2

state.obj = {
  num: 2
}

watch中调用doWatch方法,在doWatch会构造getter函数,因为所监听的数据源是个数组,所以getter函数返回值也是个数组,因为数据源的第一项是个ref,所以getter返回值第一项是ref.value,数据源的第二项是个function,所以getter返回值第二项是() => state.obj的返回值,也就是state.obj,由于我们未指定depp,最终生成的getter() => [ref.value, state.obj]

然后利用getterscheduler生成effect,因为我们指定了immediate: true,所以会立即执行job函数,在job函数中,会执行effect.run()(这个过程中最终执行getter函数,而在执行getter函数的过程中会被对应响应式对象的proxy所拦截,进而收集依赖),然后将effect.run()的结果赋值给newValue。然后对位比较newValueoldValue中的元素,因为oldValue此时是个空数组,所以会触发cb,在cb触发过程中将newValueoldValue依次传入,此时打印true 1 undefined undefined,当cb执行完,将newValue赋值为oldValue

当执行state.obj.num = 2时。因为在上一次的依赖收集过程中(也就是getter执行过程中),并没有访问到num属性,也就不会收集它的依赖,所以该步骤不会影响到watch

state.obj = { num: 2 }时,会触发到obj对应的依赖,而在依赖触发过程中会执行调度器,因为flushsync,所以调度器就是job,当执行job时,通过effect.run()得到newValue,因为这时oldValue中的state.valuenewValue中的state.value已经不是同一个对象了,所以触发cb。打印true 2 true 2

为什么第二次打印newObj.numoldObj.num相同?因为oldValue中的oldObj保存的是state.obj的引用地址,一旦state.obj发生改变,oldValue也会对应改变。

例2

const state = reactive({ str: 'foo', obj: { num: 1 } })
const flag = ref(true)

watchEffect(() => {
  console.log(flag.value)
  console.log(state.obj.num)
})


state.obj.num = 2

state.obj = {
  num: 3
}

与例1相同,例2先生成gettergetter中会调用source)与scheduler,然后生成effect。因为watchEffect是没有cb参数,也未指定flush,所以会直接执行effct.run()。在effect.run执行过程中,会调用source,在source执行过程中会将effect收集到flag.deptargetMap[toRaw(state)].objtargetMap[toRaw(state).obj].num中。所以第一次打印true 1

当执行state.obj.num = 2,会触发targetMap[toRaw(state).obj].num中的依赖,也就是effect,在触发依赖过程中会执行effect.scheduler,将job推入一个pendingPreFlushCbs队列中。

当执行state.obj = { num: 3 },会触发targetMap[toRaw(state)].obj中的依赖,也就是effect,在触发依赖过程中会执行effect.scheduler,将job推入一个pendingPreFlushCbs队列中。

最后会执行pendingPreFlushCbs队列中的job,在执行之前会对pendingPreFlushCbs进行去重,也就是说最后只会执行一个job。最终打印true 3

总结

watchwatchEffectwatchSyncEffectwatchPostEffect的实现均是通过一个doWatch函数实现。

dowatch中会首先生成一个getter函数。如果是watchAPI,那么这个getter函数中会根据传入参数,访问监听数据源中的属性(可能会递归访问对象中的属性,取决于deep),并返回与数据源数据类型一致的数据(如果数据源是ref类型,getter函数返回ref.value;如果数据源类型是reactivegetter函数返回值也是reactive;如果数据源是数组,那么getter函数返回值也应该是数组;如果数据源是函数类型,那么getter函数返回值是数据源的返回值)。如果是watchEffect等API,那么getter函数中会执行source函数。

然后定义一个job函数。如果是watchjob函数中会执行effect.run获取新的值,并比较新旧值,是否执行cb;如果是watchEffect等API,job中执行effect.run。那么如何只监听到state.obj.num的变换呢?

当声明完job,会紧跟着定义一个调度器,这个调度器的作用是根据flushjob放到不同的任务队列中。

然后根据getter调度器scheduler初始化一个ReactiveEffect`实例。

接着进行初始化:如果是watch,如果是立即执行,则马上执行job,否则执行effect.run更新oldValue;如果flushpost,会将effect.run函数放到延迟队列中延迟执行;其他情况执行effect.run

最后返回一个停止watch的函数。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容