前言
读了前三章,了解了虚拟dom的局部更新的设计思路,也知道了vue这个框架的开发体验、框架体积控制、剔除无用代码、不同资源输出格式、特性开关、错误处理、TS支持等等,它们互相配合,从而大大的降低了开发者的心智压力,这些也是衡量一个框架的质量指标,vue做的很好。
vue3中通过声明式的模板UI,编译器将模板或组件转成虚拟dom,再由渲染器转成真实dom,编译器中做了优化,从而使得渲染器能够更快找到变更的元素,性能上得到了很大的优化。
副作用函数和响应式数据
副作用函数就是 这个函数中做了影响其它的事情,比如你改变了全局的变量,而其它地方也用到了这个全局变量。直接或者间接的让其它地方受到影响,这就是副作用。
响应式数据就是 当某个数据发生变化,就会立刻执行一个行为,比如你给a赋值,a发生了变化,然后就立马触发一个行为,在这个行为里将b的数据也做了同步的更新。响应式,就是get、set、delete 之后立马执行相应的行为,从而使得其它地方也随之发生变化。
响应式数据的基本实现
拦截一个对象的get和set操作,给get和set绑定一个行为(副作用函数)。
在ES2015之前使用Object.defineProperty,在ES2015之后可以使用浏览器内置的Proxy来实现。
vue2中用的是第一种,vue3中用的是第二种。第一种只能拦截某个变量,当对象层级很多时,需要递归的调用Object.definePropery。第二种可以直接拦截整个对象。
vue中是在get的时候添加副作用函数,set的时候循环调用之前添加过的副作用函数。这样set时就会通知所有订阅过当前目标的其它群体。
设计一个完善的响应系统
Proxy默认监听一整个对象,也就是说这个对象任何一个key被get或者set都会收集副作用函数或触发副作用函数。这样就会出问题。
所以vue中采用了将对象的引用作为第一层key,第一层key对应的是一个Map,该对象的属性key作为第二层key,第二层key对应的是Set,Set存的是副作用函数。
大概是 WeakMap->Map->Set这样的一层嵌套结构,好处是WeakMap以对象为key时是弱引用的,只要这个对象被回收掉了,那么WeakMap中这部分相关的数据就会被销毁,从而不会影响垃圾回收机制的执行,从而使用它更加友好。
通过这样的树结构,精确的解决了监听一整个对象时任何一个key被get或者set时都会收集副作用函数或触发副作用函数的问题,从而能够和每一个key建立对应的关系,不会出现错误的行为。
vue中还将Proxy的get中收集副作用函数与set中触发副作用的函数的操作做了封装,封装成了track和trigger函数,这样一来代码可读性就更好了。非常棒。
分支切换与cleanup
Proxy中监听一整个对象,也就是对象中任何一个key被get,就会收集副作用函数。如果副作用函数中使用if else这样的条件分支,if小括号中把对象的key进行了get,然后if大括号中也把对象的另一个key进行了get,那么这两个key都会收集这个副作用函数的依赖。这时就会导致该对象的两个key任何一次set操作,都会触发这个副作用函数。
似乎没啥不对,但这时f小括号中的值你设置为了false,那么按道理来说应该清除之前给if大括号中给该对象另一个key进行get时的收集的依赖清除掉。但实际上默认并不会这么做,因为你已经收集,那么还是会在你set该对象另一个key时触发这个副作用函数,这样就不对。
这就是在副作用函数中分支切换时要注意的地方,也就是要做cleanup操作。
这个时候可以注册副作用函数的时候声明一个与该副作用函数相关的所有依赖集合的数组,在被监听的对象每一个key被get的时候,将其所有的副作用函数装入这个数组中。
那么在注册某个副作用函数的时候,先遍历一遍该副作用函数的收集到的依赖集合,从那些依赖集合中剔除掉当前注册的这个副作用函数,最后就能够在get的时候重新收集了,不会出现重复的收集和多余的收集了。类似于先清空之前收集的相关依赖,再收集最新的那一个。
综上所述似乎OK了,但由于cleanup是在被注册的副作用函数被执行的时候触发,也就是对象的key被set的时候会触发。虽然cleanup是在移除多余收集的副作用函数,副作用函数时放到Set中的,但是Set有一个特性,当它在遍历的时候,如果被移除了,那么它会再次被添加进这个Set中。
那么这样一来就造成了循环,我刚遍历完这个,这个也被我删除了,然后Set中又把它加进来了,总之烦死了。这时候可以在遍历整个Set之前,拷贝一份新的,去遍历那个新的,在新的副作用函数被调用时,就会正常cleanup旧的Set中的副作用函数,新的不会被删除,就不会被再次添加进去。这样就绕过了Set的这个特性带来的问题了,这也是它的forEach的规范带来的问题。
嵌套的effect和effect栈
effect用于收集副作用函数,当以嵌套的方式收集副作用函数时,会造成当前正在执行的副作用函数集合错乱,因为同一时间只能有一个激活的副作用函数集,这个时候可以模拟函数调用栈,能够恢复之前的那个激活的副作用函数集,从而能够回到正确的上下文环境中去,这样就能保证被收集的副作用函数能够正常的执行。
注:这个地方的逻辑大概思路是这样,但具体的细节还得之后慢慢思考,目前不在这上面多花时间。
避免无限递归循环
当你在effect收集的副作用函数中去做get + set的操作时,这时候会导致当前副作用函数还没执行完,然后你又再次添加,虽然集合中不会出现重复的副作用函数,然后你的set会触发副作用函数的调用。如此一来,自然就会造成递归的问题发生。我还没执行完当前副作用函数,然后你又调用了我一次。所以需要在触发副作用函数之前做一下判断,判断即将要触发的副作用函数 是否 是 正在触发的副作用函数。
调度执行
让用户去控制副作用函数在什么时候执行、是否执行。这是在副作用函数集遍历执行的时候增加了if else 分支,如果你设置了这个选项的scheduler回调,那么就把副作用函数给你传过去,让你去控制是否执行。
一般来说连续的多次set操作并不一定需要多次的执行副作用函数,多次的set可以过渡为1次set操作,只需要最终的赋值操作得到响应即可。
先用一个无重复的队列来收集副作用函数,这样收集的次数再多,也不会有重复的副作用函数。然后就是通过Promise的微任务机制外加一个状态变量来控制。
由于多次的执行副作用函数,都会通过你传入的选项来被收集到队列中。微任务的执行比同步代码的任务后执行,同步代码会先收集完所有副作用函数,那个状态变量从第一次开始执行为微任务时就已经设置为true,那么只能够等这个微任务执行完毕后才能再进来。
如此一来,重复的副作用函数会被队列收集并去重,直到同步代码执行完毕,微任务再去执行。这样就很好将多次set操作过度为1次了。
注:这里我想到了很久前React中的setState,你多次setState也只会保留最后一次setState的副作用函数的执行,不会触发多次setState的副作用函数。
计算属性 computed 与 lazy
computed是通过effect实现的,它是通过延迟执行副作用函数,从而可以直接拿到真正的副作用函数,这个延迟操作是通过option的lazy属性来做为判断依据的。同时使用了es6中对象的getter来实现你 .value的时候调用那个真正的副作用函数,从而拿到最终结果。这里的computed计算属性还没做缓存,每次调用每次实时调用真正的副作用函数。
computed计算属性是需要进行缓存的,同时监听的数据发生变化时也需要能够更新。这里就用到了一个dirty来作为判断依据,在你没有通过 .value的方式拿过值时,dirty就为false,有过一次就会true,这样一来,就能实现缓存了。可是总不能一直缓存着吧,当你监听的数据发生变化时,就需要把dirty设置为false,这个通过之前调度执行那里选项的scheduler来就能实现了。
computed的封装还缺最后一步,当在effect中对computed对象的value进行获取时,因该给computed对象监听的那些属性再进行一次副作用函数的收集,这样一来当那些属性发生变化,副作用函数可以重新执行,从而使得effect中嵌套computed对象的value在进行获取时,还能拿到最新变更的变更数据。
注:这里最后一步也是嵌套effect导致的问题的解决,因为之前并没有收集computed的value依赖,只收集obj对象的所有key的依赖。
所以就会导致在effect中获取computed value时,并不会更新最新的数据,比如obj的某个key数据发生变化,会触发effect,而value的get并不会触发effect,那么数据就不会更新了。
通过在obj每次get时给value的收集依赖,在每次obj的key的数据发生变化时,触发value的依赖函数的执行。从而实现在effect中使用computed对象的value时也能拿到最新的数据。
watch 实现原理
watch 也是通过effect来收集一个递归的监听对象的函数作为副作用函数,然后调用scheduler回调来实现的,只要有数据发生变化,就会触发这个副作用函数。同时它也支持传入一个getter函数。
拿到新旧的值是通过effect的一个lazy的option来实现的,会在scheduler中去调用watch的第二个参数回调函数。
watch 的 immediate 和 flush
immediate在vue2也有,表示立即执行,也就是你在watch的时候,就会立马调用第二个参数的回调函数,也就是会根据它的immediate的值来选择是否立即执行watch第二个参数回调函数。
flush 是vue3中新增的,表示是否等页面中组件的dom更新结束后再执行watch第二个参数的回调函数,它是通过promise来实现的。
flush支持三个值,分别是 pre sync post,默认是sync,pre表示再组件的dom更新前执行回调函数,sync表示同步执行,post会在组件dom更新完毕后再执行。用了pre的话,会有immediate这样的效果,立即执行。
watch 中 过期的副作用 加锁
当你使用watch监听一个对象时,如果这个对象的key的属性值发生变化,回调还没有执行完,然后这个对象的key的属性值又一次发生了变化,但是第二次回调很快执行完了,那么就会导致两次回调函数执行完成的时机不对,从而得到错误的结果。
这个问题就是竞态问题,也就是多线程执行,不同的线程执行同一个任务,如果结果相关联,那么可能会起冲突,一般会通过一个锁的机制来解决这个冲突。
vue中是通古watch函数第二个参数回调函数的参数解决的,这个回调函数有三个参数,前两个就是oldVal、newVal,第三个是onInvalid。
这个onInvalid是用来收集加锁的函数,它收集的加锁的函数会在下一个watch的回调函数中被执行。也就是说如果这次watch的回调函数没执行完,而下一次watch的回调已经在执行了,那么这个加锁的函数就会执行,从而上锁。
那么第一个watch回调函数就可以判断锁来决定是否继续执行下去,一般会终止往下执行,从而解决了这个竞态的问题。
总结
这篇的料很多,非常的好,讲了副作用和响应式,从基本的响应式实现到整个响应式系统的设计,以及响应式的边缘性问答的处理,比如 如何清空重复的副作用函数的收集、嵌套的effect上下文确定、还有解决无限递归的副作用函数的执行、用户自己决定调度的时机。
给对象收集副作用函数,用的是WeakMap -> Map -> Set 这样的结构。使用weakMap来优化内存,因为weakMap设计的时候对key是弱引用,不会影响垃圾回收机制。
比如weakMap是拿不到key的,所以当这个key没有任何地方引用到时,weakMap中这个key以及对应的值会被垃圾回收掉,所以优化了内存,而map并不会这样。
还有computed实现,通过dirty配合scheduler来实现缓存。watch通过lazy、scheduler,还实现了第二个回调的oldVal、newVal,还有immediate、flush,甚至还有watch如果通过加锁函数来解决多线程竞态的问题等等。