27 狂肝半个月!1.3 万字深度剖析 Vue3 响应式(附脑图) - AnnGreen1/article GitHub Wiki
狂肝半个月!1.3 万字深度剖析 Vue3 响应式(附脑图)
mp.weixin.qq.compino 前端Q
点击上方前端Q,关注公众号-
回复加群,加入前端Q技术交流群
写在前面
本文的目标是实现一个基本的vue3
的响应式,包含最基础的情况的处理,本文是系列文章,如果你对vue3
还不了解,那么请移步:
超详细整理vue3基础知识💥[1]
本文你将学到
-
一个基础的响应式实现 ✅
-
Proxy ✅
-
Reflect ✅
-
嵌套effect的实现 ✅
-
computed ✅
-
watch ✅
-
浅响应与深响应 ✅
-
浅只读与深只读 ✅
-
处理数组长度 ✅
-
ref ✅
-
toRefs ✅
响应式.png
一. 实现一个完善的响应式
所谓的响应式数据的概念,其实最主要的目的就是为数据绑定执行函数,当数据发生变动的时候,再次触发函数的执行。
例如我们有一个对象data
,我们想让它变成一个响应式数据,当data
的数据发生变化时,自动执行effect
函数,使nextVal
变量的值也进行变化:
//定义一个对象letdata={name:'pino',age:18}letnextVal//待绑定函数functioneffect(){nextVal=data.age+1}data.age++复制代码
上面的例子中我们将data
中的age
的值进行变化,但是effect
函数并没有执行,因为现在effect
函数与data
这个对象不能说是没啥联系,简直就是半毛钱的关系都没有。
那么怎么才能使这两个毫不相关的函数与对象之间产生关联呢?
因为一个对象最好可以绑定多个函数,所以有没有可能我们为data
这个对象定义一个空间,每当data
的值进行变化的时候就会执行这个空间里的函数?
答案是有的。
1. Object.defineProperty()
js在原生提供了一个用于操作对象的比较底层的api:Object.defineProperty()
,它赋予了我们对一个对象的读取和拦截的操作。
Object.defineProperty()
方法直接在一个对象上定义一个新属性,或者修改一个已经存在的属性, 并返回这个对象。
Object.defineProperty(obj,prop,descriptor)复制代码
参数
obj
需要定义属性的对象。prop
需被定义或修改的属性名。descriptor
(描述符) 需被定义或修改的属性的描述符。
其中descriptor
接受一个对象,对象中可以定义以下的属性描述符,使用属性描述符对一个对象进行拦截和控制:
-
value
——当试图获取属性时所返回的值。 -
writable
——该属性是否可写。 -
enumerable
——该属性在for in循环中是否会被枚举。 -
configurable
——该属性是否可被删除。 -
set()
——该属性的更新操作所调用的函数。 -
get()
——获取属性值时所调用的函数。
另外,数据描述符(其中属性为:enumerable
, configurable
, value
, writable
)与存取描述符(其中属性为 enumerable
, configurable
, set()
, get()
)之间是有互斥关系的。在定义了 set()
和 get()
之后,描述符会认为存取操作已被 定义了,其中再定义 value
和 writable
会引起错误。
letobj={name:"小花"}Object.defineProperty(obj,'name',{//属性读取时进行拦截get(){return'小明';},//属性设置时拦截set(newValue){obj.name=newValue;},enumerable:true,configurable:true});复制代码
上面的例子中就已经完成对一个对象的最基本的拦截,这也是vue2.x
中对对象监听的方式,但是由于Object.defineProperty()
中存在一些问题,例如:
-
一次只能对一个属性进行监听,需要遍历来对所有属性监听
-
对于对象的新增属性,需要手动监听
-
对于数组通过
push
、unshift
方法增加的元素,也无法监听
那么vue3
版本中是如何对一个对象进行拦截的呢?答案是es6
中的Proxy
。
由于本文主要是vue3
版本的响应式的实现,如果想要深入了解Object.defineProperty()
,请移步:
MDN Object.defineProperty[2]
2. Proxy
proxy
是es6
版本出现的一种对对象的操作方式,Proxy
可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy
这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
通过proxy
我们可以实现对一个对象的读取,设置等等操作进行拦截,而且直接对对象进行整体拦截,内部提供了多达13种拦截方式。
-
get(target, propKey, receiver) :拦截对象属性的读取,比如
proxy.foo
和proxy['foo']
。 -
set(target, propKey, value, receiver) :拦截对象属性的设置,比如
proxy.foo = v
或proxy['foo'] = v
,返回一个布尔值。 -
has(target, propKey) :拦截
propKey in proxy
的操作,返回一个布尔值。 -
deleteProperty(target, propKey) :拦截
delete proxy[propKey]
的操作,返回一个布尔值。 -
ownKeys(target) :拦截
Object.getOwnPropertyNames(proxy)
、Object.getOwnPropertySymbols(proxy)
、Object.keys(proxy)
、for...in
循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()
的返回结果仅包括目标对象自身的可遍历属性。 -
getOwnPropertyDescriptor(target, propKey) :拦截
Object.getOwnPropertyDescriptor(proxy, propKey)
,返回属性的描述对象。 -
defineProperty(target, propKey, propDesc) :拦截
Object.defineProperty(proxy, propKey, propDesc)
、Object.defineProperties(proxy, propDescs)
,返回一个布尔值。 -
preventExtensions(target) :拦截
Object.preventExtensions(proxy)
,返回一个布尔值。 -
getPrototypeOf(target) :拦截
Object.getPrototypeOf(proxy)
,返回一个对象。 -
isExtensible(target) :拦截
Object.isExtensible(proxy)
,返回一个布尔值。 -
setPrototypeOf(target, proto) :拦截
Object.setPrototypeOf(proxy, proto)
,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。 -
apply(target, object, args) :拦截 Proxy (代理)实例作为函数调用的操作,比如
proxy(...args)
、proxy.call(object, ...args)
、proxy.apply(...)
。 -
construct(target, args) :拦截 Proxy (代理)实例作为构造函数调用的操作,比如
new proxy(...args)
。
如果想要详细了解proxy
,请移步:
es6.ruanyifeng.com/#docs/proxy…[3]
letobj={name:"小花"}//只使用get和set进行演示letobj2=newProxy(obj,{//读取拦截get:function(target,propKey){returntarget[propKey]},//设置拦截set:function(target,propKey,value){//此处的value为用户设置的新值target[propKey]=value}});复制代码
3. 一个最简单的响应式
有了proxy
,我们就可以根据之前的思路实现一个基本的响应式功能了,我们的思路是这样的:在对象被读取时把函数收集到一个“仓库”,在对象的值被设置时触发仓库中的函数。
由此我们可以写出一个最基本的响应式功能:
//定义一个“仓库”,用于存储触发函数letstore=newSet()//使用proxy进行代理letdata_proxy=newProxy(data,{//拦截读取操作get(target,key){//收集依赖函数store.add(effect)returntarget[key]},//拦截设置操作set(target,key,newVal){target[key]=newVal//取出所有的依赖函数,执行store.forEach(fn=>fn())}})复制代码
我们创建了一个用于保存依赖函数的“仓库”,它是Set
类型,然后使用proxy
对对象data
进行代理,设置了set
和get
拦截函数,用于拦截读取和设置操作,当读取属性时,将依赖函数effect
存储到“仓库”中,当设置属性值时,将依赖函数从“仓库”中取出并重新执行。
还有一个小问题,怎么触发对象的读取操作呢?我们可以直接调用一次effect
函数,如果在effect
函数中存在需要收集的属性,那么执行一次effect
函数也是比较符合常理的。
// 定义一个对象let data = { name: 'pino', age: 18}let nextVal// 待绑定函数function effect() { // 依赖函数在这里被收集 // 当调用data.age时,effect函数被收集到“仓库”中 nextVal = data.age + 1 console.log(nextVal)}// 执行依赖函数effect() // 19setTimeout(()=>{ // 使用proxy进行代理后,使用代理后的对象名 // 触发设置操作,此时会取出effect函数进行执行 data_proxy.age++ // 2秒后输出 20}, 2000)复制代码
一开始会执行一次effect
,然后函数两秒钟后会执行代理对象设置操作,再次执行effect
函数,输出20。
Jul-24-2022 17-31-39.gif
此时整个响应式流程的功能是这样的:
阶段一,在属性被读取时,为对象属性收集依赖函数:
image.png
阶段二,当属性发生改变时,再次触发依赖函数
image.png
这样就实现了一个最基本的响应式的功能。
4. 完善
问题一
其实上面实现的功能还有很大的缺陷,首先最明显的问题是,我们把effect
函数给固定了,如果用户使用的依赖函数不叫effect
怎么办,显然我们的功能就不能正常运行了。
所以先来进行第一步的优化:抽离出一个公共方法,依赖函数由用户来传递参数。
我们使用effect
函数来接受用户传递的依赖函数:
//effect接受一个函数,把这个匿名函数当作依赖函数functioneffect(fn){//执行依赖函数fn()}//使用effect(()=>{nextVal=data.age+1console.log(nextVal)})复制代码
但是effect
函数内部只是执行了,在get
函数中怎么能知道用户传递的依赖函数是什么呢,这两个操作并不在一个函数内啊?其实可以使用一个全局变量activeEffect
来保存当前正在处理的依赖函数。
修改后的effect
函数是这样的:
letactiveEffect//新增functioneffect(fn){//保存到全局变量activeEffectactiveEffect=fn//新增//执行依赖函数fn()}//而在get内部只需要�收集activeEffect即可get(target,key){store.add(activeEffect)returntarget[key]},复制代码
调用effect
函数传递一个匿名函数作为依赖函数,当执行时,首先会把匿名函数赋值给全局变量activeEffect
,然后触发属性的读取操作,进而触发get
拦截,将全局变量activeEffect
进行收集。
问题二
从上面我们定义的对象可以看到,我们的对象data
中有两个属性,上面的例子中我们只给age
建立了响应式连接,那么如果我现在也想给name
建立响应式连接怎么办呢?那好说,那我们直接向“仓库”中继续添加依赖函数不就行了吗。
其实这会带来很严重的问题,由于 “仓库”并没有与被操作的目标属性之间建立联系,而上面我们的实现只是将整个“仓库”遍历了一遍,所以无论哪个属性被触发,都会将“仓库”中所有的依赖函数都取出来执行一遍,因为整个执行程序中可能有很多对象及属性都设置了响应式联系,这将会带来很大的性能浪费。所谓牵一发而动全身,这种结果显然不是我们想要的。
letdata={name:'pino',age:18}复制代码
image.png
所以我们要重新设计一下“仓库”的数据结构,目的就是为了可以在属性这个粒度下和“仓库”建立明确的联系。
就拿我们上面进行操作的对象来说,存在着两层的结构,有两个角色,对象data
以及属性name``age
letdata={name:'pino',age:18}复制代码
他们的关系是这样的:
data->name->effectFn//如果两个属性读取了同一个依赖函数data->name->effectFn->age->effectFn//如果两个属性读取了不同的依赖函数data->name->effectFn->age->effectFn1//如果是两个不同的对象data->name->effectFn->age->effectFn1data2->addr->effectFn复制代码
接下来我们实现一下代码,为了方便调用,将设置响应式数据的操作封装为一个函数reactive
:
letnewObj=newProxy(obj,{//读取拦截get:function(target,propKey){},//设置拦截set:function(target,propKey,value){}});//封装为functionreactive(obj){returnnewProxy(obj,{//读取拦截get:function(target,propKey){},//设置拦截set:function(target,propKey,value){}});}复制代码
functionreactive(obj){returnnewProxy(obj,{get(target,key){//收集依赖track(target,key)returntarget[key]},set(target,key,newVal){target[key]=newVal//触发依赖trigger(target,key)}})}functiontrack(target,key){//如果没有依赖函数,则不需要进行收集。直接returnif(!activeEffect)return//获取target,也就是对象名,对应上面例子中的dataletdepsMap=store.get(target)if(!depsMap){store.set(target,(depsMap=newMap()))}//获取对象中的key值,对应上面例子中的name或ageletdeps=depsMap.get(key)if(!deps){depsMap.set(key,(deps=newSet()))}//收集依赖函数deps.add(activeEffect)}functiontrigger(target,key){//取出对象对应的MapletdepsMap=store.get(target)if(!depsMap)return//取出key所对应的Setletdeps=depsMap.get(key)//执行依赖函数deps&&deps.forEach(fn=>fn());}复制代码
我们将读取操作封装为了函数track
,触发依赖函数的动作封装为了trigger
方便调用,现在的整个“仓库”结构是这样的:
image.png
WeakMap
可能有人会问了,为什么设置“仓库”要使用WeakMap
呢,我使用一个普通对象来创建不行吗?-
WeakMap
结构与 Map
结构类似,也是用于生成键值对的集合。
WeakMap
与 Map
的区别有两点。
首先, WeakMap
只接受对象作为键名( null
除外),不接受其他类型的值作为键名。
constmap=newWeakMap();map.set(1,2)//TypeError:1isnotanobject!map.set(Symbol(),2)//TypeError:Invalidvalueusedasweakmapkeymap.set(null,2)//TypeError:Invalidvalueusedasweakmapkey复制代码
上面代码中,如果将数值 1
和 Symbol
值作为 WeakMap 的键名,都会报错。
其次, WeakMap
的键名所指向的对象,不计入垃圾回收机制。
WeakMap
的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。请看下面的例子。
conste1=document.getElementById('foo');conste2=document.getElementById('bar');constarr=[[e1,'foo元素'],[e2,'bar元素'],];复制代码
上面代码中, e1
和 e2
是两个对象,我们通过 arr
数组对这两个对象添加一些文字说明。这就形成了 arr
对 e1
和 e2
的引用。
一旦不再需要这两个对象,我们就必须手动删除这个引用,否则垃圾回收机制就不会释放 e1
和 e2
占用的内存。
//不需要e1和e2的时候//必须手动删除引用arr[0]=null;arr[1]=null;复制代码
上面这样的写法显然很不方便。一旦忘了写,就会造成内存泄露。
它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap
里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。
如果我们上文中target
对象没有任何引用了,那么说明用户已经不需要用到它了,这时垃圾回收器会自动执行回收,而如果使用Map
来进行收集,那么即使其他地方的代码已经对target
没有任何引用,这个target
也不会被回收。
Reflect
在vue3中的实现方式和我们的基本实现还有一点不同就是在vue3中是使用Reflect
来操作数据的,例如:
functionreactive(obj){returnnewProxy(obj,{get(target,key,receiver){track(target,key)//使用Reflect.get操作读取数据returnReflect.get(target,key,receiver)},set(target,key,value,receiver){trigger(target,key)//使用Reflect.set来操作触发数据Reflect.set(target,key,value,receiver)}})}复制代码
那么为什么要使用Reflect
来操作数据呢,像之前一样直接操作原对象不行吗,我们先来看一下一种特殊的情况:
constobj={foo:1,getbar(){returnthis.foo}}复制代码
在effect
依赖函数中通过代理对象p访问bar属性:
effect(()=>{console.log(p.bar)//1})复制代码
可以分析一下这个过程发生了什么,当effect
函数被调用时,会读取p.bar
属性,他发现p.bar
属性是一个访问器属性,因此会执行getter
函数,由于在getter
函数中通过this.foo
读取了foo
属性的值,因此我们会认为副作用函数与属性foo
之间也会建立联系,当修改p.foo
的值的时候因该也能够触发响应,使依赖函数重新执行才对,然而当修改p.foo
的时候,并没有触发依赖函数:
p.foo++复制代码
实际上问题就出在bar
属性中的访问器函数getter
上:
getbar(){//这个this究竟指向谁?returnthis.foo}复制代码
当通过代理对象p访问p.bar
,这回触发代理对象的get
拦截函数执行:
constp=newProxt(obj,{get(target,key){track(target,key)returntarget[key]}})复制代码
可以看到在get
的拦截函数中,通过target[key]
返回属性值,其中target
是原始对象obj
,而key
就是字符串'bar'
,所以target[key]
就相当于obj.bar
。因此当我们使用p.bar
访问bar
属性时,他的getter
函数内的this
其实指向原始对象obj
,这说明我们最终访问的是obj.foo
。所以在依赖函数内部通过原始对象访问他的某个属性是不会建立响应联系的:
effect(()=>{//obj是原始数据,不是代理对象,不会建立响应联系obj.foo})复制代码
那么怎么解决这个问题呢,这时候就需要用到 Reflect
出场了。
先来看一下Reflect
是啥:
Reflect
函数的功能就是提供了访问一个对象属性的默认行为,例如下面两个操作是等价的:
constobj={foo:1}//直接读取console.log(obj.foo)//1//使用Reflect.get读取console.log(Reflect.get(obj,'foo'))//1复制代码
实际上Reflect.get
函数还能接受第三个函数,即制定接受者receiver
,可以把它理解为函数调用过程中的this
:
constobj={foo:1}console.log(Reflect.get(obj,'foo',{foo:2}))//输出的是2而不是1复制代码
在这段代码中,指定了第三个参数receiver为一个对象{ foo: 2 }
,这是读取到的值时receiver
对象的foo
属性。
而我们上文中的问题的解决方法就是在操作对象数据的时候通过Reflect
的方法来传递第三个参数receiver
,它代表谁在读取属性:
constp=newProxt(obj,{//读取属性接收receiverget(target,key,receiver){track(target,key)//使用Reflect.get返回读取到的属性值returnReflect.get(target,key,receiver)}})复制代码
当使用代理对象p
访问bar
属性时,那么receiver
就是p,可以把它理解为函数调用中的this
。
所以我们改造一下reactive
函数的实现:
functionreactive(obj){returnnewProxy(obj,{get(target,key,receiver){track(target,key)returnReflect.get(target,key,receiver)},set(target,key,value,receiver){trigger(target,key)Reflect.set(target,key,value,receiver)}})}复制代码
扩展
Proxy -> get()
get
方法用于拦截某个属性的读取操作,可以接受三个参数,依次为目标对象、属性名和proxy
(代理)实例本身(严格地说,是操作行为所针对的对象),其中最后一个参数可选。
Reflect.get(target, name, receiver)
Reflect.get
方法查找并返回 target
对象的 name
属性,如果没有该属性,则返回 undefined
。
varmyObject={foo:1,bar:2,getbaz(){returnthis.foo+this.bar;},}Reflect.get(myObject,'foo')//1Reflect.get(myObject,'bar')//2Reflect.get(myObject,'baz')//3复制代码
如果 name
属性部署了读取函数(getter),则读取函数的 this
绑定 receiver
。
varmyObject={foo:1,bar:2,getbaz(){returnthis.foo+this.bar;},};varmyReceiverObject={foo:4,bar:4,};Reflect.get(myObject,'baz',myReceiverObject)//8复制代码
如果第一个参数不是对象, Reflect.get
方法会报错。
Reflect.get(1,'foo')//报错Reflect.get(false,'foo')//报错复制代码
Reflect.set(target, name, value, receiver)
Reflect.set
方法设置 target
对象的 name
属性等于 value
。
varmyObject={foo:1,setbar(value){returnthis.foo=value;},}myObject.foo//1Reflect.set(myObject,'foo',2);myObject.foo//2Reflect.set(myObject,'bar',3)myObject.foo//3复制代码
如果 name
属性设置了赋值函数,则赋值函数的 this
绑定 receiver
。
varmyObject={foo:4,setbar(value){returnthis.foo=value;},};varmyReceiverObject={foo:0,};Reflect.set(myObject,'bar',1,myReceiverObject);myObject.foo//4myReceiverObject.foo//1复制代码
注意,如果 Proxy
对象和 Reflect
对象联合使用,前者拦截赋值操作,后者完成赋值的默认行为,而且传入了 receiver
,那么 Reflect.set
会触发 Proxy.defineProperty
拦截。
letp={a:'a'};lethandler={set(target,key,value,receiver){console.log('set');Reflect.set(target,key,value,receiver)},defineProperty(target,key,attribute){console.log('defineProperty');Reflect.defineProperty(target,key,attribute);}};letobj=newProxy(p,handler);obj.a='A';//set//defineProperty复制代码
上面代码中, Proxy.set
拦截里面使用了 Reflect.set
,而且传入了 receiver
,导致触发 Proxy.defineProperty
拦截。这是因为 Proxy.set
的 receiver
参数总是指向当前的 Proxy
实例(即上例的 obj
),而 Reflect.set
一旦传入 receiver
,就会将属性赋值到 receiver
上面(即 obj
),导致触发 defineProperty
拦截。如果 Reflect.set
没有传入 receiver
,那么就不会触发 defineProperty
拦截。
letp={a:'a'};lethandler={set(target,key,value,receiver){console.log('set');Reflect.set(target,key,value)},defineProperty(target,key,attribute){console.log('defineProperty');Reflect.defineProperty(target,key,attribute);}};letobj=newProxy(p,handler);obj.a='A';//set复制代码
如果第一个参数不是对象, Reflect.set
会报错。
Reflect.set(1,'foo',{})//报错Reflect.set(false,'foo',{})//报错复制代码
到这里,一个非常基本的响应式的功能就完成了,整体代码如下:
//定义仓库letstore=newWeakMap()//定义当前处理的依赖函数letactiveEffectfunctioneffect(fn){//将操作包装为一个函数consteffectFn=()=>{activeEffect=effectFnfn()}effectFn()}functionreactive(obj){returnnewProxy(obj,{get(target,key,receiver){//收集依赖track(target,key)returnReflect.get(target,key,receiver)},set(target,key,newVal,receiver){//触发依赖trigger(target,key)Reflect.set(target,key,newVal,receiver)}})}functiontrack(target,key){//如果没有依赖函数,则不需要进行收集。直接returnif(!activeEffect)return//获取target,也就是对象名letdepsMap=store.get(target)if(!depsMap){store.set(target,(depsMap=newMap()))}//获取对象中的key值letdeps=depsMap.get(key)if(!deps){depsMap.set(key,(deps=newSet()))}//收集依赖函数deps.add(activeEffect)}functiontrigger(target,key){//取出对象对应的MapletdepsMap=store.get(target)if(!depsMap)return//取出key所对应的Setconsteffects=depsMap.get(key)//执行依赖函数//为避免污染,创建一个新的Set来进行执行依赖函数leteffectsToRun=newSet()effects&&effects.forEach(effectFn=>{effectsToRun.add(effectFn)})effectsToRun.forEach(effect=>effect())}复制代码
二. 嵌套effect
在日常的工作中,effect
函数并不是单独存在的,比如在vue的渲染函数中,各个组件之间互相嵌套,那么他们在组件中所使用的effect
是必然会发生嵌套的:
effect(functioneffectFn1(){effect(functioneffectFn1(){//...})})复制代码
当组件中发生嵌套时,此时的渲染函数:
effect(()=>{Father.render()//嵌套子组件effect(()=>{Son.render()})})复制代码
但是此时我们实现的effect
并没有这个能力,执行下面这段代码,并不会出现意料之中的行为:
constdata={foo:'pino',bar:'在干啥'}//创建代理对象constobj=reactive(data)letp1,p2;//设置obj.foo的依赖函数effect(functioneffect1(){console.log('effect1执行');//嵌套,obj.bar的依赖函数effect(functioneffect2(){p2=obj.barconsole.log('effect2执行')})p1=obj.foo})复制代码
在这段代码中,定义了代理对象obj
,里面有两个属性foo
和bar
,然后定义了收集foo
的依赖函数,在依赖函数的内部又定义了bar
的依赖函数。在理想状态下,我们希望依赖函数与属性之间的关系如下:
obj->foo->effect1->bar->effect2复制代码
当修改obj.foo
的值的时候,会触发effect1
函数执行,由于effect2
函数在effect
函数内部,所以effect2
函数也会执行,而当修改obj.bar
时,只会触发effect2
函数。接下来修改一下obj.foo
:
constdata={foo:'pino',bar:'在干啥'}//创建代理对象constobj=reactive(data)letp1,p2;//设置obj.foo的依赖函数effect(functioneffect1(){console.log('effect1执行');//嵌套,obj.bar的依赖函数effect(functioneffect2(){p2=obj.barconsole.log('effect2执行')})p1=obj.foo})//修改obj.foo的值obj.foo='前来买瓜'复制代码
看一下执行结果:
image_1659170045716_0.png
可以看到effect2
函数竟然执行了两次?按照之前的分析,当obj.foo
被修改后,应当触发effect1
这个依赖函数,但是为什么会effect2
会被再次执行呢?来看一下我们effect
函数的实现:
functioneffect(fn){//将依赖函数进行包装consteffectFn=()=>{activeEffect=effectFnfn()}effectFn()}复制代码
其实在这里就已经很容易看出问题了,在接受用户传递过来的值时,我们直接将activeEffect
这个全局变量进行了覆盖!所以在内部执行完后,activeEffect
这个变量就已经是effect2
函数了,而且永远不会再次变为effect1
,此时再进行收集依赖函数时,永远收集的都是effect2
函数。
那么如何解决这种问题呢,这种情况可以借鉴栈结构来进行处理,栈结构是一种后进先出的结构,在依赖函数执行时,将当前的依赖函数压入栈中,等待依赖函数执行完毕后将其从栈中弹出,始终activeEffect
指向栈顶的依赖函数。
//增加effect调用栈consteffectStack=[]//新增functioneffect(fn){leteffectFn=function(){activeEffect=effectFn//入栈effectStack.push(effectFn)//新增//执行函数的时候进行get收集fn()//收集完毕后弹出effectStack.pop()//新增//始终指向栈顶activeEffect=effectStack[effectStack.length-1]//新增}effectFn()}复制代码
未命名.drawio_1659171750374_0.png
此时两个属性所对应的依赖函数便不会发生错乱了。
三. 避免无限循环
如果现在将effect
函数中传递的依赖函数改一下:
//定义一个对象letdata={name:'pino',age:18}//将data更改为响应式对象letobj=reactive(data)effect(()=>{obj.age++})复制代码
在这段代码中,我们将代理对象obj
的age
属性执行自增操作,但是执行这段代码,却发现竟然栈溢出了?这是怎么回事呢?
image_1659163246902_0.png
其实在effect
中处理依赖函数时,obj.age++
的操作其实可以看做是这样的:
effect(()=>{//等式右边的操作是先执行了一次读取操作obj.age=obj.age+1})复制代码
这段代码的执行流程是这样的:首先读取obj.foo
的值,这会触发track
函数进行收集操作,也就是将当前的依赖函数收集到“仓库”中,接着将其加1后再赋值给obj.foo
,此时会触发trigger
操作,即把“仓库”中的依赖函数取出并执行。但是此时该依赖函数正在执行中,还没有执行完就要再次开始下一次的执行。就会导致无限的递归调用自己。
解决这个问题,其实只需要在触发函数执行时,判断当前取出的依赖函数是否等于activeEffect
,就可以避免重复执行同一个依赖函数。
functiontrigger(target,key){//取出对象对应的MapletdepsMap=store.get(target)if(!depsMap)return//取出key所对应的Setconsteffects=depsMap.get(key)////执行依赖函数//因为删除又添加都在同一个deps中,所以会产生无限执行leteffectsToRun=newSet()effects&&effects.forEach(effectFn=>{//如果trigger出发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行if(effectFn!==activeEffect){effectsToRun.add(effectFn)}})effectsToRun.forEach(effect=>effect())}复制代码
四.computed
computed
是vue3中的计算属性,它可以根据传入的参数进行响应式的处理:
constplusOne=computed(()=>count.value+1)复制代码
根据computed
的用法,我们可以知道它的几个特点:
-
懒执行,值变化时才会触发
-
缓存功能,如果值没有变化,就会返回上一次的执行结果 在实现这两个核心功能之前,我们先来改造一下之前实现的
effect
函数。
怎么能使effect
函数变成懒执行呢,比如计算属性的这种功能,我们不想要他立即执行,而是希望在它需要的时候才执行。
这时候我们可以在effect
函数中传递第二个参数,一个对象,用来设置一些额外的功能。
functioneffect(fn,options={}){//修改leteffectFn=function(){activeEffect=effectFneffectStack.push(effectFn)fn()effectStack.pop()activeEffect=effectStack[effectStack.length-1]}//只有当非lazy的时候才直接执行if(!options.lazy){effectFn()}//将依赖函数组为返回值进行返回returneffectFn//新增}复制代码
这时,如果传递了lazy
属性,那么该effect
将不会立即执行,需要手动进行执行:
consteffectFn=effect(()=>{console.log(obj.foo)},{lazy:true})//手动执行effectFn()复制代码
但是如果我们想要获取手动执行后的值呢,这时只需要在effect
函数中将其返回即可。
functioneffect(fn,options={}){leteffectFn=function(){activeEffect=effectFneffectStack.push(effectFn)//保存返回值constres=fn()//新增effectStack.pop()activeEffect=effectStack[effectStack.length-1]returnres//新增}//只有当非lazy的时候才直接执行if(!options.lazy){effectFn()}//将依赖函数组为返回值进行返回returneffectFn}复制代码
接下来开始实现computed
函数:
functioncomputed(getter){//创建一个可手动调用的依赖函数consteffectFn=effect(getter,{lazy:true})//当对象被访问的时候才调用依赖函数constobj={getvalue(){returneffectFn()}}returnobj}复制代码
但是此时还做不到对值进行缓存和对比,增加两个变量,一个存储执行的值,另一个为一个开关,表示“是否可以重新执行依赖函数”:
functioncomputed(getter){//定义value保存执行结果//isRun表示是否需要执行依赖函数letvalue,isRun=true;//新增consteffectFn=effect(getter,{lazy:true})constobj={getvalue(){//增加判断,isRun为true时才会重新执行if(isRun){//新增//保存执行结果value=effectFn()//新增//执行完毕后再次重置执行开关isRun=false//新增}returnvalue}}returnobj}复制代码
但是上面的实现还有一个问题,就是好像isRun
执行一次后好像永远都不会变成true
了,我们的本意是在数据发生变动的时候需要再次触发依赖函数,也就是将isRun变为true,实现这种效果,需要我们为options
再传递一个函数,用于用户自定义的_调度执行_。
functioneffect(fn,options={}){leteffectFn=function(){activeEffect=effectFneffectStack.push(effectFn)constres=fn()effectStack.pop()activeEffect=effectStack[effectStack.length-1]returnres}//挂载用户自定义的调度执行器effectFn.options=options//新增if(!options.lazy){effectFn()}returneffectFn}复制代码
接下来需要修改一下trigger
如果传递了scheduler
这个函数,那么只执行scheduler
这个函数而不执行依赖函数:
functiontrigger(target,key){letdepsMap=store.get(target)if(!depsMap)returnconsteffects=depsMap.get(key)leteffectsToRun=newSet()effects&&effects.forEach(effectFn=>{if(effectFn!==activeEffect){effectsToRun.add(effectFn)}})effectsToRun.forEach(effect=>{//如果存在调度器scheduler,那么直接调用该调度器,并将依赖函数进行传递if(effectFn.options.scheduler){//新增effectFn.options.scheduler(effect)//新增}else{effect()}})}复制代码
那么在computed
中就可以实现重置执行开关isRun
的操作了:
functioncomputed(getter){//定义value保存执行结果//isRun表示是否需要执行依赖函数letvalue,isRun=true;//新增consteffectFn=effect(getter,{lazy:true,scheduler(){if(!isRun){isRun=true}}})constobj={getvalue(){//增加判断,isRun为true时才会重新执行if(isRun){//新增//保存执行结果value=effectFn()//新增//执行完毕后再次重置执行开关isRun=false//新增}returnvalue}}returnobj}复制代码
当computed
传入的依赖函数中的值发生改变时,会触发响应式对象的trigger
函数,而计算属性创建响应式对象时传入了scheduler
,所以当数据改变时,只会执行scheduler
函数,在scheduler
函数内我们将执行开关重置为true
,再下次访问数据触发get
函数时,就会重新执行依赖函数。这也就实现了_当数据发生改变时,会再次触发依赖函数_的功能了。
为了避免计算属性被另外一个依赖函数调用而失去响应,我们还需要为计算属性单独进行绑定响应式的功能,形成一个effect
嵌套。
functioncomputed(getter){letvalue,isRun=true;consteffectFn=effect(getter,{lazy:true,scheduler(){if(!isRun){isRun=true//当计算属性依赖的响应式数据发生变化时,手动调用trigger函数触发响应trigger(obj,'value')//新增}}})constobj={getvalue(){if(isRun){value=effectFn()isRun=false}//当读取value时,手动调用track函数进行追踪track(obj,'value')returnvalue}}returnobj}复制代码
五. watch
先来看一下watch
函数的用法,它的用法也非常简单:
watch(obj,()=>{console.log(改变了)})//修改数据,触发watch函数obj.age++复制代码
watch
接受两个参数,第一个参数为绑定的响应式数据,第二个参数为依赖函数,我们依然可以沿用之前的思路来进行处理,利用effect
以及scheduler
来改变触发执行时机。
functionwatch(source,fn){effect(//递归读取对象中的每一项,变为响应式数据,绑定依赖函数()=>bindData(source),{scheduler(){//当数据发生改变时,调用依赖函数fn()}})}//readData保存已读取过的数据,防止重复读取functionbindData(value,readData=newSet()){//此处只考虑对象的情况,如果值已被读取/值不存在/值不为对象,那么直接返回if(typeofvalue!=='object'||value==null||readData.has(value))return//保存已读取对象readData.add(value)//遍历对象for(constkeyinvalue){//递归进行读取bindData(value[key],readData)}returnvalue}复制代码
watch
函数还有另外一种用法,就是除了接收对象,还可以接受一个getter
函数,例如:
watch(()=>obj.age,()=>{console.log('改变了')})复制代码
这种情况下只需要将用户传入的getter
将我们自定义的bindData
替代即可:
functionwatch(source,fn){letgetter=typeofsource==='function'?source:(()=>bindData(source))effect(//执行getter()=>getter(),{scheduler(){//当数据发生改变时,调用依赖函数fn()}})}复制代码
其实watch
函数还有一个很重要的功能:就是在用户传递的依赖函数中可以获取新值和旧值,但是我们目前还做不到这一点。实现这个功能我们可以配置前文中的lazy
属性来实现。来回顾一下lazy
属性:设置了lazy
之后一开始不会执行依赖函数,手动执行时会返回执行结果:
functionwatch(source,fn){letgetter=typeofsource==='function'?source:(()=>bindData(source))//定义新值与旧值letnewVal,oldVal;//新增consteffectFn=effect(//执行getter()=>getter(),{lazy:true,scheduler(){//在scheduler重新执行依赖函数,得到新值newVal=effectFn()//新增fn(newVal,oldVal)//新增//执行完毕后更新旧值oldVal=newVal//新增}})//手动调用依赖函数,取得旧值oldVal=effectFn()//新增}复制代码
此外,watch
函数还有一个功能,就是可以自定义执行时机,比如immediate
属性,他会在创建时立即执行一次:
watch(obj,()=>{console.log('改变了')},{immediate:true})复制代码
我们可以把scheduler
封装为一个函数,以便在不同的时机去调用他:
functionwatch(source,fn,options={}){letgetter=typeofsource==='function'?source:(()=>bindData(source))letnewVal,oldVal;construn=()=>{//新增newVal=effectFn()fn(newVal,oldVal)oldVal=newVal}consteffectFn=effect(()=>getter(),{lazy:true,//使用run来执行依赖函数scheduler:run//修改})//当immediate为true时,立即执行一次依赖函数if(options.immediate){//新增run()//新增}else{oldVal=effectFn()}}复制代码
watch
函数还支持其他的执行调用时机,这里只实现了immediate
。
六. 浅响应与深响应
深响应和浅响应的区别:
constobj=reatcive({foo:{bar:1}})effect(()=>{console.log(obj.foo.bar)})//修改obj.foo.bar的值,并不能触发响应obj.foo.bar=2复制代码
因为之前实现的拦截,无论对于什么类型的数据都是直接进行返回的,如果实现深响应,那么首先应该判断是否为对象类型的值,如果是对象类型的值,应当递归调用reactive
方法进行转换。
//接收第二个参数,标记为是否为浅响应functioncreateReactive(obj,isShallow=false){returnnewProxy(obj,{get(target,key,receiver){//访问raw时,返回原对象if(key==='raw')returntargettrack(target,key)constres=Reflect.get(target,key,receiver)//如果是浅响应,直接返回值if(isShallow){returnres}//判断res是否为对象并且不为null,循环调用reatciveif(typeofres==='object'&&res!==null){returnreatcive(res)}returnres},//...省略其他})复制代码
将创建响应式对象的方法抽离出去,通过传递isShallow
参数来决定是否创建深响应/浅响应对象。
//深响应functionreactive(obj){returncreateReactive(obj)}//浅响应functionshallowReactive(obj){returncreateReactive(obj,true)}复制代码
七. 浅只读与深只读
有时候我们并不需要对值进行修改,也就是需要值为只读的,这个操作也分为深只读和浅只读,首先需要在createReactive
函数中增加一个参数isReadOnly
,代表是否为只读属性。
//浅只读functionshallowReadOnly(obj){returncreateReactive(obj,true,true)}//深只读functionreadOnly(obj){returncreateReactive(obj,false,true)}复制代码
set(target,key,newValue,receiver){//是否为只读属性,如果是则打印警告信息并直接返回if(isReadOnly){console.log(`属性${key}是只读的`)returnfalse}constoldVal=target[key]consttype=Object.prototype.hasOwnProperty.call(target,key)?triggerType.SET:triggerType.ADDconstres=Reflect.set(target,key,newValue,receiver)if(target===receiver.raw){if(oldVal!==newValue&&(oldVal===oldVal||newValue===newValue)){trigger(target,key,type)}}returnres}复制代码
如果为只读属性,那么也不需要为其建立响应联系 如果为只读属性,那么在进行深层次遍历的时候,需要调用readOnly
函数对值进行包装
functioncreateReactive(obj,isShallow=false,isReadOnly=false){returnnewProxy(obj,{get(target,key,receiver){//访问raw时,返回原对象if(key==='raw')returntarget//只有在非只读的时候才需要建立响应联系if(!isReadOnly){track(target,key)}constres=Reflect.get(target,key,receiver)//如果是浅响应,直接返回值if(isShallow){returnres}//判断res是否为对象并且不为null,循环调用creativeif(typeofres==='object'&&res!==null){//如果数据为只读,则调用readOnly对值进行包装returnisReadOnly?readOnly(res):creative(res)}returnres},})}复制代码
八. 处理数组
数组的索引与length
如果操作数组时,设置的索引值大于数组当前的长度,那么要更新数组的length
属性,所以当通过索引设置元素值时,可能会隐式的修改length
的属性值,因此再j进行触发响应时,也应该触发与length
属性相关联的副作用函数重新执行。
constarr=reactive(['foo'])//数组原来的长度为1effect(()=>{console.log(arr.length)//1})//设置索引为1的值,会导致数组长度变为2arr[1]='bar'复制代码
在判断操作类型时,新增对数组类型的判断,如果代理目标是数组,那么对于操作类型的判断作出处理:
如果设置的索引值小于数组的长度,就视为SET
操作,因为他不会改变数组长度,如果设置的索引值大于当前数组的长度,那么应该被视为ADD
操作。
//定义常量,便于修改consttriggerType={ADD:'add',SET:'set'}set(target,key,newValue,receiver){if(isReadOnly){console.log(`属性${key}是只读的`)returnfalse}constoldVal=target[key]//如果目标对象是数组,检测被设置的索引值是否小于数组长度consttype=Array.isArray(target)&&(Number(key)>target.length?triggerType.ADD:triggerType.SET)constres=Reflect.set(target,key,newValue,receiver)trigger(target,key,type)returnres},复制代码
functiontrigger(target,key,type){constdepsMap=store.get(target)if(!depsMap)returnconsteffects=depsMap.get(key)leteffectsToRun=newSet()effects&&effects.forEach(effectFn=>{if(effectFn!==activeEffect){effectsToRun.add(effectFn)}})//当操作类型是ADD并且目标对象时数组时,应该取出执行那些与length属性相关的副作用函数if(Array.isArray(target)&&type===triggerType.ADD){//取出与length相关的副作用函数constlengthEffects=deps.get('length')lengthEffects&&lengthEffects.forEach(effectFn=>{if(effectFn!==activeEffect){effectsToRun.add(effectFn)}})}effectsToRun.forEach(effect=>{if(effectFn.options.scheduler){effectFn.options.scheduler(effect)}else{effect()}})}复制代码
还有一点:其实修改数组的length
属性也会隐式的影响数组元素:
constarr=reactive(['foo'])effect(()=>{//访问数组的第0个元素console.log(arrr[0])//foo})//将数组的长度修改为0,导致第0个元素被删除,因此应该触发响应arr.length=0复制代码
如上所示,在副作用函数内部访问了第0个元素,然后将数组的length
属性修改为0,这回隐式的影响数组元素,及所有的元素都会被删除,所以应该触发副作用函数重新执行。
然而并非所有的对length
属性值的修改都会影响数组中的已有元素,如果设置的length
属性为100,这并不会影响第0个元素,当修改属性值时,只有那些索引值大于等于新的length
属性值的元素才需要触发响应。
调用trigger
函数时传入新值:
set(target,key,newValue,receiver){if(isReadOnly){console.log(`属性${key}是只读的`)returnfalse}constoldVal=target[key]//如果目标对象是数组,检测被设置的索引值是否小于数组长度consttype=Array.isArray(target)&&(Number(key)>target.length?triggerType.ADD:triggerType.SET)constres=Reflect.set(target,key,newValue,receiver)//将新的值进行传递,及触发响应的新值trigger(target,key,type,newValue)//新增returnres}复制代码
判断新的下标值与需要操作的新的下标值进行判断,因为数组的key
为下标,所以副作用函数搜集器是以下标作为key
值的,当length
发生变动时,只需要将新值与每个下标的key
判断,大于等于新的length
值的需要重新执行副作用函数。
未命名绘图.drawio_(2)_1659679803962_0.png
如上图所示,Map
为根据数组的key
,也就是id
组成的Map
结构,他们的每一个key
都对应一个Set
,用于保存这个key
下面的所有的依赖函数。
当length
属性发生变动时,应当取出所有key
值大于等于length
值的所有依赖函数进行执行。
functiontrigger(target,key,type,newValue){constdepsMap=store.get(target)if(!depsMap)returnconsteffects=depsMap.get(key)leteffectsToRun=newSet()effects&&effects.forEach(effectFn=>{if(effectFn!==activeEffect){effectsToRun.add(effectFn)}})//如果操作目标是数组,并且修改了数组的length属性if(Array.isArray(target)&&key==='length'){//对于索引值大于或等于新的length元素//需要把所有相关联的副作用函数取出并添加到effectToRun中待执行depsMap.forEach((effects,key)=>{//key与newValue均为数组下标,因为数组中key为indexif(key>=newValue){effects.forEach(effectFn=>{if(effectFn!==activeEffect){effectsToRun.add(effectFn)}})}})}//...省略}复制代码
本文的实现数组这种数据结构只考虑了针对长度发生变化的情况。
九. ref
由于Proxy的代理目标是非原始值,所以没有任何手段去拦截对原始值的操作:
letstr='hi'//无法拦截对值的修改str='pino'复制代码
解决方法是:使用一个非原始值去包裹原始值:
functionref(val){//创建一个对象对原始值进行包裹constwrapper={value:val}//使用reactive函数将包裹对象编程响应式数据并返回returnreactive(wrapper)}复制代码
如何判断是用户传入的对象还是包裹对象呢?
constref1=ref(1)constref2=reactive({value:1})复制代码
只需要在包裹对象内部定义一个不可枚举且不可写的属性:
functionref(val){//创建一个对象对原始值进行包裹constwrapper={value:val}//定义一个属性值__v_isRef,值为true,代表是包裹对象Object.defineProperty(wrapper,'_isRef',{value:true})//使用reactive函数将包裹对象编程响应式数据并返回returnreactive(wrapper)}复制代码
十. 响应丢失问题与toRefs
在使用...解构赋值时会导致响应式丢失:
constobj=reactive({foo:1,bar:2})//将响应式数据展开到一个新的对象newObjconstnewObj={...obj}//此时相当于:constnewObj={foo:1,bar:2}effect(()=>{//在副作用函数中通过新对象newObj读取foo属性值console.log(newObj.foo)})//obj,foo并不会触发响应obj.foo=100复制代码
首先创建一个响应式对象obj,然后使用展开运算符得到一个新对象newObj
,他是一个普通对象,不具有响应式的能力,所以修改obj.foo
的值不会触发副作用函数重新更新。
解决方法:
constnewObj={foo:{//用于返回其原始的响应式对象getvalue(){returnobj.foo}},bar:{getvalue(){returnobj.bar}}}复制代码
将单个值包装为一个对象,相当于访问该属性的时候会得到该属性的getter
,在getter
中返回原始的响应式对象。
相当于解构访问newObj.foo
=== obj.foo
。
{getvalue(){returnobj.foo}}复制代码
toRefs
functiontoRefs(obj){letres={}//处理整个对象时,将属性依次进行遍历,调用toRef进行转化for(letkeyinobj){res[key]=toRef(obj,key)}returnres}functiontoRef(obj,key){constwrapper={//允许读取值getvalue(){returnobj[key]},//允许设置值setvalue(val){obj[key]=val}}//标志为ref对象Object.defineProperty(wrapper,'_isRef',{value:true})returnwrapper}复制代码
使用toRefs
处理整个对象,在toRefs
这个函数中循环处理了对象所包含的所有属性。
constnewObj={...toRefs(obj)}复制代码
当设置value
属性值的时候,最终设置的是响应式数据的同名属性值。
一个基本的vue3
响应式就完成了,但是本文所实现的依然是阉割版本,有很多情况都没有进行考虑,还有好多功能没有实现,比如:拦截 Map
,Set
,数组的其他问题,对象的其他问题,其他api的实现,但是上面的实现已经足够让你理解vue3响应式原理实现的核心了,这里还有很多其他的资料需要推荐,比如阮一峰老师的es6教程,对于vue3底层原理的实现,许多知识依然是需要回顾和复习,查看原始底层的实现,再比如霍春阳老师的《vue.js的设计与实现》这本书,这本书目前我也只看完了一半,但是截止到目前我认为这本书对于学习vue3
的原理是非常深入浅出,鞭辟入里的,本文的许多例子也是借鉴了这本书。
最后当然是需要取读一读源码,不过在读源码之前能够先了解一下实现的核心原理,再去看源码是事半功倍的。希望大家都能早日学透源码,面试的时候能够对答如流,工作中遇到的问题也能从原理层面去理解和更好地解决!
目前我也在实现一个mini-vue
,截止到目前只实现了响应式部分,而且与本文的实现方式有所不同,后续还会继续实现编译和虚拟DOM部分,欢迎star!👇
k-vue[4]
如果想学习《vue.js的设计与实现》这本书这本书,那么请关注下面这个链接👇作为参考,里面包含了根据具体的问题的功能进行拆分实现,同样也只实现了响应式的部分!
vue3-analysis[5]
实现一个mini-vue系列文章
超详细整理vue3基础知识💥[6]
写在最后⛳
未来可能会更新实现mini-vue3
和javascript
基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳
关于本文
作者:pino
https://juejin.cn/post/7129644396533776420-
往期推荐
最后
-
欢迎加我微信,拉你进技术群,长期交流学习...-
-
欢迎关注「前端Q」,认真学前端,做个专业的技术人...
点个在看支持我吧