為什麼Vue3.0使用Proxy實現資料監聽(defineProperty表示不背這個鍋)
導 讀
vue3.0中,響應式資料部分棄用了 Object.defineProperty
,使用 Proxy
來代替它。本文將主要通過以下方面來分析為什麼vue選擇棄用 Object.defineProperty
。
Object.defineProperty
真的無法監測陣列下標的變化嗎?- 分析vue2.x中對陣列
Observe
部分原始碼 - 對比
Object.defineProperty
和Proxy
一、無法監控到陣列下標的變化?
在一些技術部落格上看到過這樣一種說法,認為 Object.defineProperty
有一個缺陷是無法監聽陣列變化:
無法監控到陣列下標的變化,導致直接通過陣列的下標給陣列設定值,不能實時響應。所以vue才設定了7個變異陣列( push
pop
、 shift
、 unshift
、 splice
、 sort
、 reverse
)的 hack
方法來解決問題。
Object.defineProperty
的第一個缺陷,無法監聽陣列變化。 然而Vue的文件提到了Vue是可以檢測到陣列變化的,但是隻有以下八種方法, vm.items[indexOfItem] = newValue
這種是無法檢測的。
這種說法是有問題的,事實上, Object.defineProperty
本身是可以監控到陣列下標的變化的,只是在 Vue 的實現中,從效能/體驗的價效比考慮,放棄了這個特性。
下面我們通過一個例子來為 Object.defineProperty
function defineReactive(data, key, value) { Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function defineGet() { console.log(`get key: ${key} value: ${value}`) return value }, set: function defineSet(newVal) { console.log(`set key: ${key} value: ${newVal}`) value = newVal } }) } function observe(data) { Object.keys(data).forEach(function(key) { defineReactive(data, key, data[key]) }) } let arr = [1, 2, 3] observe(arr)
上面程式碼對陣列arr的每個屬性通過 Object.defineProperty
進行劫持,下面我們對陣列arr進行操作,看看哪些行為會觸發陣列的 getter
和 setter
方法。
1. 通過下標獲取某個元素和修改某個元素的值
可以看到,通過下標獲取某個元素會觸發 getter
方法, 設定某個值會觸發 setter
方法。
接下來,我們再試一下陣列的一些操作方法,看看是否會觸發。
2. 陣列的 push 方法
push
並未觸發 setter
和 getter
方法,陣列的下標可以看做是物件中的 key
,這裡 push
之後相當於增加了下索引為3的元素,但是並未對新的下標進行 observe
,所以不會觸發。
3. 陣列的 unshift 方法
我擦,發生了什麼?
unshift
操作會導致原來索引為0,1,2,3的值發生變化,這就需要將原來索引為0,1,2,3的值取出來,然後重新賦值,所以取值的過程觸發了 getter
,賦值時觸發了 setter
。
下面我們嘗試通過索引獲取一下對應的元素:
只有索引為0,1,2的屬性才會觸發 getter
。
這裡我們可以對比物件來看,arr陣列初始值為[1, 2, 3],即只對索引為0,1,2執行了 observe
方法,所以無論後來陣列的長度發生怎樣的變化,依然只有索引為0,1,2的元素髮生變化才會觸發,其他的新增索引,就相當於物件中新增的屬性,需要再手動 observe
才可以。
4. 陣列的 pop 方法
當移除的元素為引用為2的元素時,會觸發 getter
。
刪除了索引為2的元素後,再去修改或獲取它的值時,不會再觸發 setter
和 getter
。
這和物件的處理是同樣的,陣列的索引被刪除後,就相當於物件的屬性被刪除一樣,不會再去觸發 observe
。
到這裡,我們可以簡單的總結一下結論。
Object.defineProperty
在陣列中的表現和在物件中的表現是一致的,陣列的索引就可以看做是物件中的 key
。
- 通過索引訪問或設定對應元素的值時,可以觸發
getter
和setter
方法 - 通過
push
或unshift
會增加索引,對於新增加的屬性,需要再手動初始化才能被observe
。 - 通過
pop
或shift
刪除元素,會刪除並更新索引,也會觸發setter
和getter
方法。
所以, Object.defineProperty
是有監控陣列下標變化的能力的,只是vue2.x放棄了這個特性。
二、vue對陣列的observe做了哪些處理?
vue的 Observer
類定義在 core/observer/index.js
中。
可以看到,vue的 Observer
對陣列做了單獨的處理。
hasProto
是判斷陣列的例項是否有 __proto__
屬性,如果有 __proto__
屬性就會執行 protoAugment
方法,將 arrayMethods
重寫到原型上。 hasProto
定義如下。
arrayMethods
是對陣列的方法進行重寫,定義在 core/observer/array.js
中, 下面是這部分原始碼的分析。
/* * not type checking this file because flow doesn't play well with * dynamically accessing methods on Array prototype */ import { def } from '../util/index' // 複製陣列建構函式的原型,Array.prototype也是一個數組。 const arrayProto = Array.prototype // 建立物件,物件的__proto__指向arrayProto,所以arrayMethods的__proto__包含陣列的所有方法。 export const arrayMethods = Object.create(arrayProto) // 下面的陣列是要進行重寫的方法 const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] /** * Intercept mutating methods and emit events */ // 遍歷methodsToPatch陣列,對其中的方法進行重寫 methodsToPatch.forEach(function (method) { // cache original method const original = arrayProto[method] // def方法定義在lang.js檔案中,是通過object.defineProperty對屬性進行重新定義。 // 即在arrayMethods中找到我們要重寫的方法,對其進行重新定義 def(arrayMethods, method, function mutator (...args) { const result = original.apply(this, args) const ob = this.__ob__ let inserted switch (method) { // 上面已經分析過,對於push,unshift會新增索引,所以需要手動observe case 'push': case 'unshift': inserted = args break // splice方法,如果傳入了第三個引數,也會有新增索引,所以也需要手動observe case 'splice': inserted = args.slice(2) break } // push,unshift,splice三個方法觸發後,在這裡手動observe,其他方法的變更會在當前的索引上進行更新,所以不需要再執行ob.observeArray if (inserted) ob.observeArray(inserted) // notify change ob.dep.notify() return result }) })
三 Object.defineProperty VS Proxy
上面已經知道 Object.defineProperty
對陣列和物件的表現是一致的,那麼它和 Proxy
對比存在哪些優缺點呢?
1. Object.defineProperty只能劫持物件的屬性,而Proxy是直接代理物件。
由於 Object.defineProperty
只能對屬性進行劫持,需要遍歷物件的每個屬性,如果屬性值也是物件,則需要深度遍歷。而 Proxy
直接代理物件,不需要遍歷操作。
2. Object.defineProperty對新增屬性需要手動進行Observe。
由於 Object.defineProperty
劫持的是物件的屬性,所以新增屬性時,需要重新遍歷物件,對其新增屬性再使用 Object.defineProperty
進行劫持。
也正是因為這個原因,使用vue給 data
中的陣列或物件新增屬性時,需要使用 vm.$set
才能保證新增的屬性也是響應式的。
下面看一下vue的 set
方法是如何實現的, set
方法定義在 core/observer/index.js
,下面是核心程式碼。
/** * Set a property on an object. Adds the new property and * triggers change notification if the property doesn't * already exist. */ export function set (target: Array<any> | Object, key: any, val: any): any { // 如果target是陣列,且key是有效的陣列索引,會呼叫陣列的splice方法, // 我們上面說過,陣列的splice方法會被重寫,重寫的方法中會手動Observe // 所以vue的set方法,對於陣列,就是直接呼叫重寫splice方法 if (Array.isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key) target.splice(key, 1, val) return val } // 對於物件,如果key本來就是物件中的屬性,直接修改值就可以觸發更新 if (key in target && !(key in Object.prototype)) { target[key] = val return val } // vue的響應式物件中都會添加了__ob__屬性,所以可以根據是否有__ob__屬性判斷是否為響應式物件 const ob = (target: any).__ob__ // 如果不是響應式物件,直接賦值 if (!ob) { target[key] = val return val } // 呼叫defineReactive給資料添加了 getter 和 setter, // 所以vue的set方法,對於響應式的物件,就會呼叫defineReactive重新定義響應式物件,defineReactive 函式 defineReactive(ob.value, key, val) ob.dep.notify() return val }
在 set
方法中,對 target
是陣列和物件做了分別的處理, target
是陣列時,會呼叫重寫過的 splice
方法進行手動 Observe
。
對於物件,如果 key
本來就是物件的屬性,則直接修改值觸發更新,否則呼叫 defineReactive
方法重新定義響應式物件。
如果採用 proxy
實現, Proxy
通過 set(target, propKey, value, receiver)
攔截物件屬性的設定,是可以攔截到物件的新增屬性的。
不止如此, Proxy
對陣列的方法也可以監測到,不需要像上面vue2.x原始碼中那樣進行 hack
。
完美!!!
3. Proxy支援13種攔截操作,這是defineProperty所不具有的
get(target, propKey, receiver):攔截物件屬性的讀取,比如 proxy.foo
和 proxy['foo']
。
set(target, propKey, value, receiver):攔截物件屬性的設定,比如 proxy.foo = v
或 proxy['foo'] = v
,返回一個布林值。
has(target, propKey):攔截 propKey in proxy
的操作,返回一個布林值。
deleteProperty(target, propKey):攔截 delete proxy[propKey]
的操作,返回一個布林值。
ownKeys(target):攔截 Object.getOwnPropertyNames(proxy)
、 Object.getOwnPropertySymbols(proxy)
、 Object.keys(proxy)
、 for...in
迴圈,返回一個數組。該方法返回目標物件所有自身的屬性的屬性名,而 Object.keys()
的返回結果僅包括目標物件自身的可遍歷屬性。
getOwnPropertyDescriptor(target, propKey):攔截 Object.getOwnPropertyDescriptor(proxy, propKey)
,返回屬性的描述物件。
defineProperty(target, propKey, propDesc):攔截 Object.defineProperty(proxy, propKey, propDesc)
、 Object.defineProperties(proxy, propDescs)
,返回一個布林值。
preventExtensions(target):攔截 Object.preventExtensions(proxy)
,返回一個布林值。
getPrototypeOf(target):攔截 Object.getPrototypeOf(proxy)
,返回一個物件。
isExtensible(target):攔截 Object.isExtensible(proxy)
,返回一個布林值。
setPrototypeOf(target, proto):攔截 Object.setPrototypeOf(proxy, proto)
,返回一個布林值。如果目標物件是函式,那麼還有兩種額外操作可以攔截。
apply(target, object, args):攔截 Proxy
例項作為函式呼叫的操作,比如 proxy(...args)
、 proxy.call(object, ...args)
、 proxy.apply(...)
。
construct(target, args):攔截 Proxy
例項作為建構函式呼叫的操作,比如 new proxy(...args)
。
4. 新標準效能紅利
Proxy
作為新標準,長遠來看,JS引擎會繼續優化 Proxy
,但 getter
和 setter
基本不會再有針對性優化。
5. Proxy相容性差
可以看到, Proxy
對於IE瀏覽器來說簡直是災難。
並且目前並沒有一個完整支援 Proxy
所有攔截方法的Polyfill方案,有一個google編寫的proxy-polyfill 也只支援了 get,set,apply,construct 四種攔截,可以支援到IE9+和Safari 6+。
四 總結
- Object.defineProperty 對陣列和物件的表現一直,並非不能監控陣列下標的變化,vue2.x中無法通過陣列索引來實現響應式資料的自動更新是vue本身的設計導致的,不是 defineProperty 的鍋。
- Object.defineProperty 和 Proxy 本質差別是,defineProperty 只能對屬性進行劫持,所以出現了需要遞迴遍歷,新增屬性需要手動 Observe 的問題。
- Proxy 作為新標準,瀏覽器廠商勢必會對其進行持續優化,但它的相容性也是塊硬傷,並且目前還沒有完整的polifill方案。
參考
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
https://www.jb51.net/article/171872.htm
https://zhuanlan.zhihu.com/p/35080324
http://es6.ruanyifeng.com/#docs/proxy
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支援碼農教程。