vue.js中watch和computed的简易实现

208 阅读6分钟

以下内容,参考了《Vue.js的设计与实现》和Vue的源码:github.com/vuejs/core.…
在vue3中watch函数和computed函数都是基于响应系统实现的。 探讨响应系统之前需要先了解两个概念

  1. 副作用函数
  2. 响应式数据
 let num=1
 const effect=()=>{
    num=2
 }

以上代码中effect函数修改了全局变量,它的执行会影响到其他使用num的函数的执行,所以说effect函数产生了副作用。
接下来说下响应式数据

  const obj={text:'11111'}
  const effect=()=>{
     document.body.innerText=obj.text
  }
  

以上代码中effect函数读取了obj.text中的值,现在我们希望当obj.text改变时,effect函数能重新执行,改变document.body.innerText的值。
很显然,当前还做不到这一点。此时我们需要用到ES6中引入的新特性Proxy(代理)

 const obj={text:'11111'}
 const newObj=new Proxy(obj,{
    get:(target, propKey)=>{
      return target[propKey]
    },
    set(target, propKey, value){
      const newVal=value
      target[propKey]=value
      effect()
      return newVal
    }
 })
  const effect=()=>{
   document.body.innerText=newObj.text
  }
  effct()
  setTimeout(()=>{
     newObj.text='new title'
   },2000)

当代码改为以上形式以后,文本的值就可以随着newObj.text的改变而改变。但是很显然,以上的实现依然是处于很不完善的状态。

 const obj={text:'11111',status:0}
 const effect=()=>{
   document.body.innerText=newObj.text
 }
 effect()
 setTimeout(()=>{
    newObj.status=1
  },2000)

很显然,当newObj.status的值改变时,副作用函数不应该执行,但是目前的写法依旧会引起副作用函数的执行。因此,在改写代码之前,需要梳理一下响应式数据和副作用函数的对应关系。如下图 截屏2024-09-25 16.15.05.png
重新修改代码如下

let activeEffect=null
//使用weakMap不影响垃圾回收避免内存泄漏
const map=new WeakMap()
//用于收集副作用函数
const track=(target,key)=>{
  if(!target.hasOwnProperty(key)){
    return
 }
   if(!map.has(target)){
    map.set(target,new Map())
   }
   const dep=map.get(target)
   if(!dep.has(key)){
       dep.set(key,new Set())
    }
    if(activeEffect){
      dep.get(key).add(activeEffect)
    }
      return
 }
 //用于触发副作用函数
 const trigger=(target,key)=>{
   const dep=map.get(target)
   const effectFnList =  dep.get(key)
   effectFnList&&effectFnList.forEach(effect=>{
     effect()
   })
}
  const effect=(fn)=>{
    const effectfn=()=>{
    activeEffect=effectfn
      fn()
    }
}
var newObj=new Proxy(obj,{
   get(target, propKey){
     track(target,propKey)
     return target[propKey]
   },
   set(target, propKey, value){
     target[propKey]=value
     trigger(target,propKey)
     return true
   }
})
effect(()=>{
   document.body.innerText=newObj.text
})
setTimeout(()=>{
    newObj.status=1
 },2000)

经过以上改写以后,可以顺利解决以上的问题。现在的实现依然不算完善,但是大体思路差不多了。感兴趣的朋友可以详细的读下Vue的官方成员霍春阳老师的《Vue.js的设计与实现》。以上内容我们设计了一个极其简易的响应式系统,接下来,可以想想如何基于这个响应式系统实现watch了,首先watch接收三个参数数据源、回调函数、options(用于配置deep、immediate)watch函数的作用是当source变动时,重新执行回调函数,并将旧值值和最新的值作为参数传入,但是目前的响应式系统只会去执行副作用函数,此时需要引入一个调度器,来执行回调函数。和一个lazy用于决定副作用函数的执行时机, 将以上的代码作如下修改。

const effect = (fn, options = {}) => {
   const effectfn = () => {
      activeEffect = effectfn;
      return fn();
   };
    effectfn.options = options;
   if(!options.lazy){ 
     effectfn()
   }
    return effectfn
};
const trigger = (target, key) => {
    const dep = map.get(target);
     const effectFnList =  dep.get(key)
    effectFnList&&effectFnList.forEach((effect) => {
        if (effect.options.scheduler) {
            effect.options.scheduler();
        } else {
           effect();
        }
    });
};

经过以上的修改以后,就可以实现一个简易版的watch了,代码如下

const watch = (source, cb, options={}) => {
  let getter,oldValue,newValue;
  const { immediate} =options
  if (typeof source ==="function") {
      getter = source;
  } else {
      getter = () => traverse(source);
  }
  
 const traverse=(val)=>{   
     for (const key in val) {
       val[key]
     }
     return val
 }
 const job=() => {
  newValue =effectfn()
  cb(newValue,oldValue)
  oldValue=newValue
}
 const effectfn=effect(getter,{scheduler:job,lazy:true})
 if(immediate){  //当传递immediate函数为true时,回调函数会立即执行
    job()
 }else{
    oldValue=effectfn()
 }
};
const cb=(newVal,oldVal)=> { console.log(newVal,oldVal) }
watch(()=> newObj.text,cb)   
setTimeout( ()=>{newObj.text = '222222'},1000)

以上的watch只考虑了source为响应式对象或getter函数的情况,没有对deep配置项处理。在这里顺便探讨一个关于watch的问题,当watchsource为一个对象时怎么解决回调函数中新值和旧值一样的问题。比如使用以上watch

watch(newObj,cb)
setTimeout( ()=>{newObj.text = '11111'},1000)
//cb打印出来的值newValue和oldValue是一样的

对于这种情况我的一种解决方案是,重新对watch做一次封装,如下代码

const objWatch=(source,cb,options={},deepClone)=>{
  if(!deepClone){
    deepClone=(val)=>{
      return JSON.parse(JSON.stringify(val))
    }
  }
  if(typeof source =="object" && source !=null){
     let oldValue = deepClone(source)
     const newCb =(newValue,_)=>{
      cb(newValue,oldValue)
      oldValue=deepClone(newValue)
   }
   return watch(source,newCb,options)
  }
  return  watch(source,cb,options)
}

objWatch中主要逻辑是针对回调函数的封装,每一次更改都对数据做一次深拷贝,然后将前一次的深拷贝的值当作旧值传入到回调函数中,深拷贝函数设置为一个参数传入便于灵活控制,如果不传,则使用默认的深拷贝函数
接下来实现一个简易版的computed

   const computed=(getter)=>{
       let value,dirty=true;
       const effectFn=effect(getter,{
         lazy:true,
         scheduler:()=>{
          dirty=true
       }
     })
     const obj={
        get value(){
        if(dirty){
          value=effectFn()
          dirty=false
        }
        return value
      }
     }
   return obj
  }
  const getter=() => {
     return newObj.text + newObj.status
  }
  var  res = computed(getter)
  

在以上代码中当computed的返回值通过value访问时才会通过effectFn来获取最新的计算值。只有当dirtytrue时,才会重新计算,而dirty的更改是通过scheduler完成的。接下来,可以探讨一个问题。
在vue的computed中为什么不支持异步?
在这里说下我的理解,computed的设计初衷是将响应式数据的计算的进行一层封装,并对其计算结果进行缓存,以达到减少重复计算提高性能,其返回值会随着依赖项的改变而改变。如果支持了异步,那么就可能出现依赖项改变时,计算属性的值因为异步的原因而没有实时更改。基于此,在源码的设计中getter函数就是一个同步函数,直接返回响应式数据的计算结果。举个不当的例子

const getter =async ()=>{
  const res =await new Promise((resolve, reject) => {
  setTimeout(() => {
     resolve(newObj.title + newObj.status)
   }, 2000)
  })
  return res
}
const com=computed(getter) 
console.log(com.value)     // 会打印出来一个pending状态的Promise
//以上的代码使用vue中的computed是一样的结果
import {computed,ref} from 'vue'
const obj = ref({title:'111',status:'22222'})
const com = computed(async ()=>{
  const res =await new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(obj.value.title + obj.value.status)
  },2000)
 })
   return res
})
com.value  //与上面的打印值一样