Vue3都使用Proxy了,你更應該瞭解Proxy
vue3.0的pre-alpha版程式碼已經開源了,就像作者之前放出的訊息一樣,其資料響應這一部分已經由ES6的Proxy
來代替Object.defineProperty
實現,感興趣的同學可以看其實現原始碼。vue都開始使用Proxy來實現資料的響應式了,所以有必要抽點時間瞭解下Proxy。
Object.defineProperty的缺陷
說到Proxy,就不得不提Object.defineProperty
,我們都知道,vue3.0之前的版本都是使用該方法來實現資料的響應式,具體是:
通過設定物件屬性getter/setter方法來監聽資料的變化,同時getter也用於依賴收集,而setter在資料變更時通知訂閱者更新檢視。
大概如下程式碼所示:
function defineReactive(obj, key, value) { Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { collectDeps() // 收集依賴 return value }, set(newVal) { observe(newVal); // 若是物件需要遞迴子屬性 if (newVal !== value) { notifyRender() // 通知訂閱者更新 value = newVal; } } }) } function observe(obj) { if (!obj || typeof obj! === 'object') { return } Object.keys(obj).forEach(key => { defineReactive(obj, key, obj[key]); }) } var data = { name: 'wonyun', sex: 'male' } observe(data)
雖然Object.defineProperty通過為屬性設定getter/setter能夠完成資料的響應式,但是它並不算是實現資料的響應式的完美方案,某些情況下需要對其進行修補或者hack,這也是它的缺陷,主要表現在兩個方面:
無法檢測到物件屬性的新增或刪除
由於js的動態性,可以為物件追加新的屬性或者刪除其中某個屬性,這點對經過Object.defineProperty方法建立的響應式物件來說,只能追蹤物件已有資料是否被修改,無法追蹤新增屬性和刪除屬性,這就需要另外處理。
目前Vue保證響應式物件新增屬性也是響應式的,有兩種方式:
Vue.set(obj, propertName/index, value)
響應式物件的子物件新增屬性,可以給子響應式物件重新賦值
data.location = { x: 100, y: 100 } data.location = {...data, z: 100}
響應式物件刪除屬性,可以使用
Vue.delete(obj, propertyName/index)
或者vue.$delete(obj, propertyName/index)
; 類似於刪除響應式物件子物件的某個屬性,也可以重新給子物件賦值來解決。不能監聽陣列的變化
vue在實現陣列的響應式時,它使用了一些hack,把無法監聽陣列的情況通過重寫陣列的部分方法來實現響應式,這也只限制在陣列的
push/pop/shift/unshift/splice/sort/reverse
七個方法,其他陣列方法及陣列的使用則無法檢測到,例如如下兩種使用方式:vm.items[index] = newValue
vm.items.length--
那麼vue怎麼實現陣列的響應式呢,並不是重寫陣列的
Array.prototype
對應的方法,具體來說就是重新指定要運算元組的prototype,並重新該prototype中對應上面的7個數組方法,通過下面程式碼簡單瞭解下實現原理:const methods = ['pop','shift','unshift','sort','reverse','splice', 'push']; // 複製Array.prototype,並將其prototype指向Array.prototype let proto = Object.create(Array.prototype); methods.forEach(method => { proto[method] = function () { // 重寫proto中的陣列方法 Array.prototype[method].call(this, ...arguments); viewRender() // 檢視更新 } }) function observe(obj) { if (Array.isArray(obj)) { // 陣列實現響應式 obj.__proto__ = proto; // 改變傳入陣列的prototype return; } if (typeof obj === 'object') { ... // 物件的響應式實現 } }
Proxy的使用
Proxy,字面意思是代理,是ES6提供的一個新的API,用於修改某些操作的預設行為,可以理解為在目標物件之前做一層攔截,外部所有的訪問都必須通過這層攔截,通過這層攔截可以做很多事情,比如對資料進行過濾、修改或者收集資訊之類。借用proxy的巧用的一幅圖,它很形象的表達了Proxy的作用。
ES6原生提供的Proxy建構函式,用法如下:
var proxy = new Proxy(obj, handler)
其中obj為Proxy要攔截的物件,handler用來定製攔截的操作,返回一個新的代理物件proxy;Proxy代理特點:
Proxy直接代理整個物件而非物件屬性
Proxy的代理針對的是整個物件,而不是像Object.defineProperty針對某個屬性。只需做一層代理就可以監聽同級結構下的所有屬性變化,包括新增屬性和刪除屬性
Proxy也可以監聽陣列的變化
例如上面vue使用的Object.defineProperty實現響應式方式用Proxy來實現則相對比較簡單:
let handler = { get(target, key){ if (target[key] === 'object' && target[key]!== null) { // 巢狀子物件也需要進行資料代理 return new Proxy(target[key], hanlder) } collectDeps() // 收集依賴 return Reflect.get(target, key) }, set(target, key, value) { if (key === 'length') return true notifyRender() // 通知訂閱者更新 return Reflect.set(target, key, value); } } let proxy = new Proxy(data, handler); proxy.age = 18 // 支援新增屬性 let proxy1 = new Proxy({arr: []}, handler); proxy1.arr[0] = 'proxy' // 支援陣列內容變化
上面的Proxy的建構函式中的
get/set
為Proxy定義的13種的trap
中的其中兩種,它共有13種代理操作方法:trap 描述 handler.get 獲取物件的屬性時攔截 handler.set 設定物件的屬性時攔截 handler.has 攔截 propName in proxy
的操作,返回booleanhandler.apply 攔截proxy例項作為函式呼叫的操作, proxy(args)
、proxy.call(...)
、proxy.apply(..)
handler.construct 攔截proxy作為建構函式呼叫的操作 handler.ownKeys 攔截獲取proxy例項屬性的操作,包括 Object.getOwnPropertyNames
、Object.getOwnPropertySymbols
、Object.keys
、for...in
handler.deleteProperty 攔截 delete proxy[propName]
操作handler.defineProperty 攔截 Objecet.defineProperty
handler.isExtensible 攔截 Object.isExtensible
操作handler.preventExtensions 攔截 Object.preventExtensions
操作handler.getPrototypeOf 攔截 Object.getPrototypeOf
操作handler.setPrototypeOf 攔截 Object.setPrototypeOf
操作handler.getOwnPropertyDescriptor 攔截 Object.getOwnPropertyDescriptor
操作
Proxy代理目標物件,是通過操作上面的13種trap來完成的,這與ES6提供的另一個apiReflect
的13種靜態方法一一對應。二者一般是配合使用的,在修改proxy代理物件時,一般也需要同步到代理的目標物件上,這個同步就是用Reflect
對應方法來完成的。例如上面的Reflect.set(target, key, value)
同步目標物件屬性的修改。需要補充一點:
13種trap操作方法中,若初始化時handler沒設定的方法就直接操作目標物件,不會走攔截操作
Proxy的使用場景
Proxy因為在目標物件之前架設了一層攔截,外部對該目標物件的訪問都必須經過這次攔截。那麼通過這層攔截,可以做很多事情,例如控制過濾、快取、資料驗證等等,可以說Proxy的使用場景比較廣,下面簡單列舉幾個使用場景,更多實用場景可以參考Proxy的巧用。
Vue3的資料響應
vue3中利用Proxy實現資料讀取和設定時進行攔截,在攔截trap中實現資料的依賴收集以及觸發檢視更新操作,vue3該部分實現的主要偽碼如下:
function get(target, key, receiver) { // handler.get的攔截實現 const res = Reflect.get(target, key, receiver) if(isSymbol(key) && builtInSymbols.has(key)) return res if (isRef(res)) return res.value track(target, OperationTypes.GET, key) // 收集依賴 return isObject(res) ? reactive(res) : res } // handler.set的攔截操作 function set(target, key, value, receiver) { value = toRaw(value) // 獲取快取響應資料 oldValue = target[key] if (isRef(oldValue) && !isRef(value)) { oldValue.value = value return true } const result = Reflect.set(target, key, value, receiver) if (target === toRaw(receiver)) { //set攔截只限物件本身 ... // 不同環境操作處理,並省略下面trigger方法第二引數獲取邏輯 trigger(target, OperationTypes.x, key) // 觸發檢視更新 } return result }
獲取屬性對應的值,無該屬性或者屬性為空返回預設值
在專案中經常遇到這樣的需求,在前端拿到後端返回的資料時,獲取某些可選欄位時,如果其值為空或者不存在該屬性時,可以設定一個預設值,類似loadsh庫的get方法
_.get(object, path, [defaultValue])
。下面就物件形式下_.get用Proxy來實現,程式碼如下:function getValueByPath(object, path, defaultValue) { let proxy = new Proxy(object, { get(target, key) { if (key.startsWith('.')) { key = key.slice(1); } if (key.includes('.')) { path = path.split('.'); let index = 0, len = path.length; while(target != null && index < len) { target = target[path[index++]] } return target || defaultValue; } if (!(key in target) || !target[key]) { return defaultValue } return Reflect.get(target, key) } }); return proxy[path] }
需要注意的是,引數path若有類似
a.b.c
這樣巢狀的路徑時,我們是直接在Proxy的handler.get
中處理的,如果在proxy物件例項上呼叫如proxy.a.b.c
則需要在Proxy的handler.get
對返回物件的屬性還需要建立其Proxy例項,類似如下:function getValueByPath(object, path, defaultValue) { return proxy = new Proxy(object, { get(target, key) { if (isObject(target[key])){ return new Proxy(target[key], {get(){}}) } ... // 其他省略 } }) }
實現陣列負數索引的訪問
正常的陣列,如果訪問陣列的負數索引會得到undefined,現在要實現類似字串的
substr
方法,傳遞負數索引index,表示從倒數第index開始讀取,實現攔截如下:function getArrItem(arr) { return new Proxy(arr, { get(target, key, receiver) { let index = Number(key); if (index < 0) { key = String(target.length + index); } return Reflect.get(target, key, receiver) } }); }
Proxy的劣勢
雖然Proxy相對於Object.defineProperty有很有優勢,但是並不是說Proxy就沒有劣勢,這主要表現在以下兩個方面:
相容性問題,無完全polyfill
Proxy為ES6新出的API,瀏覽器的對其支援情況可以在caniuse查到,如下圖所示:
可以看出雖然大部分瀏覽器支援Proxy特性,但是一些瀏覽器或者其低版本不支援Proxy,其中IE、QQ瀏覽器、百度瀏覽器等完全不支援,因此Proxy有相容性問題。那能否像ES6其他特性那樣有對應的polyfill解決方案呢,答案並不那麼樂觀。其中作為ES6轉換的翹楚babel,在其官網明確做了說明:
Due to the limitations of ES5, Proxies cannot be transpiled or polyfilled.
也就是說,由於ES5的限制,ES6的Proxy沒辦法被完全polyfill,所以babel沒有提供對應的轉換支援,Proxy的實現是需要JS引擎級別提供支援,目前大部分主要的JS引擎提供了支援,可以檢視ES6 Proxy compatibilit。
然而,截止目前2019年10月,Google開發的Proxy polyfill:proxy-polyfill,其實現也是殘缺的,表現在:
只支援Proxy的4個trap:
get
、set
、apply
和construct
部分支援的trap其功能也是殘缺的,如
set
不支援新增屬性該polyfill不能代理陣列
效能問題
Proxy的另一個就是效能問題,為此有人專門做了一個對比實驗,原文在這裡thoughts-on-es6-proxies-performance,對應的中文翻譯可以參考ES6 Proxy效能之我見。Proxy的效能比Promise還差,這就要需要在效能和簡單實用上進行權衡。例如vue3使用Proxy後,其對物件及陣列的攔截很容易實現資料的響應式,尤其對陣列來說。
另外,Proxy作為新標準將受到瀏覽器廠商重點持續的效能優化,效能這塊相信會逐步得到改善。
參考文獻
Proxy的巧用
thoughts-on-es6-proxies-performance
ES6 Proxy效能之我見
面試官: 實現雙向繫結Proxy比defineproperty優劣如何?
es6-proxy-polyfill-for-ie11