目錄
- 物件的屬性型別
- 陣列長度與索引
- 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屬性是行不通的。
陣列索引是訪問陣列值的一種方式,如果拿它和物件來比較,索引就是陣列的屬性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再牛逼也沒辦法處理對未知屬性的監聽。
- push值時,此時陣列的長度會+1,索引也會+1,但是此時的索引是新增的,雖然defineProperty不能監測到新增的屬性,但是在vue中,新增的物件屬性可以顯示的呼叫
驗證陣列的幾個內部方法對索引的影響
// 還是老套路,定義一個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
時,你會發現在列印的過程中是遍歷這個陣列的
列印的過程可以理解為
- 找到test變數指向的記憶體位置為一個陣列,長度為3並列印,但並不知道索引對應的值是多少
- 遍歷索引
接下來我們做如下操作
- push時,新增了索引並改變了長度,但新的索引未被observe
- 修改新的索引對應的值
- 彈出新的索引對應的值
- 彈出索引被observe的值時,觸發了get
- 此時再去給原索引賦值時,發現並沒有觸發被observe的set,由此可見陣列索引被刪除後就不會被observe到了,那物件的屬性是否也是一樣的呢?如下圖可見也是一樣的
- 修改索引為1的值,觸發了set
- unshift時,會將索引為0和1的值遍歷出來存放,然後重新賦值
當我們給length賦值時,可以看見並不會遍歷陣列去賦值索引。
小結
對於defineProperty來說,處理陣列與物件是一視同仁的,只是在初始化時去改寫get
和set
達到監測陣列或物件的變化,對於新增的屬性,需要手動再初始化。對於陣列來說,只不過特別了點,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賦值時,是正常的,但是其餘的屬性依舊在後面。我們可以這樣認為,陣列的建構函式的原型是個空陣列,但是預設給你內建了幾個方法。
我們再來看為什麼只對這些方法重寫?
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不能檢測到陣列長度的“變化”