深入淺出 Vue 系列 -- 資料劫持實現原理

descire發表於2019-04-22

一、前言

  資料雙向繫結作為 Vue 核心功能之一,其實現原理主要分為兩部分:

  • 資料劫持
  • 釋出訂閱模式

  本篇文章主要介紹 Vue 實現資料劫持的思路,下一篇則會介紹釋出訂閱模式的設計。

二、針對 Object 型別的劫持

  對於 Object 型別,主要劫持其屬性的讀取與設定操作。在 JavaScript 中物件的屬性主要由一個字串型別的“名稱”以及一個“屬性描述符”組成,屬性描述符包括以下選項:

  • value: 該屬性的值;
  • writable: 僅當值為 true 時表示該屬性可以被改變;
  • get: getter (讀取器);
  • set: setter (設定器);
  • configurable: 僅當值為 true 時,該屬性可以被刪除以及屬性描述符可以被改變;
  • enumerable: 僅當值為 true 時,該屬性可以被列舉。

  上述 setter 和 getter 方法就是供開發者自定義屬性的讀取與設定操作,而設定物件屬性的描述符則少不了 Object.defineProperty() 方法:

function defineReactive (obj, key) {
  let val = obj[key]
  Object.defineProperty(obj, key, {
    get () {
      console.log(' === 收集依賴 === ')
      console.log(' 當前值為:' + val)
      return val
    },
    set (newValue) {
      console.log(' === 通知變更 === ')
      console.log(' 當前值為:' + newValue)
      val = newValue
    }
  })
}

const student = {
  name: 'xiaoming'
}

defineReactive(student, 'name') // 劫持 name 屬性的讀取和設定操作

複製程式碼

  上述程式碼通過 Object.defineProperty() 方法設定屬性的 setter 與 getter 方法,從而達到劫持 student 物件中的 name 屬性的讀取和設定操作的目的。

  讀者可以發現,該方法每次只能設定一個屬性,那麼就需要遍歷物件來完成其屬性的配置:

  Object.keys(student).forEach(key => defineReactive(student, key))
複製程式碼

  另外還必須是一個具體的屬性,這也非常的致命。

  假如後續需要擴充套件該物件,那麼就必須手動為新屬性設定 setter 和 getter 方法,**這就是為什麼不在 data 中宣告的屬性無法自動擁有雙向繫結效果的原因 **。(這時需要呼叫 Vue.set() 手動設定)

  以上便是物件劫持的核心實現,但是還有以下重要的細節需要注意:

1、屬性描述符 - configurable

  在 JavaScript 中,物件通過字面量建立時,其屬性描述符預設如下:

const foo = {
  name: '123'
}
Object.getOwnPropertyDescriptor(foo, 'name') // { value: '123', writable: true, enumerable: true, configurable: true }
複製程式碼

  前面也提到了 configurable 的值如果為 false,則無法再修改該屬性的描述符,所以在設定 setter 和 getter 方法時,需要注意 configurable 選項的取值,否則在使用 Object.defineProperty() 方法時會丟擲異常:

// 部分重複程式碼 這裡就不再羅列了。
function defineReactive (obj, key) {
  // ...

  const desc = Object.getOwnPropertyDescriptor(obj, key)

  if (desc && desc.configurable === false) {
    return
  }

  // ...
}
複製程式碼

  而在 JavaScript 中,導致 configurable 值為 false 的情況還是很多的:

  • 可能該屬性在此之前已經通過 Object.defineProperty() 方法設定了 configurable 的值;
  • 通過 Object.seal() 方法設定該物件為密封物件,只能修改該屬性的值並且不能刪除該屬性以及修改屬性的描述符;
  • 通過 Object.freeze() 方法凍結該物件,相比較 Object.seal() 方法,它更為嚴格之處體現在不允許修改屬性的值。
2、預設 getter 和 setter 方法

  另外,開發者可能已經為物件的屬性設定了 getter 和 setter 方法,對於這種情況,Vue 當然不能破壞開發者定義的方法,所以 Vue 中還要保護預設的 getter 和 setter 方法:

// 部分重複程式碼 這裡就不再羅列了
function defineReactive (obj, key) {
  let val = obj[key]

  //....

  // 預設 getter setter
  const getter = desc && desc.get
  const setter = desc && desc.set

  Object.defineProperty(obj, key, {
    get () {
      const value = getter ? getter.call(obj) : val // 優先執行預設的 getter
      return value
    },
    set (newValue) {
      const value = getter ? getter.call(obj) : val
      // 如果值相同則沒必要更新 === 的坑點 NaN!!!!
      if (newValue === value || (value !== value && newValue !== newValue)) {
        return
      }

      if (getter && !setter) {
        // 使用者未設定 setter
        return
      }

      if (setter) {
        // 呼叫預設的 setter 方法
        setter.call(obj, newValue)
      } else {
        val = newValue
      }
    }
  })
}
複製程式碼
3、遞迴屬性值

  最後一種比較重要的情況就是屬性的值可能也是一個物件,那麼在處理物件的屬性時,需要遞迴處理其屬性值:

function defineReactive (obj, key) {
  let val = obj[key]

  // ...

  // 遞迴處理其屬性值
  const childObj = observe(val)

  // ...
}
複製程式碼

  遞迴迴圈引用物件很容易出現遞迴爆棧問題,對於這種情況,Vue 通過定義 ob 物件記錄已經被設定過 getter 和 setter 方法的物件,從而避免遞迴爆棧的問題。

function isObject (val) {
  const type = val
  return val !== null && (type === 'object' || type === 'function')
}

function observe (value) {
  if (!isObject(value)) {
    return
  }

  let ob
  // 避免迴圈引用造成的遞迴爆棧問題
  if (value.hasOwnProperty('__ob__') && value.__obj__ instanceof Observer) {
    ob = value.__ob__
  } else if (Object.isExtensible(value)) {
    // 後續需要定義諸如 __ob__ 這樣的屬性,所以需要能夠擴充套件
    ob = new Observer(value)
  }

  return ob
}
複製程式碼

  上述程式碼中提到了物件的可擴充套件性,在 JavaScript 中所有物件預設都是可擴充套件的,但同時也提供了相應的方法允許物件不可擴充套件:

const obj = { name: 'xiaoming' }
Object.preventExtensions(obj)
obj.age = 20
console.log(obj.age) // undefined
複製程式碼

  除了上述方法,還有前面提到的 Object.seal() 和 Object.freeze() 方法。

三、針對 Array 型別的劫持

  陣列是一種特殊的物件,其下標實際上就是物件的屬性,所以理論上是可以採用 Object.defineProperty() 方法處理陣列物件

  但是 Vue 並沒有採用上述方法劫持陣列物件,筆者猜測主要由於以下兩點:(讀者有更好的見解,歡迎留言。)

1、特殊的 length 屬性

  陣列物件的 length 屬性的描述符天生獨特:

const arr = [1, 2, 3]

Object.getOwnPropertyDescriptor(arr, 'length').configurable // false
複製程式碼

  這就意味著無法通過 Object.defineProperty() 方法劫持 length 屬性的讀取和設定方法。

  相比較物件的屬性,陣列下標變化地相對頻繁,並且改變陣列長度的方法也比較靈活,一旦陣列的長度發生變化,那麼在無法自動感知的情況下,開發者只能手動更新新增的陣列下標,這可是一個很繁瑣的工作。

2、陣列的操作場景

  陣列主要的操作場景還是遍歷,而對於每一個元素都掛載一個 get 和 set 方法,恐怕也是不小的效能負擔。

3、陣列方法的劫持

  最終 Vue 選擇劫持一些常用的陣列操作方法,從而知曉陣列的變化情況:

const methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'sort',
  'reverse',
  'splice'
]
複製程式碼

  陣列方法的劫持涉及到原型相關的知識,首先陣列例項大部分方法都是來源於 Array.prototype 物件。

  但是這裡不能直接篡改 Array.prototype 物件,這樣會影響所有的陣列例項,為了避免這種情況,需要採用原型繼承得到一個新的原型物件:

const arrayProto = Array.prototype
const injackingPrototype = Object.create(arrayProto)
複製程式碼

  拿到新的原型物件之後,再重寫這些常用的操作方法:

methods.forEach(method => {
  const originArrayMethod = arrayProto[method]
  injackingPrototype[method] = function (...args) {
    const result = originArrayMethod.apply(this, args)
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) {
      // 對於新增的元素,繼續劫持
      // ob.observeArray(inserted)
    }
    // 通知變化
    return result
  }
})
複製程式碼

  最後,更新劫持陣列例項的原型,在 ES6 之前,可以通過瀏覽器私有屬性 proto 指定原型,之後,便可以採用如下方法:

Object.setPrototypeOf(arr, injackingPrototype)
複製程式碼

  順便提一下,採用 Vue.set() 方法設定陣列元素時,Vue 內部實際上是呼叫劫持後的 splice() 方法來觸發更新

四、總結

  由上述內容可知,Vue 中的資料劫持分為兩大部分:

  • 針對 Object 型別,採用 Object.defineProperty() 方法劫持屬性的讀取和設定方法
  • 針對 Array 型別,採用原型相關的知識劫持常用的函式,從而知曉當前陣列發生變化

  並且 Object.defineProperty() 方法存在以下缺陷:

  • 每次只能設定一個具體的屬性,導致需要遍歷物件來設定屬性,同時也導致了無法探測新增屬性
  • 屬性描述符 configurable 對其的影響是致命的

  而 ES6 中的 Proxy 可以完美的解決這些問題(目前相容性是個大問題),這也是 Vue3.0 中的一個大動作,有興趣的讀者可以查閱相關的資料。

  如果本文對您有所幫助,那麼點個關注,鼓勵一下筆者吧。

相關文章