Vue3响应式个人理解(九)非原始值的响应式方案

文章参考了霍春阳的《Vue.js设计与实现》,是自己在阅读过程中的一些思考和理解

对Proxy和Reflect的理解

proxy只能代理对一个对象的基本语义,也就是允许拦截并重新定义一个对象的基本操作。单个数据例如字符串,布尔值是无法代理的。

// 基本代理
const p = new Proxy(obj, {get(){}, set(){}})
同样,函数也可以是对象。因为对函数的调用也属于基本操作。但是拦截操作不一样。
const fn = () => {console.log('233333')}
const p = new Proxy(fn, {
  apply(target, thisArg, argArray) {
    target.call(thisArg, ...argArray)
  }
})
p() // 输出'23333'

非基本操作中典型的操作为对象上的方法调用(obj.fn())。此操作称为复合操作。因为首先要通过ge操作找到对象上的这个方法,然后再通过apply调用,相当于进行了两个基本操作。
现在来考虑一个新的问题,代码如下:

const obj = {
  foo: 1,
  get bar() {
    return this.foo
  }
}
const proxy = new Proxy(obj, {
  get(target, key, reciver) {
    track(target, key)
    return target[key]
  },
  set(target, key, value, reciver) {
    target[key] = value
    trigger(target, key, value)
  }
})
effect(() => {
  console.log(proxy.bar) // 1
})

分析:我们通过effect函数,绑定了bar和当前副作用函数。因此在读取bar时,触发get操作,进而触发副作用函数,因此执行了bar的函数,得到了foo的值。

但是当修改foo的值时,会发现并没有重新执行bar函数去获取最新的foo。这里的原因是我们在get函数中,返回的是target[key]。target是原始obj,key是参数bar。所以返回的是obj.bar。因此obj中的this指向的是当前的调用对象obj。则effect中的函数可以简化为

effect(() => obj.foo)

也就是说并没有读取代理上的值,也就没有建立foo与副作用函数的联系,则修改foo并不会触发get函数和set函数的重新执行。进而无法触发bar的重新读取。
改进操作:

const proxy = new Proxy(obj, {
  get(target, key, reciver) {
    track(target, key)
    // 这里将返回对象改为Reflect
    return Reflect.get(target, key, reciver)
  },
  set(target, key, value, reciver) {
    target[key] = value
    trigger(target, key, value)
  }
})
effect(() => console.log(proxy.bar))

get函数第三个参数为proxy。Reflect.get函数的第三个参数为接收者,代表get操作将在接收者上进行。

分析:同样读取proxy.bar,触发get操作,返回的是Reflect的get操作,这里将要读取的对象设置为了proxy,因此返回的就不再是obj.foo,而是proxy.foo。这样就能建立foo与副作用函数之间的联系,使得修改foo能触发set和get操作,进而再次触发bar的副作用函数,重新读取一次foo的值,实现响应式。

JS对象和Proxy简单工作原理

JS对象分为了常规对象和异质对象。具体区别见ECMA,这里不多赘述。由于Proxy对象的内部方法[[Get]]没有使用ECMA规范的10.1.8节中给出的定义实现,因此Proxy是一个异质对象。当通过代理对象访问属性值时:

const proxy = new Proxy(obj, {})
proxy.value

实际上,JS引擎会调用部署中对象proxy的内部方法。而代理对象和普通对象的区别就在于内部的[[Get]]方法是实现。当没有给Proxy指定get方法时,通过代理访问属性时,代理对象的内部方法[[Get]]会调用原始对象的内部方法[[Get]]来获取属性值。这里的逻辑是:并不是代理对象执行了自己定义的get函数,而是自己重写了内部的[[Get]]方法,因为如果没有写get函数的话,仍然执行的是原始对象的[[Get]],这里体现了内部方法的多态性。这里需要注意的是如果要删除属性,使用代理对象拦截删除操作时,要使用Reflect去删除原始对象中的属性,而不能删除代理对象上的属性,不然相当于没有删除。

Object的代理

对象上的普通属性读取可以通过代理对象的get和set进行拦截。现在考虑如何代理in操作符。

第一个:key: key in obj。

通过中ECMA-262规范的13.10.1节中,发现in操作符的运算结果是通过调用HasProperty的抽象方法得到。在Reflect的表中可以发现其对应的拦截函数为has。因此可以通过has函数实现对in操作符的代理。代码如下:

const obj = {
  foo: 1
}
const proxy = new Proxy(obj, {
  has(target, key){
    track(target, key)
    return Reflect.has(target, key)
  }
})

第二个:for i in obj。

通过查看ECMA的14.7.5.6节中的定义,找到关键方法EnumerateObjectProperties(obj)。在该方法的实现中,有如下的一段代码:

for(const key of Reflect.ownKeys(obj)) {
  if(typeof key === 'symbol') continue
  // 属性的描述器包括value,writable, enumerable,configurable。这些属性可以在Object.defineProperty里面定义的时候设置。
  const desc = Reflect.getOwnPropertyDescriptor(obj, key)
  if(desc) {
    visited.add(key)
    // 如果可枚举,则将作为迭代对象
    if(desc.enumerable) yield key
  }
}

遍历的obj就是我们for…in的对象,而他通过Reflect.ownKeys()来遍历此对象。因此可以考虑用ownKeys来拦截此操作。代码如下:

const obj = {
  foo: 1
}
// 一个Symbol对象来作为当前属性的标识
const ITERATE_KEY = Symbol()
const proxy = new Proxy(obj, {
  ownKeys(target) {
    track(target, ITERATE_KEY)
    return Reflect.ownKeys(target)
  }
})

可以发现用了一个Symbol对象来传入track函数,因为ownKeys只有一个参数,就是当前对象,所以用一个Symbol对象来与当前对象的副作用函数进行唯一绑定,然后Reflect.ownKeys仍然还是传入当前对象(因为也只能传一个参数…)。

但是啊,由于进行的操作是将Symbol对象与当前对象进行的绑定,众所周知,在JS中对静态对象的属性更改是默认对对象没有更改操作的。所以此时,如果对对象进行属性的添加或者修改,是没有办法触发对象与Symbol绑定的副作用函数的。因为set函数中接受的是target和key,所以操作是在key上进行的,不是在target上进行的。(这里我居然还在想为什么没有触发get,tnnd,for…in的操作是在ownKeys上的,肯定没有触发get啊,我是sb)
解决方法:

// 获取与ITERATE_KEY相关联的副作用函数
  const iterateEffects = depsMap.get(ITERATE_KEY)
  // 加到effectsToRun中进行后续执行
  iterateEffects & iterateEffects.forEach(effect => {
   // 防止死循环
    if(effect != activeEffect) {
      effectsToRun.add(effect)
    }
  })

这段代码,无论是添加新属性还是修改属性,都会触发iterateEffects副作用函数队列。但是考虑到修改属性的值对于for…in循环来说,都只会循环一次,因为这里循环的是key,而不是value,has函数返回的是对象中的key,不是value!所以这里不论如何修改key的value值,对于for…in循环来说,是没有变化的,因为输出的是key的列表。但是由于修改value,会触发set方法,导致iterateEffects集合执行,所以需要通过判断来规避由于修改而产生的不必要的for…in循环。代码如下:

set(target, proxy, value, reciver) {
  // 原对象中修改要变的值
  target[proxy] = value
  // 通过原型上的方法判断属性是否存在,如果存在则是修改,不存在则是新增
  const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
  // 属性值通过Reflect.set确定
  const res = Reflect.set(target, proxy, value, reciver)
  // 封装出去了
  trigger(target, proxy, value, reciver, type)
  // effects && effects.forEach(fn => fn())
  return res
},
// 枚举集合
const TriggerType = {
  SET: 'SET',
  ADD: 'ADD'
}
// trigger中改变为

// 只有当为新增操作时,才执行迭代器的副作用函数
if(type === TriggerType.ADD) {
  // 获取与ITERATE_KEY相关联的副作用函数
  const iterateEffects = depsMap.get(ITERATE_KEY)
  iterateEffects & iterateEffects.forEach(effect => {
    if(effect != activeEffect) {
      effectsToRun.add(effect)
    }
  })
}

添加属性值判断,来决定是否要执行迭代器的副作用函数。

最后是对删除操作的代理。

在ECMA13.5.1.2节中定义了delete操作符的行为,可以看到其依赖于[[Delete]]内部方法,接着对照Reflect表,可以发现其对应于deleteProperty函数。因此可以在Proxy用其进行拦截。代码如下:

deleteProperty(target, key) {
  // 判断属性是否属于当前对象
  const isHave = Object.prototype.hasOwnProperty.call(target, key)
  // 通过Reflect进行删除
  const res = Reflect.deleteProperty(target, key)
  // 如果属于当前对象且已经删除成功,则触发trigger,重新执行一次循环,因为此时key减少了
  if(isHave && res) {
    trigger(target, key, 'DELETE')
  }
}
// 在trigger中新增'DELETE'判断
// 只有当为新增或删除操作时,才执行迭代器的副作用函数
if(type === TriggerType.ADD || type === TriggerType.DELETE) {
  // 获取与ITERATE_KEY相关联的副作用函数
  const iterateEffects = depsMap.get(ITERATE_KEY)
  iterateEffects & iterateEffects.forEach(effect => {
    if(effect != activeEffect) {
      effectsToRun.add(effect)
    }
  })
}

合理地触发响应式

1.对属性赋予相同的值时,不应该触发响应式

比如:当前的obj.value = 1,然后再次执行obj.value = 1。此时不应该触发响应式。因此需要在set方法中进行添加判断。

const oldVal = target[proxy]
// 封装出去了,添加新旧判断
if( oldVal !== value) {
  trigger(target, proxy, type)
}

虽然可以完成大部分的判断,但是存在的缺陷是NAN类型。众所周知,NAN与NAN相比较是会返回false,所以二者总是不相等的。改进后代码:

// 封装出去了,添加新旧判断
if( oldVal !== value && (oldVal === oldVal || value === value)) {
  trigger(target, proxy, type)
}

2.原型继承上的问题

简单封一个reactive函数表达式:

function reactive(obj) {
  return new Proxy(obj, {
    /* 省略一大堆函数 */
  })
}
const obj = {}
const proto = {bar: 1}
// 就是返回了一个代理对象
const child = reactive(obj)
const parent = reactive(proto)
// 将child的原型设置为parent
Object.setPrototypeOf(child, parent)

effect(() => {
  console.log(child.bar)
})  
// 修改bar的值,会发现执行了两次副作用函数
child.bar = 2

过程分析:首先在child对象上读取bar属性,触发代理的get拦截操作,然后child上面是没有bar属性的:

Reflect.get(obj, 'bar', child)

通过查看ECMA-10.1.8.1节,发现如果属性为undefined,那么就会获取对象的原型,并调用原型的[[Get]]方法来得到最终的结果。因此在child上没有bar属性就会去parent上获取。因此在这个过程中,child和parent的属性集合都搜集了该副作用函数。

再来看set操作。当企图向child代理对象上设置bar时,会被其set函数拦截。通过查看ECMA-10.1.9.2节可知如果对象上属性为undefined,也会向原型对象上获取,并调用原型的[[Set]]方法。由于parent也是代理对象,所以也执行了set拦截函数,因为之前都收集了副作用函数,又都执行set拦截函数,所以导致了副作用函数执行了两次。

解决办法就是将第二次的副作用函数执行给屏蔽掉,也就是需要在set拦截函数中进行第二次的屏蔽。通过在set函数中输出receiver,发现两次的输出都是child的代理对象,也是可以理解的,虽然执行了的两次副作用函数来自不同的对象,但是仍然是在child的基础上向原型链中查找的。所以这里发现两次的set执行,原始target不同,但是reciver是同一个。所以只需要判断当前的代理对象reciver是当前的target的代理对象就可以了。代码如下:

// 添加判断,判断当前的代理对象是不是当前原对象的代理
// 通过代理对象上的raw属性判断
if(reciver.raw === target) {
  // 新旧值判断,同时排除NAN
  if( oldVal !== value && (oldVal === oldVal || value === value)) {
    trigger(target, proxy, type)
  }
}

浅响应式与深响应

这个概念比较简单。代码如下:

// 简单的reactive函数实现 
function reactive(obj) {
  return new Proxy(obj, {/* 方法省略 */})
}
// 传入一个双层对象
const active = reactive({foo: {bar: 1}})

考虑之前的Proxy的get函数。返回的是:

Reflect.get(target, proxy, reciver)

很明显,只有单层的数据返回,这里传入的{foo: {bar: 1}},在get中返回的是{bar:1},所以如果修改obj.foo.bar的值,并不会触发响应式,因为对象里面的属性值修改不算对象改变。但是如果直接修改obj.foo,是可以触发响应式的,因此这里是第一层数据。
所以需要对get函数进行修改,代码如下:

//当前对象判断
if(typeof res === "object" && res !== null) {
  // 继续对对象进行响应式处理
  return reactive(res)  // <--为对象里面继续添加响应式
}

但是有时候,却不希望是深层的响应式,而只需要第一层。因此就产生了shallowReactive,也就是浅响应式。代码如下:

function createReactive(obj, isShallow = false) {
  return new Proxy(obj, {
    get(target, key, reciver) 
      const res = Reflect.get(target, key, reciver)
      track(target, key) 
      // 浅响应式直接返回
      if(isShallow) {
        return res
      }
      // 深响应式继续对对象做深入的响应式
      if(typeof res === 'object' && res !== null) {
        return reactive(res)
      }
      return res
    }
  })
}
// ractive深层响应式方法
function reactive(obj) {
  return createReactive(obj)
}
// shallow浅层响应式方法,传入true
function shallowReactive(obj) {
  return createReactive(obj, true)
}

只读和浅只读

当数据是只读的时候,如果用户尝试去修改或删除时,都应该弹出警告且拒绝该操作。因此需要对set和delete代理进行修改。代码如下:

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    get(target, key, reciver) 
      const res = Reflect.get(target, key, reciver)
      track(target, key) 
      // 浅响应式直接返回
      if(isShallow) {
        return res
      }
      // 深响应式继续对对象做深入的响应式
      if(typeof res === 'object' && res !== null) {
        return reactive(res)
      }
      return res
    },
    set(target, key, value, reciver) {
      /* 省略其他操作, 只加入只读的判断 */
      if(isReadonly) {
        console.warn(`属性${key}是只读的`)
        return true
      }
    },
    deleteProperty(targrt, key) {
      /* 省略其他操作, 只加入只读的判断 */
      if(isReadonly) {
        console.warn(`属性${key}是只读的`)
        return true
      }
    }
  })
}

除了不能修改和删除外,由于数据是只读的,所以也不需要为其添加响应式。因此get函数也需要一点判断,避免不必要的逻辑。代码如下:

get(target, key, reciver) {
  // 同样当不是只读的情况下才为其添加响应式
  if(!isReadonly) {
    track(target, key)
  }
}

新增readonly函数,以及其他函数的修改。代码如下:

function readonly(obj) {
  return createReactive(obj, false, true)
}

这里只做到了浅只读,因为第二个参数为false,深度的对象依然会添加响应式。所以需要在get函数中添加判断,如果是只读,那么应该调用readonly函数而不是reactive函数。代码如下:

get() {
  if(typeof res === 'object' && res !== null) {
    // 如果是只读,那么调用readonly函数
    return isReadonly ? readonly(res) : reactive(res)
  }
}

在浅只读中,只需要添加第三个参数就可以了。代码如下:

function shallowReactive(obj) {
  // 因为浅层本身就只有一层,所以不需要深度判断
  return createReactive(obj, true, true)
}

截止到现在,大部分的功能都测试过,但是难免有部分功能与Vue.js不同,比如watch一个代理对象,深度数据监视不到、传的oldval和newval一致,这里可能有时间差的存在等等。在所难免,因为这是简化了很多后的代码。只要原理能理解好,后续看源代码的时候有很大的帮助。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值