认识
一、认识
Vue 3
的 reactivity
系统是全新设计的,主要基于 ECMAScript 6
的 Proxy
来实现对对象 get
、set
、has
、ownKeys
、deleteProperty
的拦截,从而实现响应式数据追踪和更新。
一、reactive
底层结构
function reactive(target){
const existingProxy = reactiveMap.get(target);
if(existingProxy){
return existingProxy;
}
const proxy = new Proxy(target, {
get(target,key,receiver){
track(target,key);
},
set(target,key,value,receiver){
trigger(target,type,key,value);
},
has(target,key){
track(target,key);
},
ownKeys(target){
track(target, ITERATE_KEY);
},
deleteProperty(target,key){
trigger(target,'delete',key);
}
});
}
二、缓存与复用, 为了避免对同一个对象多次包装,Vue
使用 WeakMap
来缓存已经创建的 reactive
对象。每个目标对象只会有一个唯一的 Proxy
包装,这样可以保证响应式对象的一致性和性能。
三、get
拦截器, 读取属性时,调用 track(target, key)
进行依赖收集,将当前激活的 effect
(即正在执行的响应式函数)与该属性关联。
-
对象: 如果访问的属性值本身是一个对象,还会递归调用
reactive
将其转换为响应式对象(除非使用shallowReactive
)。 -
数组:
-
如果访问数组
array.includes() array.indexOf() array.lastIndexOf()
查找类型的数组API
, 查找的目标元素有可能是代理数据, 有可能是原始数据, 但是array
肯定是代理数据, 所以需要对查找类型的API
重写, 添加代理数组与原始数组的映射关系, 之后优先在代理数组中查找, 如果找不到再去原始数组中查找。 -
如果访问数组
array.push() array.push() array.shift() array.unshift() array.splice()
增删类型API
, 会读取数组中的length
属性,也会设置数组中的length
属性, 这会导致两个独立的副作用函数相互影响。只要屏蔽了对length
属性的读取, 从而避免在它与副作用函数之间建立响应联系。具体策略: 重写array.push() array.push() array.shift() array.unshift() array.splice()
方法, 在调用原始方法之前, 停止追踪, 调用原始方法之后, 继续追踪。
-
四、set
拦截器, 当设置属性时,先判断新值和旧值是否真正发生变化(例如通过比较)。若有变化,则更新 target
中对应的属性,并调用 trigger(target, key, newVal)
通知所有依赖此属性的 effect
执行更新。
五、has
拦截器, 监听 xx in yy
操作符, Vue
会调用内部的 track
函数,将当前正在运行的响应式 effect
收集为依赖
六、ownKeys
拦截器, 监听 for in
、for of
遍历操作, 收集 ITERATE_KEY
相关的副作用。因为 ownKeys
只有一个 target
参数, 没有 key
, 对于对象来说需要一个 ITERATE_KEY
来充当 key
, 后续之后新增属性时, 才会触发副作用函数执行, 已有属性不触发。对于数组而言, 无论是为数组添加新元素还是直接修改数组的长度, 本质上都是因为修改了数组的 length
属性。 一旦数组的 length
被修改, 那么 for…in
、for……of
循环对数组的遍历结果就会改变。
七、deleteProperty
拦截器, 监听属性删除操作, 触发对应属性副作用函数重新执行
二、变量细节
2.1 Proxy
reactive
的实现原理如下:
const obj = reactive({});
function reactive(target){
const baseHandlers = {
get: ()=>{
},
set: ()=>{
}
}
return new Proxy(target,baseHandlers);
}
我们可以知道, reactive
本质上其实就是一个 Proxy
代理对象, Proxy
只接收引用类型数据, 传入基础数据会报错。因此, reactive
也只可以接收引用数据类型
Proxy
的局限性如下:
-
Proxy
只可以代理引用类型数据 -
Proxy
代理对象解构之后的属性将不会触发handler
2.2 WeakMap
弱引用 不会影响垃圾回收机制。WeakMap
键为弱引用,如下所示 当 obj
置为 null
时,WeakMap
键没有对 obj
的地址保持引用,所以会触发垃圾回收机制回收
2.3 targetMap
存储结构
WeakMap: {
key: 响应性对象,
value: Map 对象
{
key: 响应性对象指定属性,
value: ReactiveEffect 实例的 Set 集合
}
}
2.4 reactiveMap
reactiveMap
为 const reactiveMap = new WeakMap()
WeakMap
类型, 以 target
源对象为 target
, 代理对象为 value
。这样做的目的是: 一旦 target
源对象不存在,代理对象随即会被垃圾回收器回收,进而优化性能,减少了内存占用。
2.5 activeEffect
activeEffect
记录当前激活的 ReactiveEffect
实例, 如果存在 activeEffect
, 说明注册了 fn
副作用函数,需要进行依赖收集。
三、函数细节
3.1 track
所谓的响应性指的是: 当响应性数据触发 setter
时执行 fn
函数。那么需要在 getter
时能够收集当前的 fn
函数,以便在 setter
的时候可以执行对应的 fn
函数。但是对于收集而言, 如果仅仅是把 fn
存起来还是不够的,我们需要知道: 当前的这个 fn
是与哪个响应式数据对象的哪个属性对应的。只有这样,我们才可以在该属性触发 setter
的时候,准确的执行响应性。
因此,我们在依赖收集的时候,需要将 fn
与 响应式对象的具体属性 绑定,那么如何精准的绑定呢?
track
单一依赖收集
WeakMap: {
key: 响应性对象,
value: Map 对象
{
key: 响应性对象指定属性,
value: 单个 ReactiveEffect
}
}
const targetMap = new WeakMap();
const depsMap= targetMap.get(target);
if(!depsMap){
targetMap.set(target, (depsMap = new Map()));
}
depsMap.set(key, activeEffect);
通过构建以上数据结构,我们就可以精准的绑定 fn
与 响应式对象的具体属性。但是,如果一个属性有多个 ReactiveEffect
, 以上实现会使后面 ReactiveEffect
覆盖前面的 ReactiveEffect
。因此, 我们需要支持收集多个依赖。
track
多个依赖收集
WeakMap: {
key: 响应性对象,
value: Map 对象
{
key: 响应性对象指定属性,
value: ReactiveEffect 实例的 Set 集合
}
}
const targetMap = new Weakmap();
const depsMap = targetMap.get(target);
if(!depsMap){
targetMap.set(target, (depsMap = new Map()));
}
const dep = depsMap.get(key);
if(!dep){
depsMap.set(key, (dep = new Set()));
}
dep.add(activeEffect);
3.2 trigger
trigger
单一依赖触发
const depsMap = targetMap.get(target);
if(!depsMap){
return;
}
const effect = depsMap.get(key) as ReactiveEffect;
effect?.fn();
trigger
多个依赖触发
const depsMap = targetMap.get(target);
if(!depsMap){
return;
}
const deps = depsMap.get(key);
if(!deps){
return;
}
for(const dep of deps){
dep.run();
}
四、对象细节
4.1 object[xx]
object[xx]
调用 get(target,key,receiver)
来拦截, 有以下几点细节:
-
通过
receiver.raw
记录此时的target
, 如果访问.raw
时, 直接返回target
-
使用
for……of
或者values()
方法, 都会读取target.[Symbol.iterator]
属性, 为了避免发生意外的错误以及性能上的考虑, 不应该在副作用函数与target[Symbol.iterator]
之间建立响应联系, 所以在调用track
函数进行追踪之前, 需要加一个判断条件, 即只有当key
的类型不是symbol
时才进行追踪。 -
调用
track
收集当前key
相关的副作用函数 -
如果
target[key]
为对象, 继续为target[key]
执行reactive(target[key])
递归响应式。
4.2 object[xx] = yy
object[xx] = yy
调用 set(target,key,value,receiver)
进行拦截, 有以下几点细节:
-
处理
value
, 保证value
为原始数据。target
为原始数据, 如果value
为响应式数据, 那么会使原始数据拥有响应式的能力, 造成了数据污染。将响应式数据设置到原始数据上的行为称为数据污染 -
根据是否存在
key
值, 得到此时的操作类型为TriggerType.ADD
还是TriggerType.SET
, 如果操作类型为TriggerType.ADD
, 后续会触发与ITERATE_KEY
相关的副作用函数。 -
需要判断此时的
target
是否为此时的receiver
中记录的raw
, 只有target === receiver.raw
才会进行后续的trigger
触发重新执行副作用函数的操作。 这样针对A
响应式数据的原型是B
响应式数据, 更新A
中的B
时, 会有重复执行多次副作用函数的问题。这个判断很好的屏蔽了原型链引起的更新问题。 -
需要判断此时的新值是否发生过变化, 判断的逻辑其实是一个
Object.is()
来比较旧值与新值。只有发生过变化, 才会调用trigger
重新执行与key
相关的副作用函数。
4.3 xx in object
xx in object
通过 has(target,key)
拦截器拦截, 调用 track
收集key
相关的副作用函数。
4.4 for … in object
for …… in object
通过 ownKeys
拦截器拦截, 调用 track
收集副作用函数。但是 ownKeys
拦截器只有一个 target
源对象参数, 没有 key
, 要用一个变量来充当 key
, 这个变量为 ITERATE_KEY
, 它的值为 Symbol
, 是一个唯一标识。那么这时候, track
是以 ITERATE_KEY
为 key
收集副作用函数的。
track
将 for … in object
的副作用函数收集到了 ITERATE_KEY
的一个唯一标识 key
中, 那么在 trigger
触发的时候, 对于 object
中的属性触发有如下策略:
-
**
key
**是已有属性: 那么ITERATE_KEY
对应的副作用函数不需要重新执行, 这样的话避免了不必要的性能损耗 -
**
key
**是新增属性: 那么ITERATE_KEY
对应的副作用函数会重新执行 -
通过
delete
触发: 删除操作会使obj
的key
减少, 它会影响for……in
的循环次数, 我们应该让ITERATE_KEY
对应的effect
副作用函数重新执行
4.5 delete objet[xx]
delete object[xx]
通过 deleteProperty
拦截器拦截, 调用 trigger
触发副作用函数重新执行
五、数组细节
5.1 array[xx] = yy
array[xx] = yy
有两种场景, trigger
重新执行 length
副作用函数的策略为:
-
xx < array.length
: 修改已有数组元素, 操作类型为TriggerType.SET
, 元素已经支持响应式,length
无变化 -
xx >= array.length
: 增加新数组元素, 操作类型为TriggerType.ADD
, 新增元素其实已经支持响应式,length
同时发生了变化, 需要在trigger
中 重新执行length
相关的副作用函数。
5.2 array.length = xx
array.length = xx
有两种场景, trigger
重新执行数组元素相关的所有的副作用函数的策略为:
-
xx < array.length
-
xx >= array.length
重新执行 array
数组中索引值大于等于 xx
的元素相关的所有副作用函数即可
5.3 for…in array
for…in array
同样通过 ownKeys
进行拦截, 只不过跟 for…in object
不同的是, 我们为了追踪普通对象的 for…in
, 借用了 ITERATE_KEY
作为了追踪的 key
。 对于一个普通对象来说, 只有添加或者删除属性值时才会影响 for…in
循环的结果, 所以当添加或者删除操作发生时, 我们需要取出与 ITERATE_KEY
相关联的副作用函数重新执行。 对于一个数组来说, 无论是为数组添加新元素还是直接修改数组的长度, 本质上都是因为修改了数组的 length
属性。 一旦数组的 length
被修改, 那么 for…in
循环对数组的遍历结果就会改变。
由上所述, 我们在 ownKeys
拦截器中需要判断 target
类型:
-
target
为数组: 使用length
属性作为key
建立响应式 -
target
为对象: 使用ITERATE_KEY
唯一值为key
建立响应式
5.4 for…of array
for…of array
同样通过 ownKeys
进行拦截即可。 此外, 使用 for……of
或者 values()
方法, 都会读取 target.[Symbol.iterator]
属性, 为了避免发生意外的错误以及性能上的考虑, 不应该在副作用函数与 target[Symbol.iterator]
之间建立响应联系, 所以在调用 track
函数进行追踪之前, 需要加一个判断条件, 即只有当 key
的类型不是 symbol
时才进行追踪。
5.5 array.includes() array.indexOf() array.lastIndexOf()
array.includes(x)
、array.indexOf(x)
、**array.lastIndexOf(x)
**有如下两种特殊场景需要处理:
-
x
同样为reactive
数据: 我们需要定义reactiveMap
存储原始对象到代理对象的映射。在每次调用reactive
函数创建代理对象之前, 优先检查是否已经存在相应的代理对象, 如果存在, 则直接返回已有的代理对象, 这样就避免了同一个原始对象多次创建代理对象的问题。 -
x
为普通对象{}
:array
为代理对象,array.includes()
内部的this
指向的是array
的代理对象, 通过array[xx]
得到的值也是代理对象, 所以拿原始对象{}
去查找肯定找不到。所以, 我们需要重写数组的includes
并实现自己自定的行为。重写策略为: 修改get
拦截器, 每当访问array.includes()
、array.indexOf()
、array.lastIndexOf()
时, 返回定义在arrayInstrumentations
上定义的重写函数, 该函数优先在代理对象中进行查找,实现array.includes()
、array.indexOf()
、array.lastIndexOf()
的默认行为; 如果找不到, 通过this.raw
拿到原始数组, 再去其中进行查找, 最后返回结果。
5.6 array.push() array.push() array.shift() array.unshift() array.splice()
array.push() array.push() array.shift() array.unshift() array.splice()
会读取数组中的 length
属性,也会设置数组中的 length
属性, 这会导致两个独立的副作用函数相互影响。如果多次运行 array.push() array.push() array.shift() array.unshift() array.splice()
, 会出现这一次的副作用函数还没有执行完毕,就要再次执行上一次副作用函数的情况,如此循环往复, 最终导致了栈溢出。解决思路就是: 只要屏蔽了对 length
属性的读取, 从而避免在它与副作用函数之间建立响应联系。具体策略: 重写array.push() array.push() array.shift() array.unshift() array.splice()
方法, 在调用原始方法之前, 停止追踪, 调用原始方法之后, 继续追踪
六、Set、Map 细节
6.1 size
set.size
或者 map.size
使用 get()
拦截器进行拦截, size
是一个访问器属性,读取属性名称是否为 size
, 如果是, 则在调用 Reflect.get
函数时, 指定第三个参数为原始 Set
对象或者 Map
对象。 这样访问器属性 set.size
或者 map.size
的 getter
函数在执行时, this
指向的是原始 Set
对象而非代理对象。
get(target,key,receiver){
if(key === 'size'){
return Reflect.get(target,key,target);
}
}
set.size
或者 map.size
收集副作用函数时, 应该收集到 ITERATE_KEY
上, 这是因为任何新增、删除操作都会影响 size
属性。
get(target,key,receiver){
if(key === 'size'){
track(target,ITERATE_KEY);
return Reflect.get(target,key,target);
}
}
6.2 add
set.add(x)
使用 get()
拦截器进行拦截, add
是一个方法, 可以使用 bind
函数将 this
指向为为原始Set
或者Map
对象, 使代码可以正常执行。
get(target,key,receiver){
if(key === "size"){
return Reflect.get(target,key,target);
}
return target[key].bind(target);
}
但是在具体实现中, 不用 bind
改变 this
指向, 我们可以通过 raw
直接拿到原始 Set
或者 Map
对象, 可以直接调用 raw.add()
。
add(value){
const target = this.raw;
target.add(value);
}
在 target.add(value)
之前,需要确保 value
为原始数据。因为 target
为原始数据, 如果将响应式数据添加给原始数据, 会造成数据污染, 使原始数据拥有了响应式的能力, 这不是我们期望的。
add(value){
value = value.raw || value;
const target = this.raw;
target.add(value);
}
添加操作完成后, 调用 trigger
触发响应, 并指定操作类型为 TriggerType.ADD
, 当操作类型为 TriggerType.ADD
时, 会对数据的 size
属性产生影响, 所以取出与 ITERATE_KEY
相关的副作用函数并执行
add(value){
value = value.raw || value;
const target = this.raw;
target.add(value);
trigger(target,TriggerType.ADD,value);
}
6.3 set
map.set(key,value)
使用 get()
拦截器进行拦截, set
是一个方法, 可以使用 bind
函数将 this
指向为为原始Set
或者Map
对象, 使代码可以正常执行。
get(target,key,receiver){
if(key === "size"){
return Reflect.get(target,key,target);
}
return target[key].bind(target);
}
但是在具体实现中, 不用 bind
改变 this
指向, 我们可以通过 raw
直接拿到原始 Set
或者 Map
对象, 可以直接调用 raw.set()
。
set(key,value){
const target = this.raw;
target.set(key,value);
}
在 target.set(key,value)
之前,需要确保 value
为原始数据。因为 target
为原始数据, 如果将响应式数据添加给原始数据, 会造成数据污染, 使原始数据拥有了响应式的能力, 这不是我们期望的。
set(key,value){
value = value.raw || value;
const target = this.raw;
target.set(key,value);
}
添加操作完成后, 调用 trigger
触发响应, 我们需要判断 key
是否存在, 以便指定操作类型为 TriggerType.ADD
还是 TriggerType.SET
, 当操作类型为 TriggerType.ADD
时, 会对 size
属性产生影响, 会取出与 ITERATE_KEY
相关的副作用函数并执行。当操作类型为 TriggerType.SET
时, 虽然对 size
属性没有影响, 但是会改变一个 键值, 所以同样需要取出与 ITERATE_KEY
相关的副作用函数并执行
set(key,value){
value = value.raw || value;
const target = this.raw;
const had = target.has(key);
const oldValue = target.get(key);
if(!had){
trigger(target,TriggerType.ADD,key,value);
}else if(hasChanged(oldValue,value)){
trigger(target,TriggerType.SET,key,value);
}
}
6.4 get
map.get(key)
使用 get()
拦截器进行拦截, get
是一个方法, 可以使用 bind
函数将 this
指向为为原始Set
或者Map
对象, 使代码可以正常执行。
get(target,key,receiver){
if(key === "size"){
return Reflect.get(target,key,target);
}
return target[key].bind(target);
}
但是在具体实现中, 不用 bind
改变 this
指向, 我们可以通过 raw
直接拿到原始 Set
或者 Map
对象, 可以直接调用 raw.get()
。
get(key){
const target = this.raw;
const had = target.has(key);
}
调用 track
收集相关副作用函数
get(key){
const target = this.raw;
const had = target.has(key);
track(target,key);
}
如果 key
存在, 那么获取 value
, 并判断 value
是否为对象, 如果为对象, 继续递归变成响应式对象
6.5 delete
set.delete(x)
或者 map.delete(x)
使用 get()
拦截器进行拦截, delete
是一个方法, 可以使用 bind
函数将 this
指向为为原始Set
或者Map
对象, 使代码可以正常执行。
get(target,key,receiver){
if(key === "size"){
return Reflect.get(target,key,target);
}
return target[key].bind(target);
}
但是在具体实现中, 不用 bind
改变 this
指向, 我们可以通过 raw
直接拿到原始 Set
或者 Map
对象, 可以直接调用 raw.delete()
。
delete(key){
const target = this.raw;
target.delete(key);
}
删除操作完成后, 调用 trigger
触发响应, 并指定操作类型为 TriggerType.DELETE
, 当操作类型为 TriggerType.DELETE
时, 会取出与 ITERATE_KEY
相关的副作用函数并执行
6.6 forEach
map.forEach
实现响应式有以下细节:
-
遍历操作只与键值对的数量有关, 当
forEach
被调用时, 我们应该让副作用函数与ITERATE_KEY
建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。 -
map.forEach(callback)
要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用reactive
转换为响应式数据
6.7 for…of
for…of
遍历 set
或者 map
代理对象, 内部会试图从代理对象读取 代理对象[Symbol.iterator]
的 Iterator
接口, 这个操作会触发 get
拦截器, 因此, 我们可以在 get
拦截器中重写 [Symbol.iterator]
, 代理对象就可以拥有 for…of
的遍历能力。细节如下:
-
通过代理对象
[raw]
得到原始set
、map
, 从原始set
、map
中通过[Symbol.iterator]
获取迭代器对象 -
for…of
遍历set
、map
时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用reactive
转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现next
迭代器。 -
for…of
为遍历操作, 只与键值对的数量有关。 当for…of
被调用时, 我们应该让副作用函数与ITERATE_KEY
建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。 -
模拟迭代器, 返回带有
next
方法的对象。next
处理的是键值对
6.8 entries
entries
的遍历与 for…of
同理。 因此, entries
的重写逻辑与 for…of
是一样的, 但是也有一些小小的区别: for…of
需要代理对象带有 next
方法, 而 entries
需要代理对象带有 [Symbol.iterator]
方法。for…of
的实现需要有 next
方法,叫做迭代器协议; entries
的实现需要 Symbol[iterator]
方法, 叫做 可迭代协议。处理细节如下:
-
通过代理对象
[raw]
得到原始set
、map
, 从原始set
、map
中通过[Symbol.iterator]
获取迭代器对象 -
entries
遍历set
、map
时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用reactive
转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现next
迭代器。 -
entries
为遍历操作, 只与键值对的数量有关。 当for…of
被调用时, 我们应该让副作用函数与ITERATE_KEY
建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。 -
模拟可迭代协议, 返回带有
next
和[Symbol.iterator]
方法的对象。其中,next
处理的是键值对
可迭代协议: 在 [Symbol.iterator]
中部署了 Iterator
接口
{
[Symbol.iterator](){
next(){
……
}
}
}
迭代器协议: 对象中包含 next
方法
{
next(){
}
}
6.9 values
map.values()
与 map.entries()
处理逻辑类似, 细节如下:
-
通过代理对象
[raw]
得到原始set
、map
, 从原始set
、map
中通过set.values
或者map.values()
获取迭代器对象。for…of
与entries
是通过[Symbol.iterator]
获取迭代器对象的。 -
values
遍历set
、map
时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用reactive
转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现next
迭代器。 -
values
为遍历操作, 只与键值对的数量有关。 当values
被调用时, 我们应该让副作用函数与ITERATE_KEY
建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。 -
模拟可迭代协议, 返回带有
next
和[Symbol.iterator]
方法的对象。其中,next
方法中只处理值
6.10 keys
map.keys()
与 map.values()
处理逻辑类似, 细节如下:
-
通过代理对象
[raw]
得到原始set
、map
, 从原始set
、map
中通过set.keys()
或者map.keys()
获取迭代器对象。for…of
与entries
是通过[Symbol.iterator]
获取迭代器对象的。 -
keys
遍历set
、map
时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用reactive
转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现next
迭代器。 -
keys
为遍历操作, 只关心Map
数据的键的变化, 而不关心值的变化。 因此, 当keys
被调用时, 使用MAP_KEY_ITERATOR_KEY
与副作用函数建立联系。for…of
、entries()
、value()
方法关心的是键值对, 所以仍然使用ITERATE_KEY
。实现了依赖收集的分离。从而在使用map.keys()
方法时, 在键无变化时,避免不必要的更新。 -
模拟可迭代协议, 返回带有
next
和[Symbol.iterator]
方法的对象。其中,next
方法中只处理建