非原始值的响应式方案
对象的响应式
-
在Vue3中,使用Proxy和reflect实现对象的代理工作
-
Proxy:能够对其他对象的基本语义进行代理,但无法代理非对象值
- 基本语义:即对象的基本操作,如get,set,apply等
- 非对象值:string,number等
- 代理:proxy接受两个参数,第一个是被代理的对象,第二个是拦截器对象,拥有许多拦截方法,用于拦截对象的基本操作
-
Reflect:能够实现一个对象的基本语义
- 实现:在reflect对象拥有与proxy的拦截器同等的方法,用于实现对象的基本操作。
- 即当我们通过trap拦截到对象基本操作后,可以调用reflect再重新执行基本操作
什么是对象
- Js的世界中一切皆对象,对象分为两类,常规对象和异质对象
- 对象内部维护了各自独有的一些方法,由方法的不同表现出不同的语义
- 对象必要的内部方法如下
- 对象额外的必要内部方法如下
- 以上必要的内部方法,虽然是不同类型的对象都具有的,但它们具有多态性,方法内部的逻辑表现不同
- 内部方法实现遵循指定的规范,则为常规对象,否则为异质对象
- Proxy对象内部部署了包括必要方法和额外必要方法在内的所有方法,但是由于它的某些方法未遵循指定规范,所以为异质对象
- Proxy对象内部方法及具体表现
- 如图所示内部方法对应的具体实现正是Proxy的拦截器(trap)
对象如何代理
-
为了实现代理,我们需要拦截对象的所有读取操作
-
对象所有的读取操作:
- obj.prop 属性读取, 对应内部方法GET,拦截器为get
- key in obj 属性判断, 对应内部方法HasProperty,拦截器为has
- for( const key in obj ) 属性遍历, 对应内部方法OwnPropertyKeys,拦截器为oneKeys
-
对象所有的设置操作:
- obj.prop = a; 属性添加, 对应内部方法SET,拦截器为set
- obj.prop = b; 属性修改, 对应内部方法SET,拦截器为set
-
对象所有的删除操作:
- delete obj.prop 属性删除, 对应内部方法DELETE,拦截器为deleteProperty
-
-
为了实现响应式,我们需要合理的触发响应
- 当数据修改了但是值没变,此时不应该触发响应。我们需要在set中触发之前检查新值和旧值是否一致
- 当代理对象所代理的原始对象不存在某个属性时,会从代理对象的原型中查找,如果代理对象的原型仍然是一个代理对象,那么其拦截函数中,receiver始终保持为子级代理对象。这样通过判断拦截函数的target和receiver的关系即可决定是否要触发响应,来避免重复响应的问题
-
浅响应与深响应
-
将Proxy代理操作封装到reactive函数内部,即实现了浅响应
- 我们在get拦截函数内通过reflect返回了原始对象的属性,但即便该属性仍是一个对象,但其本身非代理对象没有拦截器,无法收集和触发响应
-
将Reflect操作返回的结果重新使用reactive包装,将包装后的代理对象返回,即实现了深响应
- 我们可以对reflect返回的属性类型进行判断,如果其仍为对象,则递归调用reactive将其包装为代理对象,将代理对象返回,这样返回的是代理对象,那么任何对代理对象的操作都会被拦截
-
-
只读和浅只读
- 只读本质上也是代理,通过拦截set和delete实现只读
- 深只读,只要递归调用只读函数即可
数组如何代理
- 数组是特殊的对象,也是异质对象,主要是由于其内部方法DefineOwnProperty未遵循规范所致
-
除了DefineOwnProperty外的其余内部方法均可沿用常规对象的代理方式
-
数组的读取操作
- arr[0] 数组元素值的访问,对应内部方法GET,拦截器get
- arr.length 数组属性的访问,对应内部方法GET,拦截器get
- for in 循环遍历key, 对应内部方法OwnPropertyKeys,拦截器ownKeys
- for of 循环遍历value, 对应内部方法GET,拦截器get
- concat,join,every,some,find,findIndex,includes等 数组方法的访问,没有对应的内部方法,只能通过重写方法,手动实现拦截
-
数组的设置操作
- arr[0] = 1 数组元素值的修改,对应内部方法SET,DefineOwnProperty,拦截器set
- DefineOwnProperty会去比较索引值和属性length,隐式修改length,那么我们除了要将当前索引的副作用函数触发,也要将length的副作用函数触发
- arr.length = 1 数组length属性(只有一个属性)的修改,对应内部方法SET,拦截器set
- push,pop,shift,unshift 数组元素操作方法,也需要重写原始方法,手动触发响应
- splice,fill,sort等,原数组修改的方法 也需要重写原始方法,手动触发响应
- arr[0] = 1 数组元素值的修改,对应内部方法SET,DefineOwnProperty,拦截器set
-
Set和Map如何代理
- 与常规对象不同,Set,Map的数据有特定的属性和方法来操作
- 数据的读取操作
-
set.size,集合的size属性访问,对应内部方法GET,拦截器get
- 但是默认的get会从代理对象身上读取size,会报错,我们要修改为原始集合
-
set.forEach(),集合的forEach方法访问,需要重写该方法
- 建立forEach和ITERATE_KEY的响应,在set和delete的时候去触发
- 重写的forEach内部会调用原始的forEach,由于是通过原始对象调用的,那么forEach方法的回调函数收到的由调用者传入的key,value参数就是非响应式数据,会导致响应失效。
- 我们需要对传递给回调函数的参数进行reactive的包装,然后在传递,保证响应式
-
set的迭代器方法,entries,keys,values,需要重写此类方法
- 调用此类方法会返回迭代器,供forof消费,而且set和map本身也有iterate接口,可以直接被消费
- forof会从被遍历的对象身上获取[Symbol.iterator]属性,得到迭代器然后不断调用next方法获取数据
- 那我们可以通过重写[Symbol.iterator]属性返回的迭代器来实现响应式
- entries会返回[Symbol.iterator]属性,与forof直接读取对象一致,直接使用以上方法即可
- keys,values分别关心key,和value,那么对于entries中的key,value都进行响应式来分别调整即可
-
- 数据的设置操作
- set.delete(),集合的delete方法访问,需要重写该方法
- 将重写的方法存储在mutableInstrumentations之下,同时使用bind来重新指定delete方法的this为原始对象
- set.add(),集合的add方法访问,对应内部方法SET,拦截器set
- 默认的set会将设置的value直接添加到原始对象身上,如果value是代理对象,我们称发生了数据污染
- 为了避免数据污染,我们要判断属性值类型,如果是代理对象,通过代理对象的raw属性获取其原始对象,再将其设置到我们要设置的原始对象中即可
- 为避免与用户设置的raw属性发生冲突,我们需要使用Symbol来包装raw保证唯一性
- set.delete(),集合的delete方法访问,需要重写该方法
- 数据的读取操作
原始值的响应式方案
原始值的有哪些
- Boolean,Number,BigInt,String,Symbol,undefined,null等
原始值如何代理
-
Proxy只能代理对象,不能代理原始值
-
要想实现原始值的响应式,可以通过将其包裹在对象中,通过对象间接实现响应式
- 创建一个wrapper对象,将需要实现响应式的原始值作为wrapper对象某个属性的值
- 这样当修改原始值时,就会触发某个属性的set,进而触发响应式
- 实现ref函数,接收原始值,创建wrapper对象,将原始值设置为属性value的值,对wrapper进行代理,将代理对象返回,我们调用ref函数得到代理对象,操作其value属性即可
- 以上行为,就是我们最常见的访问器属性设置
-
解决reactive数据,解构后丢失响应式问题
- 实现toRef函数,接收响应式对象参数,创建临时对象,为响应式对象的每个属性在临时对象中创建对应的访问器属性,然后将响应式对象的该属性读取操作返回。最后将临时对象返回
- 调用toRef函数,得到临时对象,将该对象作为响应式对象去解构,解构后得到的普通对象仍是通过访问器属性访问代理对象的属性,保证了响应式
-
自动脱ref
- 虽然通过toRef解决了reactive的响应式解构后丢失的问题,但由于访问的是临时对象的访问器属性,不得不每次都通过.value形式来获取,增加了用户的负担
- 拦截get函数,判断当前属性是否是ref,如果是ref,则返回属性.value
- 拦截set函数,判断当前属性是否是ref,如果是ref,则设置属性.value
- 保证用户无需关心当前数据是否是ref
总结
- 使用Proxy直接代理对象基本操作,再读取时收集副作用,设置时触发重新执行
- 使用Proxy间接代理原始值,通过get,set拦截器获取原始值的变化,进行触发重新执行