1. 程式人生 > >Vue3都使用Proxy了,你更應該瞭解Proxy

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,這也是它的缺陷,主要表現在兩個方面:

  1. 無法檢測到物件屬性的新增或刪除

    由於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); 類似於刪除響應式物件子物件的某個屬性,也可以重新給子物件賦值來解決。

  2. 不能監聽陣列的變化

    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的操作,返回boolean
    handler.apply 攔截proxy例項作為函式呼叫的操作,proxy(args)proxy.call(...)proxy.apply(..)
    handler.construct 攔截proxy作為建構函式呼叫的操作
    handler.ownKeys 攔截獲取proxy例項屬性的操作,包括Object.getOwnPropertyNamesObject.getOwnPropertySymbolsObject.keysfor...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:getsetapplyconstruct

    • 部分支援的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