跳到主要内容

认识

2024年03月13日
柏拉文
越努力,越幸运

一、认识


双向数据绑定 基于 MVVM 模型, 包括数据层Model视图层View业务逻辑层ViewModel。其中 ViewModel(业务逻辑层) 负责将数据和视图关联起来, 提供了数据变化后更新视图视图变化后更新数据这样一个功能,就是传统意义上的双向绑定。

Vue.js 3.0 reactive 对非原始值 ObjectArraySetMap 基于 Proxy 实现响应式。通过 Proxy 监听整个对象, 实现双向绑定(响应式)。监听策略如下:

  1. 通过 get 处理器监听属性的访问, 收集对应属性相关副作用

  2. 通过 set 处理器监听属性的变更, 触发对应属性相关副作用函数执行

  3. 通过 has 处理器来监听 xx in yy 操作符, 收集对应属性相关副作用

  4. 通过 ownKeys 处理器来监听 for infor of 遍历操作, 收集 ITERATE_KEY 相关的副作用。因为 ownKeys 只有一个 target 参数, 没有 key, 对于对象来说需要一个 ITERATE_KEY 来充当 key, 后续之后新增属性时, 才会触发副作用函数执行, 已有属性不触发。对于数组而言, 无论是为数组添加新元素还是直接修改数组的长度, 本质上都是因为修改了数组的 length 属性。 一旦数组的 length 被修改, 那么 for…infor……of 循环对数组的遍历结果就会改变。

  5. 通过 deleteProperty 处理器来监听属性删除操作, 触发对应属性副作用函数重新执行

  6. 对于 array.includes() array.indexOf() array.lastIndexOf() 查找类型的数组 API, 查找的目标元素有可能是代理数据, 有可能是原始数据, 但是 array 肯定是代理数据, 所以需要对查找类型的 API 重写, 添加代理数组与原始数组的映射关系, 之后优先在代理数组中查找, 如果找不到再去原始数组中查找。对于 array.push() array.push() array.shift() array.unshift() array.splice() 会读取数组中的 length 属性,也会设置数组中的 length 属性, 这会导致两个独立的副作用函数相互影响。只要屏蔽了对 length 属性的读取, 从而避免在它与副作用函数之间建立响应联系。具体策略: 重写array.push() array.push() array.shift() array.unshift() array.splice() 方法, 在调用原始方法之前, 停止追踪, 调用原始方法之后, 继续追踪

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);
}
});
}

二、变量细节


2.1 Proxy

reactive 的实现原理如下:

const obj = reactive({});

function reactive(target){
const baseHandlers = {
get: ()=>{

},
set: ()=>{

}
}
return new Proxy(target,baseHandlers);
}

我们可以知道, reactive 本质上其实就是一个 Proxy 代理对象, Proxy 只接收引用类型数据, 传入基础数据会报错。因此, reactive 也只可以接收引用数据类型

Proxy 的局限性如下:

  1. Proxy 只可以代理引用类型数据

  2. Proxy 代理对象解构之后的属性将不会触发 handler

2.2 WeakMap

弱引用 不会影响垃圾回收机制。WeakMap弱引用,如下所示 当 obj 置为 null 时,WeakMap 键没有对 obj 的地址保持引用,所以会触发垃圾回收机制回收

2.3 targetMap

存储结构

WeakMap: {
key: 响应性对象,
value: Map 对象
{
key: 响应性对象指定属性,
value: ReactiveEffect 实例的 Set 集合
}
}

2.4 reactiveMap

reactiveMapconst 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) 来拦截, 有以下几点细节:

  1. 通过 receiver.raw 记录此时的 target, 如果访问 .raw 时, 直接返回 target

  2. 使用 for……of 或者 values() 方法, 都会读取 target.[Symbol.iterator] 属性, 为了避免发生意外的错误以及性能上的考虑, 不应该在副作用函数与 target[Symbol.iterator] 之间建立响应联系, 所以在调用 track 函数进行追踪之前, 需要加一个判断条件, 即只有当 key 的类型不是 symbol 时才进行追踪。

  3. 调用track收集当前key相关的副作用函数

  4. 如果target[key]为对象, 继续为 target[key] 执行 reactive(target[key]) 递归响应式。

4.2 object[xx] = yy

object[xx] = yy 调用 set(target,key,value,receiver) 进行拦截, 有以下几点细节:

  1. 处理 value , 保证 value 为原始数据。 target 为原始数据, 如果 value 为响应式数据, 那么会使原始数据拥有响应式的能力, 造成了数据污染。将响应式数据设置到原始数据上的行为称为数据污染

  2. 根据是否存在 key 值, 得到此时的操作类型为 TriggerType.ADD 还是 TriggerType.SET, 如果操作类型为 TriggerType.ADD , 后续会触发与 ITERATE_KEY 相关的副作用函数。

  3. 需要判断此时的 target 是否为此时的 receiver 中记录的 raw, 只有 target === receiver.raw 才会进行后续的 trigger 触发重新执行副作用函数的操作。 这样针对 A 响应式数据的原型是 B响应式数据, 更新 A 中的 B时, 会有重复执行多次副作用函数的问题。这个判断很好的屏蔽了原型链引起的更新问题。

  4. 需要判断此时的新值是否发生过变化, 判断的逻辑其实是一个 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_KEYkey 收集副作用函数的。

trackfor … in object 的副作用函数收集到了 ITERATE_KEY 的一个唯一标识 key 中, 那么在 trigger 触发的时候, 对于 object 中的属性触发有如下策略:

  • **key**是已有属性: 那么 ITERATE_KEY 对应的副作用函数不需要重新执行, 这样的话避免了不必要的性能损耗

  • **key**是新增属性: 那么 ITERATE_KEY 对应的副作用函数会重新执行

  • 通过 delete 触发: 删除操作会使 objkey 减少, 它会影响 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)**有如下两种特殊场景需要处理:

  1. x 同样为 reactive 数据: 我们需要定义 reactiveMap 存储原始对象到代理对象的映射。在每次调用 reactive 函数创建代理对象之前, 优先检查是否已经存在相应的代理对象, 如果存在, 则直接返回已有的代理对象, 这样就避免了同一个原始对象多次创建代理对象的问题。

  2. 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.sizegetter 函数在执行时, 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 实现响应式有以下细节:

  1. 遍历操作只与键值对的数量有关, 当 forEach 被调用时, 我们应该让副作用函数与 ITERATE_KEY 建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。

  2. map.forEach(callback) 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用 reactive 转换为响应式数据

6.7 for…of

for…of 遍历 set 或者 map 代理对象, 内部会试图从代理对象读取 代理对象[Symbol.iterator]Iterator 接口, 这个操作会触发 get 拦截器, 因此, 我们可以在 get 拦截器中重写 [Symbol.iterator], 代理对象就可以拥有 for…of 的遍历能力。细节如下:

  1. 通过代理对象[raw] 得到原始 setmap, 从原始 setmap 中通过 [Symbol.iterator] 获取迭代器对象

  2. for…of 遍历 setmap 时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用 reactive 转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现 next 迭代器。

  3. for…of 为遍历操作, 只与键值对的数量有关。 当 for…of 被调用时, 我们应该让副作用函数与 ITERATE_KEY 建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。

  4. 模拟迭代器, 返回带有 next 方法的对象。next 处理的是键值对

6.8 entries

entries 的遍历与 for…of 同理。 因此, entries 的重写逻辑与 for…of 是一样的, 但是也有一些小小的区别: for…of 需要代理对象带有 next 方法, 而 entries 需要代理对象带有 [Symbol.iterator] 方法。for…of 的实现需要有 next 方法,叫做迭代器协议; entries 的实现需要 Symbol[iterator] 方法, 叫做 可迭代协议。处理细节如下:

  1. 通过代理对象[raw] 得到原始 setmap, 从原始 setmap 中通过 [Symbol.iterator] 获取迭代器对象

  2. entries 遍历 setmap 时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用 reactive 转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现 next 迭代器。

  3. entries 为遍历操作, 只与键值对的数量有关。 当 for…of 被调用时, 我们应该让副作用函数与 ITERATE_KEY 建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。

  4. 模拟可迭代协议, 返回带有 next[Symbol.iterator] 方法的对象。其中, next 处理的是键值对

可迭代协议: 在 [Symbol.iterator] 中部署了 Iterator 接口

{   
[Symbol.iterator](){
next(){
……
}
}
}

迭代器协议: 对象中包含 next 方法

{
next(){

}
}

6.9 values

map.values()map.entries() 处理逻辑类似, 细节如下:

  1. 通过代理对象[raw] 得到原始 setmap, 从原始 setmap 中通过 set.values 或者 map.values() 获取迭代器对象。for…ofentries 是通过 [Symbol.iterator] 获取迭代器对象的。

  2. values 遍历 setmap 时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用 reactive 转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现 next 迭代器。

  3. values 为遍历操作, 只与键值对的数量有关。 当 values 被调用时, 我们应该让副作用函数与 ITERATE_KEY 建立响应式联系。所以, 任何修改值或者键值对数量的操作都应该触发到副作用函数重新执行。

  4. 模拟可迭代协议, 返回带有 next[Symbol.iterator] 方法的对象。其中, next 方法中只处理值

6.10 keys

map.keys()map.values() 处理逻辑类似, 细节如下:

  1. 通过代理对象[raw] 得到原始 setmap, 从原始 setmap 中通过 set.keys() 或者 map.keys() 获取迭代器对象。for…ofentries 是通过 [Symbol.iterator] 获取迭代器对象的。

  2. keys 遍历 setmap 时, 要对迭代中产生的值进行判断, 如果迭代产生的值为对象, 需要进一步调用 reactive 转换为响应式数据。为了实现对迭代产生的值进行响应式包装, 需要自定义实现 next 迭代器。

  3. keys 为遍历操作, 只关心 Map 数据的键的变化, 而不关心值的变化。 因此, 当 keys 被调用时, 使用 MAP_KEY_ITERATOR_KEY 与副作用函数建立联系。for…ofentries()value() 方法关心的是键值对, 所以仍然使用 ITERATE_KEY。实现了依赖收集的分离。从而在使用 map.keys() 方法时, 在键无变化时,避免不必要的更新。

  4. 模拟可迭代协议, 返回带有 next[Symbol.iterator] 方法的对象。其中, next 方法中只处理建