【源码系列#04】Vue3侦听器原理(Watch)

发布时间 2023-12-26 14:28:17作者: 柏成

专栏分享:vue2源码专栏vue3源码专栏vue router源码专栏玩具项目专栏,硬核?推荐?
欢迎各位ITer关注点赞收藏???

语法

侦听一个或多个响应式数据源,并在数据源变化时调用所给的回调函数

const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, (newValue, oldValue) => {
  console.log(`x is ${newValue}`)
})

// getter 函数
watch(
  () => x.value + y.value,
  (newValue, oldValue) => {
    console.log(`sum of x + y is: ${newValue}`)
  }
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x is ${newX} and y is ${newY}`)
})

第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组

第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:新值、旧值,以及一个用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。

第三个可选的参数是一个对象,支持以下这些选项:

  • immediate:在侦听器创建时立即触发回调。第一次调用时旧值是 undefined。
  • deep:如果源是对象,强制深度遍历,以便在深层级变更时触发回调。参考深层侦听器
  • flush:调整回调函数的刷新时机。参考回调的刷新时机watchEffect()
  • onTrack / onTrigger:调试侦听器的依赖。参考调试侦听器

源码实现

  • @issue1 深度递归循环时考虑对象中有循环引用的问题

  • @issue2 兼容数据源为响应式对象和getter函数的情况

  • @issue3 immediate回调执行时机

  • @issue4 onCleanup该回调函数会在副作用下一次重新执行前调用

/**
 * @desc 递归循环读取数据
 * @issue1 考虑对象中有循环引用的问题
 */
function traversal(value, set = new Set()) {
  // 第一步递归要有终结条件,不是对象就不在递归了
  if (!isObject(value)) return value

  // @issue1 处理循环引用
  if (set.has(value)) {
    return value
  }
  set.add(value)

  for (let key in value) {
    traversal(value[key], set)
  }
  return value
}

/**
 * @desc watch
 * @issue2 兼容数据源为响应式对象和getter函数的情况
 * @issue3 immediate 立即执行
 * @issue4 onCleanup:用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求
 */
// source 是用户传入的对象, cb 就是对应的回调
export function watch(source, cb, { immediate } = {} as any) {
  let getter

  // @issue2
  // 是响应式数据
  if (isReactive(source)) {
    // 递归循环,只要循环就会访问对象上的每一个属性,访问属性的时候会收集effect
    getter = () => traversal(source)
  } else if (isRef(source)) {
    getter = () => source.value
  } else if (isFunction(source)) {
    getter = source
  }else {
    return
  }

  // 保存用户的函数
  let cleanup
  const onCleanup = fn => {
    cleanup = fn
  }

  let oldValue
  const scheduler = () => {
    // @issue4 下一次watch开始触发上一次watch的清理
    if (cleanup) cleanup()
    const newValue = effect.run()
    cb(newValue, oldValue, onCleanup)
    oldValue = newValue
  }

  // 在effect中访问属性就会依赖收集
  const effect = new ReactiveEffect(getter, scheduler) // 监控自己构造的函数,变化后重新执行scheduler

  // @issue3
  if (immediate) {
    // 需要立即执行,则立刻执行任务
    scheduler()
  }

  // 运行getter,让getter中的每一个响应式变量都收集这个effect
  oldValue = effect.run()
}

测试代码

循环引用

对象中存在循环引用的情况

const person = reactive({
  name: '柏成',
  age: 25,
  address: {
    province: '山东省',
    city: '济南市',
  }
})
person.self = person

watch(
  person,
  (newValue, oldValue) => {
    console.log('person', newValue, oldValue)
  }, {
    immediate: true
  },
)

数据源

  1. 数据源为 ref 的情况,和 immediate 回调执行时机
const x = ref(1)

watch(
  x,
  (newValue, oldValue) => {
    console.log('x', newValue, oldValue)
  }, {
    immediate: true
  },
)

setTimeout(() => {
  x.value = 2
}, 100)
  1. 兼容数据源为 响应式对象getter函数 的情况,和 immediate 回调执行时机
const person = reactive({
  name: '柏成',
  age: 25,
  address: {
    province: '山东省',
    city: '济南市',
  }
})

// person.address 对象本身及其内部每一个属性 都收集了effect。traversal递归遍历
watch(
  person.address,
  (newValue, oldValue) => {
    console.log('person.address', newValue, oldValue)
  }, {
    immediate: true
  },
)

// 注意!我们在 watch 源码内部满足了 isFunction 条件
// 此时只有 address 对象本身收集了effect,仅当 address 对象整体被替换时,才会触发回调;
// 其内部属性发生变化并不会触发回调
watch(
  () => person.address,
  (newValue, oldValue) => {
    console.log('person.address', newValue, oldValue)
  }, {
    immediate: true
  },
)

// person.address.city 收集了 effect
watch(
  () => person.address.city,
  (newValue, oldValue) => {
    console.log('person.address.city', newValue, oldValue)
  }, {
    immediate: true
  },
)

setTimeout(() => {
  person.address.city = '青岛市'
}, 100)

onCleanup

watch回调函数接受三个参数:新值、旧值,以及一个用于注册副作用清理的回调函数(即我们的onCleanup)。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。

const person = reactive({
  name: '柏成',
  age: 25
})

let timer = 3000
function getData(timer) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(timer)
    }, timer)
  })
}

// 1. 第一次调用watch的时候注入一个取消的回调
// 2. 第二次调用watch的时候会执行上一次注入的回调
// 3. 第三次调用watch会执行第二次注入的回调
// 后面的watch触发会将上次watch中的 clear 置为true
watch(
  () => person.age,
  async (newValue, oldValue, onCleanup) => {
    let clear = false
    onCleanup(() => {
      clear = true
    })

    timer -= 1000
    let res = await getData(timer) // 第一次执行2s后渲染2000, 第二次执行1s后渲染1000, 最终应该是1000
    if (!clear) {
      document.body.innerHTML = res
    }
  },
)

person.age = 26
setTimeout(() => {
  person.age = 27
}, 0)