以下内容,参考了《Vue.js的设计与实现》和Vue的源码:github.com/vuejs/core.…
在vue3中watch函数和computed函数都是基于响应系统实现的。
探讨响应系统之前需要先了解两个概念
- 副作用函数
- 响应式数据
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的值改变时,副作用函数不应该执行,但是目前的写法依旧会引起副作用函数的执行。因此,在改写代码之前,需要梳理一下响应式数据和副作用函数的对应关系。如下图
重新修改代码如下
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的问题,当watch的source为一个对象时怎么解决回调函数中新值和旧值一样的问题。比如使用以上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来获取最新的计算值。只有当dirty为true时,才会重新计算,而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 //与上面的打印值一样