Vue3响应式个人理解(十)代理数组

文章参考了霍春阳的《Vue.js设计与实现》,是自己在阅读过程中的一些思考和理解
此篇文章紧接着上篇的《非原始值的响应式方案》

数组属于异质对象,因此普通的响应式是可以拦截到并且做出响应的。这里有部分是由于effect的单项数据绑定的原因吧,个人猜测。

const test = reactive(['www', 'asd'])
effect(() => {
  console.log(test[0])
})
// 修改数组的第一个元素会触发effect中的元素的重新执行
test[0] = 'asss'

但是对数组的操作,比如说,修改数组的长度、数组身上的栈方法、修改原数组的原型方法等等,这些操作也应该正确的建立其响应式才行。

数组的索引与length

上面通过访问单个数组索引的情况,是可以完成响应式的。但是这种方式与设置对象的属性值仍然存在根本上的不同。因为数组对象部署的内部方法[[DefineOwnProperty]]不同于常规对象。

虽然通过索引的方式会执行数组对象所部署的内部方法[[Set]],但是通过查阅ECMA规范,发现内部[[Set]]其实是依赖于[[DefineOwnProperty]]。规范中声明,当设置的索引值大于数组当前长度时,会将数组的长度设置为索引值+1。所以当通过索引设置属性值的时候,也可能会隐式的修改length的值,因此在触发响应时,也应该触发与length相关联的副作用函数。代码如下:

cosnt arr = reactive(['sss'])
effect(() => {
  console.log(arr.length)
})
// 当修改长度时,也应该触发关于length的副作用函数,也就是上面的函数
arr[1] = 'asd'

因此需要对set函数进行改进,新增对数组的判断,分别在set和trigger函数中。代码如下:

set() {
// 通过原型上的方法判断属性是否存在,如果存在则是修改,不存在则是新增
// 新增对数组类型的判断
const type = Array.isArray(target) ? Number(proxy) < target.length ? 'SET' : 'ADD'                      : Object.prototype.hasOwnProperty.call(target, proxy) ? 'SET' : 'ADD'
}
trigger() {
  // 新增数组判断,添加length属性对应的副作用函数
  if(type === TriggerType.ADD && Array.isArray(target)) {
    const lengthEffectfn = depsMap.get('length')
    lengthEffectfn && lengthEffectfn.forEach(fn => {
      if(fn !== activeEffect) {
        effectsToRun.add(fn)
      }
    })
  }
}

这是数据影响数组长度,反过来,数组长度也会影响数据。当把数组的长度设置为小于当前长度时,超出长度的数据就会被删除,这里也需要触发副作用函数。而设置的长度如果大于当前长度,则不会对数据产生影响,进而不需要触发副作用函数。代码如下:

set() {
  // 省略其他代码
  if(receiver.raw === target) {
    if( oldVal !== value && (oldVal === oldVal || value === value)) {
      // 新增第四个参数,要触发响应式的新值
      trigger(target, proxy, type, newVal)
    }
  }
}
trigger(target, proxy, type, newVal) {
    // 省略其他代码
    if(Array.isArray(target) && proxy === 'length') {
    // 对于索引大于或等于新的length的元素
    // 需要把其对应的副作用函数加入到effectsToRun中去执行
    depsMap.forEach((effects, key) => {
      if(key >= newVal) {
        effects.forEach(fn => {
          if(fn !== activeEffect) {
            effectsToRun.add(fn)
          }
        })
      }
    })
  }
}

这里需要理解。map对象的forEach循环第一个参数是value,第二个是key。其中数组的depsMap其key为索引,value为其对应的副作用函数Set。而在set函数中,对数组的length拦截,传入的newValue是新的长度。所以数组的长度发生了变化(这里跟上面的理解重了,导致卡了我半天,TNND)。且如果数组长度变短,则需要将多余部分元素的副作用函数都执行一遍。因为发生了变化,所以要触发其响应式。这里的变化,是将超出部分的值设置为了undefined,这是赋值操作,所以一定也必须走一次副作用函数,因为这是set操作。

遍历数组

数组同样是对象,因此可以用for…in进行遍历,但是却应该尽量要避免。回顾之前对普通对象的for…in循环拦截,用的是ownKeys函数,对数组同样适用。但是对数组而言,影响其长度的操作只有当长度增大或减少时才需要进行再次的遍历,因此需要在原本普通对象的基础上添加数组的标识。考虑到数组的变化都是length,结合上述的操作,因此数组的标识用length。代码如下:

ownKeys(target) {
  // 新增对数组的判断,如果是数组则用length进行标识,普通对象还是ITERATE_KEY
  track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

这里复用了上一个数组的操作。如果是增大数组长度,则会走ADD的数组操作,如果是长度减少,则会走length的数组操作。
接下来是for…of循环。需要注意的是for…of是用来遍历可迭代对象的。一个对象能否被迭代取决于该对象或者该对象的原型上是否实现了@@iterator方法,也就是Symbol.iterator(这个可以查看JS高级)。如果对象实现了这个方法,那么这个对象就是可迭代的。
通过查看ECMA规范,发现数组迭代器的实现会读取数组的length的属性,如果迭代的是数组的元素值还会读取数组的索引。简单代码实现如下:

const arr = [1,2,3,4,5]
arr[Symbol.iterator] = function() {
  // this指向arr
  const target = this
  const len = target.length
  let index = 0
  
  return {
    next() {
      return {
        value: index < len ? target[index] : undefined,
        done: index ++ >= len
      }
    }
  } 
}

通过上述代码可以发现,只需要中副作用函数与数组长度和索引值直接建立响应联系就能够实现响应式的for…of迭代。实际上,不需要新增任何代码就可以使得for…of的effect函数在数组发生长度变化或者是修改值的时候重新执行副作用函数,代码如下:

// 测试确实是可以的
const arr = reactive([1,2,3,4,5])
effet(() => {
  for(const key of arr) {
    console.log(key)
  }
})
// 长度修改
arr.length = 0
// 修改值
arr[1] = 123

其实,数组的value方法的返回值实际上就是数组内建的迭代器。
图片: https://cooper.didichuxing.com/cooper_gateway/cn/shimo-images/pYlBegNUbXQ6gxU5/image.png

但是,无论是for…of还是直接调用value方法,都是读取的Symbol.iterator属性,这是一个symbol值,考虑到性能和安全,都不应该让副作用函数与symbol建立响应联系。所以需要修改get函数。代码如下:

// 非只读情况且track的类型不是symbol时才添加响应式
if(!isReadonly && typeof proxy !== 'symbol') {
  track(target, proxy)
}

数组的查找方法

数组上的内部方法很多都依赖了对象的基本语义,因此大多数情况下,我们不需要做特殊处理就可以让这些方法按预期工作。代码如下:

const arr = reactive([1,2,3,4])
effect(() => {
  console.log(arr.includes[1])
})
// 这样也可以触发响应式
arr[0] = 3
但是这样的操作也不完全能起作用。代码如下:
const obj={}
const arr = reactive([obj])
// 输出了false,也就是说明它没有找到第一个元素,但是实际上是有是,只不过是一个空对象
console.log(arr.includes(arr[0]))

通过查看ECMA规范,根据includes方法的执行流程,发现includes方法会通过索引读取数组元素的值,但是此时执行includes的对象是arr,是一个代理对象。通过之前的理解,如果元素值仍然是可以被代理的话,得到的值就是新的代理对象而不是原来的普通对象。这段操作中get函数中可以看到:

//当前对象判断
if(typeof res === "object" && res !== null) {
  // 继续对对象进行响应式处理
  return isReadonly ? readonly(res) : reactive(res)
}

分析:includes方法会通过索引读取数组元素,而执行此方法的是arr,一个代理对象,所以当读取到obj时,发现是一个对象后会继续调用reactive函数创造新的代理对象。而通过arr[0]下标来读取元素时,也是一样的操作,发现读取元素时一个对象时,会继续调用reactive创造新的代理对象。但是includes和arr[0]都执行了reactive函数,而reactive函数每次都返回了一个新的代理对象,也就是说这两个读取所创造出来了两个新的代理对象,所以会返回false。解决方法:

// 深响应式
const reactiveMap = new Map()
function reactive(obj) {
  //在Map中查找是否已经创造过当前对象的响应式
  const existionProxy = reactiveMap.get(obj)
  // 如果已经存在了,则直接返回创造过的;否则去创造代理
  if(existionProxy) return existionProxy
  const proxy = createReactive(obj, false, false)
  // 存储创造过的映射
  reactiveMap.set(obj, proxy)
  // 返回创造的映射
  return proxy
}

这样的方法虽然可以解决上述问题,但是也有新的问题出现。代码如下:

const obj = {}
const arr = reactive([obj])
// 输出false
console.log(arr.includes(obj))

当传入一个原始对象进行查找时,依然会返回false。这里就很好理解了,includes方法遍历时,会创建代理对象,与原始对象相比较肯定是不一样的。因此需要重写includes方法。代码如下:

// 重写数组上的部分方法
const arrayInstrumentations = {
  includes: function(...args){
    // 由于是在reciver上调用的apply,因此this指向代理对象,args则是要查找的元素
    // 首先代理对象上查找是否存在
    let res = Array.prototype.includes.apply(this, args)
    // 如果没找到,则在原始数组上查找
    if(res === false) {
      res = Array.prototype.includes.apply(this.raw, args)
    }
    // 返回最后的结果
    return res
  }
}
// 如果操作的对象是数组,且key(操作的方法)存在于arrayInstrumentations上,
// 则返回定义在arrayInstrumentations上的值
if(Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
  // 传入操作方法和代理对象
  return Reflect.get(arrayInstrumentations, key, receiver)
}

除了includes方法之外,还需要做类似处理的数组方法还有indexof和lastIndexOf,因为这些都属于根据给的的值返回查找结果的方法。因此都可以适用这一套代码。完整代码如下:

// 数组部分功能重写
const arrayInstrumentations = {}

['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
  const originMethod = Array.prototype[method]
  arrayInstrumentations[method] = function (...args) {
    // 由于是在reciver上调用的apply,因此this指向代理对象,args则是要查找的元素
    // 首先代理对象上查找是否存在
    let res = originMethod.apply(this, args)
    // 如果没找到,则在原始数组上查找
    if(res === false) {
      res = originMethod.apply(this.raw, args)
    }
    // 返回最后的结果
    return res
  }
})

隐式修改数组长度的原型方法

隐式修改长度的方法主要指:push /pop /shift /unshift。除这些外,splice方法也会隐式地修改数组的长度。以push为例,查看ECMA规范,发现当调用数组的push方法向数组中添加元素时,既会读取数组的length属性,也会设置数组的length。这里有个bug,就是如果有两个独立的副作用函数同时调用push操作,会造成栈溢出。代码如下:

const arr = reactive([])

effect(() => {
  arr.push(1)
})

effect(() => {
  arr.push(2)
})

分析:当第一个副作用函数执行时,绑定了length与其的联系。第二个副作用函数执行时,读取了length的值且将其与自己绑定,且会修改length的值。此时,在trigger函数中,由于是ADD操作且是length,因此会将于length绑定的副作用函数全部执行一遍,因此在还未执行完第二个副作用函数时,就要执行第一个副作用函数。第二个函数执行完后执行到第一个函数又回修改length,又会执行第二个副作用函数。形成了循环,最后导致栈溢出。

问题的原因就是push操作会读取length,进而绑定联系。因此只要关闭这种绑定联系就可以了,同时push的操作本质上是对数组的增加,而不需要读取length,因此避免联系建立不会产生其他副作用函数。因此需要重写push方法。代码如下:

// 数组长度隐式修改方法重写
let shouldTrack = true    // 全局标记,决定是否启用track,在get中用到
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
  const originMethod = Array.prototype[method]
  arrayInstrumentations[method] = function(...args) {
    // 当进行操作时,设置为false,禁止建立联系
    shouldTrack = false
    // 执行操作
    let res = originalMethod.apply(this, args)
    // 操作完后,开启track,不影响其他数据的关系建立
    shouldTrack = true
    return res
  }
})
get() {
  // 禁止追踪时,直接返回
  if(!activeEffect || !shouldTrack) return 
}

你可能感兴趣的:(Vue,javascript,前端,开发语言,vue,vue.js)