為什麼defineProperty不能檢測到陣列長度的“變化”

Jmingzi發表於2018-05-29

目錄

  • 物件的屬性型別
  • 陣列長度與索引
  • 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不能檢測到陣列長度的“變化”

相關文章