數據雙向綁定做爲 Vue 核心功能之一,其實現原理主要分爲兩部分:數組
本篇文章主要介紹 Vue 實現數據劫持的思路,下一篇則會介紹發佈訂閱模式的設計。瀏覽器
對於 Object 類型,主要劫持其屬性的讀取與設置操做。在 JavaScript 中對象的屬性主要由一個字符串類型的「名稱」以及一個「屬性描述符」組成,屬性描述符包括如下選項:app
上述 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 屬性的讀取和設置操做的目的。性能
讀者能夠發現,該方法每次只能設置一個屬性,那麼就須要遍歷對象來完成其屬性的配置:ui
Object.keys(student).forEach(key => defineReactive(student, key))
複製代碼
另外還必須是一個具體的屬性,這也很是的致命。this
假如後續須要擴展該對象,那麼就必須手動爲新屬性設置 setter 和 getter 方法,**這就是爲何不在 data 中聲明的屬性沒法自動擁有雙向綁定效果的緣由 **。(這時須要調用 Vue.set() 手動設置)spa
以上即是對象劫持的核心實現,可是還有如下重要的細節須要注意:prototype
在 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 的狀況仍是不少的:
另外,開發者可能已經爲對象的屬性設置了 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
}
}
})
}
複製代碼
最後一種比較重要的狀況就是屬性的值可能也是一個對象,那麼在處理對象的屬性時,須要遞歸處理其屬性值:
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() 方法。
數組是一種特殊的對象,其下標實際上就是對象的屬性,因此理論上是能夠採用 Object.defineProperty() 方法處理數組對象。
可是 Vue 並無採用上述方法劫持數組對象,筆者猜想主要因爲如下兩點:(讀者有更好的看法,歡迎留言。)
數組對象的 length 屬性的描述符天生獨特:
const arr = [1, 2, 3]
Object.getOwnPropertyDescriptor(arr, 'length').configurable // false
複製代碼
這就意味着沒法經過 Object.defineProperty() 方法劫持 length 屬性的讀取和設置方法。
相比較對象的屬性,數組下標變化地相對頻繁,而且改變數組長度的方法也比較靈活,一旦數組的長度發生變化,那麼在沒法自動感知的狀況下,開發者只能手動更新新增的數組下標,這但是一個很繁瑣的工做。
數組主要的操做場景仍是遍歷,而對於每個元素都掛載一個 get 和 set 方法,恐怕也是不小的性能負擔。
最終 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.defineProperty() 方法存在如下缺陷:
而 ES6 中的 Proxy 能夠完美的解決這些問題(目前兼容性是個大問題),這也是 Vue3.0 中的一個大動做,有興趣的讀者能夠查閱相關的資料。
若是本文對您有所幫助,那麼點個關注,鼓勵一下筆者吧。