当前位置: 首页 > 科技观察

手写Vue3响应式系统:核心是一个数据结构_0

时间:2023-03-22 01:13:24 科技观察

响应式是Vue的一个特性。简历里写Vue项目,基本都会问响应式实现原理。而且不仅仅是Vue,状态管理库Mobx也是基于响应式实现的。响应性是如何实现的?不说原理了,我们手写一个简单的版本。响应式首先,什么是响应式?响应式就是当观察到的数据发生变化时,做一系列的联动处理。就像一个社会热点事件,一旦有消息更新,所有媒体都会跟进并进行相关报道。这里的社会热点事件就是要观察的对象。那么在前端框架中,观察目标是什么?很明显,这是状态。一般有多个状态,按对象组织。因此,我们观察状态对象每个键的变化,并结合做一系列的处理。我们需要维护这样一个数据结构:state对象的每个key关联了一系列的effectsideeffects函数,也就是发生变化时联动执行的逻辑,通过Set来组织。每个key通过这种方式关联了一系列的效果函数,多个key可以维护在一个Map中。对象存在时这个Map就存在,对象销毁时它也会被销毁。(因为对象都没有了,也就不用再维护每个键关联的效果了)WeakMap正好有这样一个特性。WeakMap的键必须是对象,值可以是任意数据。当键的对象被销毁时,值也会被销毁。所以响应式Map会用WeakMap保存,key是原始对象。这个数据结构就是响应式的核心数据结构。比如这样一个状态对象:constobj={a:1,b:2}它的响应式数据结构是这样的:constdepsMap=newMap();constaDeps=newSet();depsMap.set('a',aDeps);constbDeps=newSet();depsMap.set('b',bDeps);constreactiveMap=newWeakMap()reactiveMap.set(obj,depsMap);创建的数据结构就是图中的那个:然后添加deps依赖,比如某个函数依赖a,就应该添加到a的deps集合中:effect(()=>{console.log(obj。A);});就是这样:constdepsMap=reactiveMap.get(obj);constaDeps=depsMap.get('a');aDeps.add(thisfunction);=这样维护deps函数没有问题,但是应该用户手动添加部门?那样不仅会侵入业务代码,也容易漏掉。所以,绝对不能让用户自己手动去维护dep,而是要做自动的依赖收集。那么如何自动收集依赖呢?当读取state值时,就建立了与state的依赖关系,所以很容易想到可以通过proxystateget来实现。通过Object.defineProperty或Proxy:constdata={a:1,b:2}letactiveEffectfunctioneffect(fn){activeEffect=fnfn()}constreactiveMap=newWeakMap()constobj=newProxy(data,{get(targetObj,key){letdepsMap=reactiveMap.get(targetObj);if(!depsMap){reactiveMap.set(targetObj,(depsMap=newMap()))}letdeps=depsMap.get(key)if(!deps){depsMap.set(key,(deps=newSet()))}deps.add(activeEffect)returntargetObj[key]}})effect会执行传入的回调函数fn,当你在fnobj的时候.a被读取,会触发get,获取对象的响应式Map,并从中取出a对应的deps集合,将当前效果函数添加到其中。这样就完成了一次依赖收集。修改obj.a时,需要通知所有deps,所以需要proxyset:set(targetObj,key,newVal){targetObj[key]=newValconstdepsMap=reactiveMap.get(targetObj)if(!depsMap)returnconsteffects=depsMap.get(key)effects&&effects.forEach(fn=>fn())}基本响应完成,我们测试一下:打印两次,第一次是1,第二次是3。effect会先执行一次传入的回调函数,然后触发get收集依赖。此时打印出来的obj.a为1,然后当obj.a赋值为3时,就会触发set去执行收集到的依赖。这时候会打印出obj。a是3个依赖也都收集正确了:结果是正确的,我们已经完成了基本的响应式风格!当然,响应式风格的代码不止这么一点。我们目前的实现还不完善,还存在一些问题。比如代码中有分支开关,上一次执行依赖obj.b,下一次执行不依赖。此时是否存在无效依赖?这样一段代码:constobj={a:1,b:2}effect(()=>{console.log(obj.a?obj.b:'nothing');});obj.a=undefined;对象.b=3;第一次执行效果函数,obj.a为1,此时会走到第一个分支,依赖obj.b。将obj.a改为undefined,触发set,执行所有依赖函数。这时候去分支2,不再依赖obj.b。把obj.b改成3,按理说此时没有依赖b的函数。我们试一下:第一次打印2是正确的,就是我们到了第一个分支,打印obj.b,第二次打印nothing也是对的,这时候去第二个分支。但是第三次??什么都不打印是错误的,因为此时obj.b已经没有依赖函数了,但是还是打印出来了。打印查看deps,会发现obj.b的deps并没有被清除,所以解决方法是每次添加依赖前先清除之前的deps。如何清除与函数关联的所有deps?记录一下吧。我们对已有的效果函数进行改造:letactiveEffectfunctioneffect(fn){activeEffect=fnfn()}记录这个效果函数放在了哪个depssets。即:letactiveEffectfunctioneffect(fn){consteffectFn=()=>{activeEffect=effectFnfn()}effectFn.deps=[]effectFn()}用前面的fn包裹一层,在函数中添加一个deps数组来记录添加了哪些依赖集合。get收集依赖的时候,也会在这里记录一份:这样下次执行effect函数的时候,可以把effect函数从上次添加的依赖集合中删除:cleanup实现如下:functioncleanup(effectFn){for(leti=0;i{console.log('effect1');effect(()=>{console.log('effect2');obj.b;});obj.a;});obj.a=3;按理说effect1和effect2会打印一次,也就是第一次执行。然后obj.a改成3后,会触发effect1的打印,执行内层效果,再次触发effect2的打印。即会打印effect1、effect2、effect1、effect2。我们来测试一下:打印effect1和effect2是正确的,但是第三次??打印的是effect2,也就是说obj.a修改后,没有执行外层函数,而是执行了内层函数。为什么?看这段代码:当我们执行effect的时候,我们会把它赋值给一个全局变量activeEffect,后面用它来收集依赖。嵌套效果时,内层函数执行后会修改activeEffect,所以收集到的依赖是不正确的。怎么做?嵌套的话,加个stack记录一下效果,不就可以了吗?就是这样:在执行效果函数之前将当前的effectFn压入栈中,执行完弹出,将activeEffect修改为栈顶的effectFn。这确保收集的依赖项是正确的。这个想法还有很多应用。当上下文需要保存和恢复时,通过这种方式添加一个栈。让我们再测试一下:打印现在是正确的。至此,我们的响应式系统就比较完善了。全部代码如下:constdata={a:1,b:2}letactiveEffectconsteffectStack=[];functioneffect(fn){consteffectFn=()=>{cleanup(effectFn)activeEffect=effectFneffectStack.push(effectFn);fn()effectStack.pop();activeEffect=effectStack[effectStack.length-1];}effectFn.deps=[]effectFn()}functioncleanup(effectFn){for(leti=0;ifn())consteffectsToRun=newSet(effects);effectsToRun.forEach(effectFn=>effectFn());系列联动的处理核心是这样一个数据结构:最外层是一个WeakMap,key是一个对象,value是一个响应式的Map。这样,当对象被销毁时,Map也会被销毁。每个key的依赖集合保存在Map中,Map是通过Set来组织的。我们使用Proxy来完成自动依赖收集,即给key对应的dep集合添加effect。设置后,所有效果函数都会被触发执行。这是基本的反应系统。但它还不完美。每次执行effect前,需要将其从上次加入的deps集合中删除,然后重新收集依赖。这避免了由于分支切换导致的无效依赖。并且在执行deps中的effect之前,需要创建一个新的Set来执行,避免add和delete的循环。另外,为了支持嵌套效果,需要在效果执行前入栈,执行完再出栈。解决了这些问题之后就是一个完整的Vue响应式系统。当然现在虽然功能齐全,但是computed、watch等功能还没有实现,以后再实现。最后,我们再来看看这个数据结构。理解之后,我们就可以理解Vue响应式的核心: