vue3响应式API是怎么实现的?(watch篇)

1,072 阅读4分钟

在阅读这篇之前,推荐先看一下Vue3的响应式API是怎么实现的(ref篇、上),了解一下什么是副作用函数,以及副作用函数追踪依赖和依赖改变重新触发副作用函数执行的过程

这篇说一下watch(), 先看一下文档是怎么说的:

image.png

说白了就是观测一个响应式数据,数据变化的时候通知并执行相应的回调函数:

watch(obj, () => {
    console.log('响应式数据改变');
})

// 打印“响应式数据改变”
obj.value++

还记得我们上一篇computed中提到的,effect函数中的scheduler,如果effect提供了scheduler就会执行scheduler调度函数而不是执行effect,所以说scheduler给用户提供了如何调用effect注册的副作用函数的方法。

watch提供了回调,而watch会在响应式数据变化时触发这个回调,结合上下文我们可以想到,在响应式数据变化的时候,不再触发副作用函数(也就是setter),而是转而使用scheduler,在scheduler中调用这个回调。所以可以给出一个简单实现:

function watch(source, callback) {
  effect(
    () => {
      // 读取响应式数据,和副作用函数建立联系
      source.value
    },
    {
      scheduler() {
      // 调度函数内执行callback
        callback()
      }
    }
  )
}

上面的实现就实现了执行watch时建立响应式联系,数据变化时执行scheduler,在scheduler中调用这个回调。

如果你看过开头讲的文章,就知道在副作用函数中读取source.value只会在source的value这个key中收集副作用函数,所以也只有source的value这个属性改变后会触发副作用函数重新执行。所以说这么做只能检测到source的value属性的变化。但是watch要求的时默认监听对象的深层次变化(本文只处理响应式对象和数组两种source,原始值的响应式很简单,直接读取一下ref.value就可以)。我们想要的是source的每一个属性变化都会被检测到,所以要让source的每一个属性都建立响应式关联,所以我们需要递归地去遍历读取source上的每一个属性:

function watch(source, callback) {
  effect(
    () => {
      // 递归遍历每一个数据
      traverse(source)
    },
    {
      scheduler() {
        // 调度函数内执行callback
        callback()
      }
    }
  )
}

// 遍历函数
function traverse(value) {
  // 记录已经读取过的value,防止循环引用导致的死循环
  let seen = new Set()
  // 如果不是对象,那么什么都不用做因为已经读取过了,如果是null也什么都不做
  // 如果seen中有value,那么说明存在循环引用而且value已经被读取过了,所以什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return

// 只考虑了纯对象一种情况,源码中还有其他判断分支
  for (let key in value) {
    traverse(value[key])
  }

  return value
}

可能有小伙伴好奇循环引用是啥样的,我举个例子:

let a = {
  b: {}
}
a.b = a
console.log(a.b)

执行这段代码就可以发现a和b无限套娃引用,所以seen就是为了避免这个情况,而且set还兼具了去重。

但是现在还不完美,我们知道watch的source还可以接受一个setter,甚至它还可以接受一个混合source:

image.png

watch(()=>source.value, callback)

watch([...args], callback)

所以要针对的处理一下:

function watch(source, callback) {
  let getter
  if (typeof source === 'function') {
    // 如果source是getter
    getter = source
  } else if (Array.isArray(source)) {
    // 如果是数组
    getter = () =>
      source.map((val) => {
        // 我只处理了数组中只有普通对象和getter这两种情况
        if (typeof source === 'function') {
          return val()
        } else {
          traverse(val)
        }
      })
  } else {
    getter = () => {
      traverse(source)
    }
  }
  
    effect(
      () => {
        // 执行封装好的getter,读取数据
        getter()
      },
      {
        scheduler() {
          // 调度函数内执行callback
          callback()
        }
      }
    )
}

现在watch看起来就很完美了,但是还差一部,在watch的回调中是可以访问到新值和旧值的:

watch(
  () => source.value,
  // prevValue是上次getter的返回值
  (value, prevValue) => {
    /* ... */
  }
)

所以还需要对应的完善一下,通过懒执行手动调用拿到副作用函数返回值,同时拿到首次返回值prevValue,这样每次数据变更时拿到新值,就实现了变更时在callback中传入新值和旧值。

function watch(source, callback) {
  let getter
  if (typeof source === 'function') {
    // 如果source是getter
    getter = source
  } else if (Array.isArray(source)) {
    // 如果是数组
    getter = () =>
      source.map((val) => {
        // 我只处理了数组中只有普通对象和getter
        if (typeof source === 'function') {
          return val()
        } else {
          traverse(val)
        }
      })
  } else {
    getter = () => {
      traverse(source)
    }
  }

  let value
  let prevValue

  // 前面讲到过,如果lazy为true,
  // 就返回副作用函数的包装函数,这个包装函数返回副作用函数的值
  const effectFn = effect(
    () => {
      // 执行封装好的getter,读取数据
      getter()
    },
    {
      lazy: true,
      scheduler() {
        // 调度函数内执行callback
        value = effectFn()
        callback(value, prevValue)
        prevValue = value
      }
    }
  )

  // 因为懒执行,所以手动调用进行读取操作建立响应联系
  // 数据变更调用scheduler的时候,会重新拿到新值
  prevValue = effectFn()
}

其实这一块和computed是一样的,都是通过懒执行拿到副作用函数返回值,同时响应式数据变更触发scheduler达到想要的效果。

不同之处在于,computed我们设置lazy是因为想要在读取computed值的时候才触发响应式这一套逻辑。但是在watch中设置lazy是想后续首次执行时拿到prevValue