昨天老袁面腾讯的时候被面试官从 Vue 的数据劫持跳转到这么个问题:为什么Object.defineProperty不能检测到数组长度的变化???
老袁面完后看起来很难过。我其实心里对这个问题也很纳闷:
改变对象属性能检测到嘛?
由于数组属于引用类型,所以其本质上还是属于对象的。我们来看看对对象进行数据劫持后改变其子属性会有怎样的反应
1 | var obj = { |
咋样咋样,瞅着没,一个鸡巴德行!我子属性变化它是检测不到的!
那为什么检测不到呢?有以下两种可能
- 对象和数组作为引用类型之所以无法被检测到是因为我们存储在栈区的只是一个指向堆区的指针,数据的改变不会引起指向其指针的变化,所以无法被
Object.defineProperty
- 对象和属性变化时分几种情况,当新增数据时由于属性名(索引)增加而无法被
Object.defineProperty
检测到所以无法通过Objcet.defineProperty
监测数组变化。
到底是哪种呢?
铺垫
属性类型
属性分为两种类型:数据属性 & 访问器属性
数据属性
[[Configurable]]
:是否可配置,- 能否通过
delete
删除属性 - 能否修改属性
- 能否把属性修改为访问器属性
- 能否通过
[[Enumerable]]
:能否通过for-in
循环返回该属性[[Get]]
:取值[[Set]]
:赋值
访问器属性
[[Configurable]]
:是否可配置,能否通过delete
删除属性。- 能否通过
delete
删除属性 - 能否修改属性
- 能否把属性修改为访问器属性
- 能否通过
[[Enumerable]]
:能否通过for-in
循环返回该属性[[Writable]]
:是否可写[[Value]]
:属性的值
属性创建的区别
我们平时通过obj.attributeName
取值赋值时实际是修改其[[Value]]
属性。而我们通过Object.defineProperty
方式定义的属性对其通过[[Get]]
和[[Set]]
函数进行读写。
数组长度 & 索引
之所以我们无法通过[[Get]]
和[[Set]]
得知数组的更改,原因正是类似于上述的对象一般,Object.defineProperty
无法检测到数组长度的变化。准确的说是无法检测到通过改变length
而增加的长度
我们将数组的length
属性初始化为:
1 | enumberable: false |
即,无法删除和修改(并非赋值)length
属性
1 | Object.defineProperty(arr, 'length', { set(){}}) |
而数组索引则是访问数组值的一种方式。若拿它与对象相比较,索引就是数组属性的key
,它与length
是2个不同的概念
1 | var a = [a, b, c] |
JavaScript 数组的 length 属性和其数字下标之间有着紧密的联系。数组内置的几个方法(例如 join、slice、indexOf 等)都会考虑 length 的值。另外还有一些方法(例如 push、splice 等)还会改变 length 的值。
这些内置方法再操作数组时出去改变其中的内容还会影响length
的值。分为两种情况
减少值
- 当我们
shift
一个数组时你会发现它会遍历数组。此时数组的索引对应的值得到了相应的更新。这种情况可以被Object.defineProperty
检测到,因为有属性(索引)的存在。
- 当我们
增加值
push
值时,数组的长度会增加1,索引也会增加1.但此时的索引是新增的。虽然Object.defineProperty
不能检测到新增的属性(push
之后index
自增,相当于新增key
),但是在 Vue 中,新增的对象属性可以显式的调用vm.$set
来添加监听- 手动赋值
length
为一个更大的值。此时长度会更新,但对应的索引不会被赋值,即对象的属性为null
。Object.defineProperty
再强也无法处理对未知属性的监听
我们来看一下上面的论述
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26// 还是老套路,定义一个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)打印的过程可以解释为:
- 找到
test
变量指向的内存位置为一个数组,长度为3并打印,但并不知道索引对应的值是多少 - 便利索引
接下来我们做如下操作
push
时,新增了索引并且改变了长度,但新索引未被observe
- 修改新的索引对应的值
- 弹出新的索引对应的值
- 弹出索引被
observe
的值时触发了get
- 此时再去给原索引赋值时发现并没有触发被
observe
的set
,由此可见数组索引被删除后就不会被observe
到了。
那对象的属性被删除后是否还可以被observe
到么?
- 修改索引为1的值,出发了
set
unshift
时,会将索引为0和1的值遍历出来存放,然后重新赋值
当我们给length
赋值时,可以看见并不会遍历数组去赋值索引
1 | var arr = new Array(1, 2, 3) |
总结
对于Object.defineProperty
来说,处理对象和数组一样,只是在初始化时去改写get
和set
达到监测数组或对象的变化。对于新增的属性,需要手动再初始化。
对于数组来说,只不过特别了点,某些方法例如push
、unshift
等也会新增索引。对于新增的索引亦可以添加observe
从而达到监听的效果。而pop
和shift
则会删除更新索引,也会出发Object.defineProperty
的get
和set
。对于重新赋值length
的数组,不会新增索引,因为不清楚新增的索引数量。
所以在Vue中我们是可以显式的通过调用
vm.$set
监听对象新增的键(key
)。但这样相对来讲比较损耗性能,所以尤大用了另一种 “奇技淫巧” 来保证数组的更新可以实时同步到data
中
1 | const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; |
这部分在 《Proxy && defineProperty之实现双向绑定》 一文中有介绍,并由此过渡到了Proxy
实现双向绑定上,感兴趣的boy可以去看一下~