【笔记】Vue.js设计与实现:watch原理

53 阅读5分钟

所谓watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。举个例子:

watch(obj,()=>{
	console.log('数据变了')
})

//修改响应数据的值,会导致回调函数执行
obj.foo++

假设obj是一个响应数据,使用watch函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。

实际上,watch的实现本质上就是利用了effect以及options.scheduler选项,如以下代码所示:

effect(()=>{
  console.log(obj.foo)
},{
  scheduler(){
    //当obj.foo的值变化时,会执行scheduler调度函数
  }
})

在一个副作用函数中访问响应式数据obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。

但有一个例外,即如果副作用函数存在scheduler选项,当响应式数据发生变化时,会触发scheduler调度函数执行,而非直接触发副作用函数执行。

从这个角度来看,其实scheduler调度函数就是相当于一个回调函数,而watch的实现就是利用了这个特点。下面是最简单的watch函数的实现:

scss复制代码//watch函数接受两个参数,source是响应式数据,cb是回调函数
function watch(source,cb){
	effect(
    //触发读取操作,从而建立联系
  ()=> source.foo,
    {
      scheduler(){
        //当数据变化时,调用回调函数cb
        cb()
      }
    }
  )
}

我们可以如下所示使用watch函数:

javascript复制代码const data = {foo:1}
const obj = new Proxy(data,{/*...*/})
watch(obj,()=>{
  console.log('数据变化了')
})
obj.foo++

上面这段代码能正常工作, 但是我们注意到在watch函数的实现中,硬编码了对source.foo的读取操作。

scss复制代码function watch(source,cb){
	effect(
    // 调用traverse递归地读取
    ()=> traverse(source),
    {
      scheduler(){
        //当数据变化时,调用回调函数cb
        cb()
      }
    }
  )
}

function traverse(value,seen=new Set()){
  //如果读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if(typeof value !== 'object' || value === null || seen.has(value)) return
  //将数据添加到seen中,代表遍历地读取过了, 避免循环引用引起的死循环
  seen.add(value)
  //暂时不考虑数组等其他结构
  //假设value就是一个对象,使用for...in 读取对象的每一个值,并递归调用traverse进行处理
  for(const k in value){
    traverse(value[k],seen)
  }
  return value
}

如上面的代码所示,在watch内部的effect中调用traverse函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能触发回调函数执行。

watch函数除了可以观测响应式数据,还可以接受一个getter函数:

javascript复制代码watch(
  //getter函数
  ()=> obj.foo,
  //回调函数
  ()=>{
    console.log('obj.foo 的值变了')
  }
)

如以上代码所示,传递给watch函数的第一个参数不再是一个响应式数据,而是一个getter函数。在getter函数内部,用户可以指定该watch依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:\

scss复制代码function watch(source,cb){
  //定义getter
  let getter
  //如果source是函数,说明用户传递的是getter,所以直接把source赋值给getter
  if(typeof source === 'function'){
    getter = source
  }else{
    //否则按照原来的实现调用traverse递归地读取
    getter = () => traverse(source)
  }
  effect(
    //执行 getter
    () => getter(),
    {
      scheduler(){
        cb()
    }
  )
}

首先判断source的类型,如果是函数类型,说明用户直接传递了getter函数, 这时直接使用用户的getter函数;如果不是函数类型,那么保留之前的做法,即调用traverse函数递归地读取。这样就实现了自定义getter的功能,同时使得watch函数更加强大。。

仔细观察你可能会注意到,现在的实现还缺少了一个非常重要的能力,即在回调函数中拿不到旧值与新值。通常我们在使用Vue.js中的watch函数时,能够在回调函数中得倒变化前后的值;

javascript复制代码watch(
  () => obj.foo,
  (newValue,oldValue) =>{
    console.log(newValue,oldValue) // 2,1
  }
)

obj.foo++

那么如何获得新值与旧值呢?这需要充分利用effect函数的lazy选项 , 如下代码所示:

scss复制代码function watch(source,obj){
	let getter
  if(typeof source === 'function'){
    getter = source
  }else{
    getter = () => traverse(source)
  }

  //定义旧值与新值
  let objValue, newValue
  //使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
  const effectFn = effect(
    ()=> getter(),
    {
      lazy:true,
      scheduler(){
        //在scheduler 中重新执行副作用函数,得到的是新值
        newValue = effectFn()
        // 将旧值和新值作为回调函数的参数
        cb(newValue,oldValue)
        //更新旧值,不然下一次会得到错误的旧值
        oldValue = newValue
      }
    }
  )
  //手动调用副作用函数,拿到的值就是旧值
  oldValue = effectFn()
}

在这段代码中, 最核心的改动就是使用lazy选项创建了一个懒执行的effect。注意上面代码中最下面的部分,我们手动调用effectFn函数得到的返回值就是旧值,即第一次执行的到的值。

当变化发生并触发scheduler调度函数执行时,会重新调用effectFn函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数cb就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值。