vue3.0的pre-alpha版代碼已經開源了,就像作者之前放出的消息一樣,其數據響應這一部分已經由ES6的Proxy
來代替Object.defineProperty
實現,感興趣的同學可以看其實現源碼,同樣Mobx5也使用Proxy來實現數據響應式。vue和mobx都開始使用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作為新標准將受到瀏覽器廠商重點持續的性能優化,性能這塊相信會逐步得到改善。