为什么defineProperty不能检测到数组长度的“变化”

7,623 阅读7分钟

目录

  • 对象的属性类型
  • 数组长度与索引
  • vue对数组方法的hack

属性类型

我们知道对象是一个无序属性集合,创建一个包含属性的对象有3种方式:

  • 构造函数
  • 字面量
  • defineProperty
var object1 = new Object()
object1.name = 'a'

var object2 = {}
object2.name = 'b'

var object3 = {}
Object.defineProperty(object3, 'name', {
  enumerable: true,
  configurable: true,
  get() {
    return 'c'
  },
  set() {
    // do
  }
})

区别我们先讲完属性类型后再来看。

属性类型分为

  • 数据属性
  • 访问器属性

ECMA规范中定义放在2对方括号中的属性表示内部属性

相同点,都有

  • [[Configurable]] 字面理解是表示属性是否可配置——能否修改属性;能否通过delete删除属性;能否把属性修改为访问器属性。
  • [[Enumerable]]能否通过for-in循环返回该属性。

区别

  • 数据属性
    • [[Writable]]是否可写
    • [[Value]] 属性的值
  • 访问器属性
    • [[Get]]取值函数
    • [[Set]]赋值函数

接着来看属性创建的区别

  • 第1、第2种对于属性的赋值是一样的,不同的是创建对象的方式。在使用object.name赋值的时候,我们其实是对数据属性[[Value]]赋值,取值也是一样
  • 通过第3种创建的对象,在对object.name取值赋值时,是通过访问器属性的[[Get]][[Set]]函数

使用defineProperty注意点

// 假设我们想修改a的值为123
var object = { a: 1 }
Object.defineProperty(object, 'a', {
  enumerable: true,
  configurable: true,
  get() {
    // 不能在函数中引用属性a,否则会造成循环引用
    // 错误
    return this.a + '23'
    // 正确
    return val + '23'
  },
  set(newVal) {
    // 为了在原属性值的基础上修改属性,我们可以利用闭包的特性
    // 在初始化对象的时候会调用set函数,此时将属性(例如a)的值用闭包保存起来
    // 接着取值的时候,就利用闭包中变量的值修改即可
    val = newVal
  }
})
// 其实也就是一个先赋值再取值修改的过程

以上有感于vue早期源码学习系列之一:如何监听一个对象的变化

数组长度与索引

我们知道vue对于监测数组的变化重写了数组的原型以达到目的,原因是defineProperty不能检测到数组长度的变化,准确的说是通过改变length而增加的长度不能监测到。

我们需要理解2个概念,即数组长度与数组索引

数组的length属性,被初始化为

enumberable: false
configurable: false
writable: true

也就是说,试图去删除和修改(并非赋值)length属性是行不通的。

5b0c02cfac502e0062ea9d9d

数组索引是访问数组值的一种方式,如果拿它和对象来比较,索引就是数组的属性key,它与length是2个不同的概念。

var a = [a, b, c]
a.length = 10
// 只是显示的给length赋值,索引3-9的对应的value也会赋值undefined
// 但是索引3-9的key都是没有值的
// 我们可以用for-in打印,只会打印0,1,2
for (var key in a) {
  console.log(key) // 0,1,2
}

当我们给数组push值后,会给length赋值

length 和数字下标之间的关系 —— JavaScript 数组的 length 属性和其数字下标之间有着紧密的联系。数组内置的几个方法(例如 join、slice、indexOf 等)都会考虑 length 的值。另外还有一些方法(例如 push、splice 等)还会改变 length 的值。

这几个内置的方法在操作数组时,都会改变length的值,分2种情况

  • 减少值
    • 当我们shift一个数组时,你会发现它会遍历数组(下面有代码印证),此时数组的索引对应的值得到了相应的更新,这种情况下defineProperty是可以监测到的,因为有属性(索引)存在。
  • 增加值
    • push值时,此时数组的长度会+1,索引也会+1,但是此时的索引是新增的,虽然defineProperty不能监测到新增的属性,但是在vue中,新增的对象属性可以显示的调用vm.$set来添加监听
    • 手动赋值length为一个更大的值,此时长度会更新,但是对应的索引不会被赋值,也就是对象的属性没有,defineProperty再牛逼也没办法处理对未知属性的监听。

验证数组的几个内部方法对索引的影响

// 还是老套路,定义一个observe方法
function defineReactive(data, key, val) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
     get: function defineGet() {
      console.log(`get key: ${key} val: ${val}`)
      return val
    },
     set: function defineSet(newVal) {
      console.log(`set key: ${key} val: ${newVal}`)
      // 还记得我们上面讨论的闭包么
      // 此处将新的值赋给val,保存在内存中,从而达到赋值的效果
      val = newVal
    }
  })
}
function observe(data) {
  Object.keys(data).forEach(function(key) {
    defineReactive(data, key, data[key])
  })
}

let test = [1, 2, 3]
// 初始化
observe(test)

console.log时,你会发现在打印的过程中是遍历这个数组的

5b0cac2cd50eee008930bc0d

打印的过程可以理解为

  • 找到test变量指向的内存位置为一个数组,长度为3并打印,但并不知道索引对应的值是多少
  • 遍历索引

接下来我们做如下操作

5b0cae8efb4ffe005b06d343

  • push时,新增了索引并改变了长度,但新的索引未被observe
  • 修改新的索引对应的值
  • 弹出新的索引对应的值
  • 弹出索引被observe的值时,触发了get
  • 此时再去给原索引赋值时,发现并没有触发被observe的set,由此可见数组索引被删除后就不会被observe到了,那对象的属性是否也是一样的呢?如下图可见也是一样的
    5b0cb0772f301e0038b29fd2
  • 修改索引为1的值,触发了set
  • unshift时,会将索引为0和1的值遍历出来存放,然后重新赋值

当我们给length赋值时,可以看见并不会遍历数组去赋值索引。

5b0cb00f9f54540043d30ab7

小结
对于defineProperty来说,处理数组与对象是一视同仁的,只是在初始化时去改写getset达到监测数组或对象的变化,对于新增的属性,需要手动再初始化。对于数组来说,只不过特别了点,push、unshift值也会新增索引,对于新增的索引也是可以添加observe从而达到监听的效果;pop、shift值会删除更新索引,也会触发defineProperty的get和set。对于重新赋值length的数组,不会新增索引,因为不清楚新增的索引有多少,根据ecma规范定义,索引的最大值为2^32 - 1,不可能循环去赋值索引的。

以上参考

引发我对这个问题的思考是

对我有所帮助是知乎@liuqipeng的回答

vue对数组方法的hack

vue对数组的observe单独做了处理

if (Array.isArray(value)) {
  const augment = hasProto
    ? protoAugment
    : copyAugment
  // 判断数组实例是否有__proto__属性,有就用protoAugment
  // 而protoAugment司机就是重写实例的__proto__
  // target.__proto__ = src
  // 将新的arrayMethods重写到value上
  augment(value, arrayMethods, arrayKeys)
  // 然后初始化observe已存在索引的值
  this.observeArray(value)
} else {
  this.walk(value)
}

再来看如何重写的arrayMethods,在array.js中,我们可以看到

const arrayProto = Array.prototype
// 复制了数组构造函数的原型
// 这里需要注意的是数组构造函数的原型也是个数组
// 实例中指向原型的指针__proto__也是个数组
// 数组并没有索引,因为length = 0
// 相反的拥有属性,属性名为数组方法,值为对应的函数
export const arrayMethods = Object.create(arrayProto)

// 对以下方法重写
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

如下图,当我给__proto__索引为0赋值时,是正常的,但是其余的属性依旧在后面。我们可以这样认为,数组的构造函数的原型是个空数组,但是默认给你内置了几个方法。

5b0cfd0367f356003b7ae87c

我们再来看为什么只对这些方法重写?

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  // 这里的def很重要,其实也就是用object.defineProperty重新定义属性
  // 但这里的arrayMethods是个数组,这就是为什么上面我们解释
  // 数组构造函数原型是个空数组但是默认了属性方法
  // 所以这里的定义是很巧妙的
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    // ob就是observe实例
    const ob = this.__ob__
    let inserted
    switch (method) {
      // 为什么对push和unshift单独处理?
      // 我们在上看解释过,这2中方法会增加数组的索引,但是新增的索引位需要手动observe的
      case 'push':
      case 'unshift':
        inserted = args
        break
      // 同理,splice的第三个参数,为新增的值,也需要手动observe
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // 其余的方法都是在原有的索引上更新,初始化的时候已经observe过了
    if (inserted) ob.observeArray(inserted)
    // notify change
    // 然后通知所有的订阅者触发回调
    ob.dep.notify()
    return result
  })
})

最后,还是贴一波博客地址为什么defineProperty不能检测到数组长度的“变化”