Proxy詳解,運用與Mobx

眷你發表於2018-12-17

本文簡單闡述一點超程式設計的知識,然後較為詳細的給出 Proxy 的有關用法(起碼比 MDN 詳細,補充了各種錯誤情況的具體示例,且比上面的機翻準確),再用一些例子講述 Proxy 適合在什麼場景下使用

超程式設計

首先,在程式設計中有以下兩個級別:

  • 基本級別/應用程式級別:程式碼處理使用者輸入
  • 級別:程式碼處理基本級別的程式碼

元( meta ) 這個詞綴在這裡的意思是:關於某事自身的某事,因此超程式設計( metaprogramming )這個詞意味著 關於程式設計的程式設計,可以在兩種同的語言進行超程式設計,編寫元程式的語言稱之為元語言。被操縱的程式的語言稱之為“目標語言”,在下面這段程式碼中 JavaScript 為元語言,而 Java 為目標語言:

const str = 'Hello' + '!'.repeat(3);
console.log('System.out.println("'+str+'")');
複製程式碼

一門程式語言同時也是自身的元語言的能力稱之為反射(Reflection),用於發現和調整你的應用程式結構和語義。

超程式設計有三種形式:

  • 自省(Introspection): 暴露出程式執行時的狀態,以只讀的形式獲取程式結構
    • 比如使用 Object.keys(obj) 等,ES6 中新出了個 Reflect 對許多獲取內部狀態的介面進行了整合與統一
  • Self-modification: 可以修改執行時的程式結構/資料
    • 比如 deleteproperty descriptors
  • 調解(Intercession): 可以重新定義某些語言操作的語義
    • 比如本文中的 Proxy

超程式設計與 Reflect

ES6 中也新增了一個全域性物件 Reflect,其中的大多數方法都早已以其他形式存在,這次將其介面統一的目的在於:

  1. 在以前各種獲取/修改程式執行時狀態的方法通常是散落在各處的,有的掛在 Object.prototype 上,有的掛在 Function.prototype 上,有的是一個操作符(如 delete / in 等 )
  2. 以前的呼叫過於複雜或不夠安全
    • 某些情況下呼叫 obj.hasOwnProperty 時物件上可能沒有這個方法(比如這個物件是通過 Object.create(null) 建立的),因此這個時候使用 Object.prototype.hasOwnProperty.call 才是最安全的,但是這樣過於複雜
    • callapply 也有上述問題
  3. 返回值不科學,如使用 Object.defineProperty ,如果成功返回一個物件,否則丟擲一個 TypeError ,因此不得不使用 try...catch 來捕獲定義屬性時發生的任何錯誤。而 Reflect.defineProperty 返回一個布林值表示的成功狀態,所以在這裡可以只用 if...else

這裡可以參考一篇文章來了解 Reflect 做出了哪些優化

Proxy 的基本內容

終於來到了我們的主角,首先我們來看看 Proxy 的建構函式:

Proxy(target, handler)
複製程式碼
  • target: 用 Proxy 包裝的目標物件(可以是任何型別的物件,包括原生陣列,函式,甚至另一個代理)。
  • handler: 處理器物件( proxy's handler)用來自定義代理物件的各種可代理操作。其中包括眾多 traps

不變數(Invariants)

在進一步探究 Proxy 有什麼之前,先回顧一下如何通保護物件:

  • 不可擴充套件(Non-extensible)
    • 不可新增屬性且不可改變原型
    'use strict'
    const obj = Object.preventExtensions({});
    console.log(Object.isExtensible(obj)); // false
    obj.foo = 123; // Cannot add property foo, object is not extensible
    Object.setPrototypeOf(obj, null) // #<Object> is not extensible
複製程式碼
  • 不可寫(Non-writable)
    • value 不能被賦值運算子改變
  • 不可配置(Non-configurable)
    • 不能改變/刪除屬性(除了把 writable 改為 false

使用代理以後,很容易違反上述約束(因為上述約束作用在被 Proxy 代理的物件中, Proxy 物件並不受其約束),因此在呼叫/返回的時候 Proxy 會幫我們檢查或者強制做型別轉換等(比如預期是 Boolean 時會把 truishfalsish 強制轉換成 Boolean 等)。後文中的約束部分有進一步的解釋與示例。

這裡有一份關於 不變數的文件

然後來看看 Proxyhandler 提供了哪些東西供我們使用

handler.get()

攔截物件的讀取屬性操作。

get: function(target, property, receiver) {}
複製程式碼
  • target :目標物件。
  • property :被獲取的屬性名。
  • receiver :最初被呼叫的物件。通常是 proxy 本身,但 handlerget 方法也有可能在原型鏈上或以其他方式被間接地呼叫(因此不一定是 proxy 本身)。
    • 這裡的 targetproperty 都很好理解,但是 receiver 需要額外注意,下面使用一個例子幫助理解:
    var obj = {
        myObj: 1
    };
    obj.__proto__ = new Proxy({
        test: 123
    },{
        get:function(target, property, receiver) {
            console.log(target, property, receiver);
            return 1;
        }
    });
    console.log(obj.test);
    // {test: 123}, "test" ,{myObj: 1}
    // 可以看見 receiver 是最初被呼叫的物件
複製程式碼

該方法會攔截目標物件的以下操作:

  • 訪問屬性: proxy[foo]proxy.bar
  • 訪問原型鏈上的屬性: Object.create(proxy)[foo]
  • Reflect.get()

約束(違反約束會丟擲 Type Error):

  • 如果要訪問的目標屬性是不可寫以及不可配置的,則返回的值必須與該目標屬性的值相同。
    const obj = {};
    // 不可寫以及不可配置
    Object.defineProperty(obj, "a", { 
        configurable: false,
        enumerable: true, 
        value: 10, 
        writable: false
    });

    const p = new Proxy(obj, {
        get: function(target, prop) {
            return 20;
        }
    });

    console.log(p.a); // 'get' on proxy: property 'a' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected '10' but got '20')
複製程式碼
  • 如果目標物件的屬性是不可配置且沒有定義其 get 方法,則其返回值必須為 undefined
    const obj = { a: 10 };
    // 不可配置 且 沒有定義 get
    Object.defineProperty(obj, "a", { 
        configurable: false,
        get: undefined,
    });

    const p = new Proxy(obj, {
        get: function(target, prop) {
            return 20;
        }
    });

    console.log(p.a) // 'get' on proxy: property 'a' is a non-configurable accessor property on the proxy target and does not have a getter function, but the trap did not return 'undefined' (got '20')
複製程式碼

handler.set()

攔截設定屬性值的操作

set: function(target, property, value, receiver) {}
複製程式碼
  • target :目標物件。
  • property :被設定的屬性名。
  • value :被設定的新值
  • receiver :最初被呼叫的物件。同上文 get 中的 receiver

返回值:

set 方法應該返回一個 Boolean :

  • 返回 true 代表此次設定屬性成功了
  • 返回 false 且設定屬性操作發生在嚴格模式下,那麼會丟擲一個 TypeError

注意: Proyx 中大多數方法要求返回 Boolean 時本質上是會幫你把返回值轉換成 Boolean,因此可以在裡面隨便返回啥,到了外面拿到的都是 Boolean;這也是為什麼報錯的時候用詞為: truishfalsish

該方法會攔截目標物件的以下操作:

  • 指定屬性值: proxy[foo] = barproxy.foo = bar
  • 指定繼承者的屬性值: Object.create(proxy)[foo] = bar
  • Reflect.set()

約束:

  • 若目標屬性是不可寫及不可配置的,則不能改變它的值。
    const obj = {};
    // 不可寫以及不可配置
    Object.defineProperty(obj, "a", {
        configurable: false,
        enumerable: true,
        value: 10,
        writable: false
    });

    const p = new Proxy(obj, {
        set: function(target, prop, value, receiver) {
            console.log("called: " + prop + " = " + value);
            return true;
        }
    });

    p.a = 20; // trap returned truish for property 'a' which exists in the proxy target as a non-configurable and non-writable data property with a different value
    // 注意這裡我們並沒有真正改變 'a' 的值,該錯誤由 return true 引起    
複製程式碼
  • 如果目標物件的屬性是不可配置且沒有定義其 set 方法,則不能設定它的值。
    const obj = {};
    // 不可寫 且 沒有定義 set
    Object.defineProperty(obj, "a", {
        configurable: false,
        set: undefined
    });

    const p = new Proxy(obj, {
        set: function(target, prop, value, receiver) {
            console.log("called: " + prop + " = " + value);
            return true;
        }
    });

    p.a = 20; // trap returned truish for property 'a' which exists in the proxy target as a non-configurable and non-writable accessor property without a setter
    // 注意這裡我們並沒有真正改變 'a' 的值,該錯誤由 return true 引起
複製程式碼
  • 在嚴格模式下,若 set 方法返回 false ,則會丟擲一個 TypeError 異常。
    'use strict'
    const obj = {};
    const p = new Proxy(obj, {
        set: function(target, prop, value, receiver) {
            console.log("called: " + prop + " = " + value);
            return false;
        }
    });
    p.a = 20; // trap returned falsish for property 'a'
複製程式碼

handler.apply()

攔截函式的呼叫

apply: function(target, thisArg, argumentsList) {}
複製程式碼
  • target :目標物件(函式)。
  • thisArg :被呼叫時的上下文物件。
  • argumentsList :被呼叫時的引數陣列。

該方法會攔截目標物件的以下操作:

  • proxy(...args)
  • Function.prototype.apply()Function.prototype.call()
  • Reflect.apply()

約束:

  • target 本身必須是可被呼叫的。也就是說,它必須是一個函式物件。

handler.construct()

用於攔截 new 操作符

construct: function(target, argumentsList, newTarget) {}
複製程式碼
  • target :目標物件。
  • argumentsList :constructor 的引數列表。
  • newTarget :最初被呼叫的建構函式。

該方法會攔截目標物件的以下操作:

注意:

  • 為了使 new 操作符在生成的 Proxy 物件上生效,用於初始化代理的目標物件自身必須具有 [[Construct]] 內部方法,即 new target 必須是有效的。比如說 target 是一個 function

約束:

  • construct 方法必須返回一個物件,否則將會丟擲錯誤 TypeError
    const p = new Proxy(function () {}, {
        construct: function (target, argumentsList, newTarget) {
            return 1;
        }
    });
    new p(); // 'construct' on proxy: trap returned non-object ('1')
複製程式碼

handler.defineProperty()

用於攔截 Object.defineProperty() 操作

defineProperty: function(target, property, descriptor) {}
複製程式碼
  • target :目標物件。
  • property :待檢索其描述的屬性名。
  • descriptor :待定義或修改的屬性的描述符。

注意:

  • defineProperty 方法也必須返回一個布林值,表示定義該屬性的操作是否成功。(嚴格模式下返回 false 會拋 TypeError)
  • defineProperty 方法只能接受如下標準屬性,其餘的將直接無法獲取(示例程式碼如下):
    • enumerable
    • configurable
    • writable
    • value
    • get
    • set
var p = new Proxy({}, {
    defineProperty(target, prop, descriptor) {
        console.log(descriptor);
        return Reflect.defineProperty(target, prop, descriptor);
    }
});

Object.defineProperty(p, 'name', {
    value: 'proxy',
    type: 'custom'
}); 
// { value: 'proxy' }
複製程式碼

該方法會攔截目標物件的以下操作 :

  • Object.defineProperty()
  • Reflect.defineProperty()

約束:

  • 如果目標物件不可擴充套件, 將不能新增屬性。
    const obj = {
        a: 10
    };
    Object.preventExtensions(obj);
    const p = new Proxy(obj, {
        defineProperty(target, prop, descriptor) {
            return true;
        }
    });
    Object.defineProperty(p, 'name', {
        value: 'proxy'
    }); // 'defineProperty' on proxy: trap returned truish for adding property 'name'  to the non-extensible proxy target
複製程式碼
  • 如果屬性不是作為目標物件的不可配置的屬性存在,則無法將屬性新增或修改為不可配置。
    const obj = {
        a: 10
    };
    const p = new Proxy(obj, {
        defineProperty(target, prop, descriptor) {
            return true;
        }
    });
    Object.defineProperty(p, 'a', {
        value: 'proxy',
        configurable: false,
    }); // trap returned truish for defining non-configurable property 'a' which is either non-existant or configurable in the proxy target
複製程式碼
  • 如果目標物件存在一個對應的可配置屬性,這個屬性可能不會是不可配置的。
  • 如果一個屬性在目標物件中存在對應的屬性,那麼 Object.defineProperty(target, prop, descriptor) 將不會丟擲異常。
  • 在嚴格模式下, false 作為 handler.defineProperty 方法的返回值的話將會丟擲 TypeError 異常.
    const obj = {
        a: 10
    };
    const p = new Proxy(obj, {
        defineProperty(target, prop, descriptor) {
            return false
        }
    });
    Object.defineProperty(p, 'a', {
        value: 'proxy',
    }); // 'defineProperty' on proxy: trap returned falsish for property 'a'
複製程式碼

handler.deleteProperty()

用於攔截對物件屬性的 delete 操作

deleteProperty: function(target, property) {}
複製程式碼
  • target : 目標物件。
  • property : 待刪除的屬性名。

返回值: 必須返回一個 Boolean 型別的值,表示了該屬性是否被成功刪除。(這次返回 false 不會報錯了)

該方法會攔截以下操作:

  • 刪除屬性: delete proxy[foo]delete proxy.foo
  • Reflect.deleteProperty()

約束:

  • 如果目標物件的屬性是不可配置的,那麼該屬性不能被刪除。並且嘗試刪除會拋 TypeError
    const obj = {};
    Object.defineProperty(obj, 'a', {
        value: 'proxy',
    });
    const p = new Proxy(obj, {
        deleteProperty: function (target, prop) {
            return true;
        }
    });

    delete p.a; // trap returned truish for property 'a' which is non-configurable in the proxy target
複製程式碼

handler.getOwnPropertyDescriptor()

用於攔截對物件屬性的 getOwnPropertyDescriptor() 方法

getOwnPropertyDescriptor: function(target, prop) {}
複製程式碼
  • target :目標物件。
  • prop :屬性名。

返回值: 必須返回一個 objectundefined。

該方法會攔截以下操作:

  • Object.getOwnPropertyDescriptor()
  • Reflect.getOwnPropertyDescriptor()

約束:

  • getOwnPropertyDescriptor 必須返回一個 objectundefined
    const obj = { a: 10 };
    const p = new Proxy(obj, {
        getOwnPropertyDescriptor: function(target, prop) {
            return '';
        }
    });
    Object.getOwnPropertyDescriptor(p, 'a'); // trap returned neither object nor undefined for property 'a'
複製程式碼
  • 如果屬性作為目標物件的不可配置的屬性存在,則該屬性無法報告為不存在。
    const obj = { a: 10 };
    Object.defineProperty(obj, 'b', {
        value: 20
    });
    const p = new Proxy(obj, {
        getOwnPropertyDescriptor: function(target, prop) {
            return undefined;
        }
    });
    Object.getOwnPropertyDescriptor(p, 'b'); // trap returned undefined for property 'b' which is non-configurable in the proxy target
複製程式碼
  • 如果屬性作為目標物件的屬性存在,並且目標物件不可擴充套件,則該屬性無法報告為不存在。
    const obj = { a: 10 };
    Object.preventExtensions(obj);
    const p = new Proxy(obj, {
        getOwnPropertyDescriptor: function(target, prop) {
            return undefined;
        }
    });
    Object.getOwnPropertyDescriptor(p, 'a'); // trap returned undefined for property 'a' which exists in the non-extensible proxy target
複製程式碼
  • 如果屬性不存在作為目標物件的屬性,並且目標物件不可擴充套件,則不能將其報告為存在。
    const obj = { a: 10 };
    Object.preventExtensions(obj);
    const p = new Proxy(obj, {
        getOwnPropertyDescriptor: function(target, prop) {
            return Object.getOwnPropertyDescriptor(obj, prop) || {};
        }
    });
    console.log(Object.getOwnPropertyDescriptor(p, 'a'))
    Object.getOwnPropertyDescriptor(p, 'b'); // trap returned descriptor for property 'b' that is incompatible with the existing property in the proxy target
複製程式碼
  • 如果一個屬性作為目標物件的自身屬性存在,或者作為目標物件的可配置的屬性存在,則它不能被報告為不可配置
    const obj = { a: 10 };
    const p = new Proxy(obj, {
        getOwnPropertyDescriptor: function(target, prop) {
            return { configurable: false };
        }
    });
    Object.getOwnPropertyDescriptor(p, 'a'); // trap reported non-configurability for property 'a' which is either non-existant or configurable in the proxy target
複製程式碼
  • Object.getOwnPropertyDescriptor(target)的結果可以使用 Object.defineProperty 應用於目標物件,也不會丟擲異常。

handler.getPrototypeOf()

用於攔截讀取代理物件的原型的方法

getPrototypeOf(target) {}
複製程式碼
  • target : 被代理的目標物件。

返回值: 必須返回一個物件值或者返回 null ,不能返回其它型別的原始值。

該方法會攔截以下操作:

  • Object.getPrototypeOf()
  • Reflect.getPrototypeOf()
  • __proto__
  • Object.prototype.isPrototypeOf()
  • instanceof

舉例如下:

const obj = {};
const p = new Proxy(obj, {
    getPrototypeOf(target) {
        return Array.prototype;
    }
});
console.log(
    Object.getPrototypeOf(p) === Array.prototype,  // true
    Reflect.getPrototypeOf(p) === Array.prototype, // true
    p.__proto__ === Array.prototype,               // true
    Array.prototype.isPrototypeOf(p),              // true
    p instanceof Array                             // true
);
複製程式碼

約束:

  • getPrototypeOf() 方法返回的不是物件也不是 null
    const obj = {};
    const p = new Proxy(obj, {
        getPrototypeOf(target) {
            return "foo";
        }
    });
    Object.getPrototypeOf(p); // TypeError: trap returned neither object nor null
複製程式碼
  • 目標物件是不可擴充套件的,且 getPrototypeOf() 方法返回的原型不是目標物件本身的原型。
    const obj = {};
    Object.preventExtensions(obj);
    const p = new Proxy(obj, {
        getPrototypeOf(target) {
            return {};
        }
    });
    Object.getPrototypeOf(p); // proxy target is non-extensible but the trap did not return its actual prototype
複製程式碼

handler.has()

主要用於攔截 inwith 操作

has: function(target, prop) {}
複製程式碼
  • target : 目標物件
  • prop : 需要檢查是否存在的屬性

返回值: Boolean (返回一個可以轉化為 Boolean 的也沒什麼問題)

該方法會攔截以下操作:

  • 屬性查詢: foo in proxy
  • 繼承屬性查詢: foo in Object.create(proxy)
  • with 檢查: with(proxy) { (foo); }
  • Reflect.has()

約束:

  • 如果目標物件的某一屬性本身不可被配置,則該屬性不能夠被代理隱藏
    const obj = {};
    Object.defineProperty(obj, 'a', {
        value: 10
    })
    const p = new Proxy(obj, {
        has: function (target, prop) {
            return false;
        }
    });
    'a' in p; // trap returned falsish for property 'a' which exists in the proxy target as non-configurable
複製程式碼
  • 如果目標物件為不可擴充套件物件,則該物件的屬性不能夠被代理隱藏
    const obj = { a: 10 };
    Object.preventExtensions(obj);
    const p = new Proxy(obj, {
        has: function(target, prop) {
            return false;
        }
    });
    'a' in p; // trap returned falsish for property 'a' but the proxy target is not extensible
複製程式碼

handler.isExtensible()

用於攔截對物件的 Object.isExtensible() 操作

isExtensible: function(target) {}
複製程式碼
  • target : 目標物件。

該方法會攔截目標物件的以下操作:

  • Object.isExtensible()
  • Reflect.isExtensible()

返回值: Boolean 值或可轉換成 Boolean 的值。

約束:

  • Object.isExtensible(proxy) 必須同 Object.isExtensible(target) 返回相同值。
    • 如果 Object.isExtensible(target) 返回 ture ,則 Object.isExtensible(proxy) 必須返回 true 或者為 true 的值
    • 如果 Object.isExtensible(target) 返回 false ,則 Object.isExtensible(proxy) 必須返回 false 或者為 false 的值
    const p = new Proxy({}, {
        isExtensible: function(target) {
            return false;
        }
    });
    Object.isExtensible(p); // trap result does not reflect extensibility of proxy target (which is 'true')
複製程式碼

handler.ownKeys()

用於攔截 Reflect.ownKeys()

ownKeys: function(target) {}
複製程式碼
  • target : 目標物件

返回值: 一個可列舉物件

該方法會攔截目標物件的以下操作(同時有一些額外的限制):

  • Object.getOwnPropertyNames()
    • 返回一個由指定物件的所有自身屬性的屬性名(包括不可列舉屬性但不包括Symbol值作為名稱的屬性)
    • 返回的結果中只能拿到 String 的,Symbol 型別的將被忽視
  • Object.keys()
    • 返回一個由一個給定物件的自身可列舉屬性組成的陣列,陣列中屬性名的排列順序和使用 for...in 迴圈遍歷該物件時返回的順序一致。 可列舉的屬性可以通過 for...in 迴圈進行遍歷(除非該屬性名是一個Symbol)
    • 所以返回的結果中只能拿到可列舉的 String 陣列
  • Object.getOwnPropertySymbols()
    • 只返回一個給定物件自身的所有 Symbol 屬性的陣列
    • 返回的結果中只能拿到 SymbolString 型別的將被忽視
  • Reflect.ownKeys()
    • 返回一個目標物件自身的屬性鍵
    • 什麼都返回
    const mySymbel = Symbol('juanni');
    const obj = { a: 10 };
    Object.defineProperty(obj, 'b', { 
        configurable: false, 
        enumerable: false, 
        value: 10 }
    );
    Object.defineProperty(obj, mySymbel, { 
        configurable: true, 
        enumerable: true, 
        value: 10 }
    );
    const p = new Proxy(obj, {
        ownKeys: function (target) {
            return ['a', 'b', mySymbel];
        }
    });
    console.log(Object.getOwnPropertySymbols(p)); // [Symbol(juanni)]

    console.log(Object.getOwnPropertyNames(p)); // ["a", "b"]

    console.log(Object.keys(p)); // ["a"]

    console.log(Reflect.ownKeys(p)); // ["a", "b", Symbol(juanni)]
複製程式碼

約束:

  • ownKeys 的結果必須是一個陣列
    const obj = {
        a: 10
    };
    const p = new Proxy(obj, {
        ownKeys: function (target) {
            return 123;
        }
    });
    Object.getOwnPropertyNames(p); // CreateListFromArrayLike called on non-object
複製程式碼
  • 陣列的元素型別要麼是一個 String ,要麼是一個 Symbol
    const obj = {
        a: 10
    };
    const p = new Proxy(obj, {
        ownKeys: function (target) {
            return [123];
        }
    });
    Object.getOwnPropertyNames(p); // 123 is not a valid property name
複製程式碼
  • 結果列表必須包含目標物件的所有不可配置( non-configurable )、自有( own )屬性的 key
    const obj = {
        a: 10
    };
    Object.defineProperty(obj, 'b', { 
        configurable: false, 
        enumerable: true, 
        value: 10 }
    );
    const p = new Proxy(obj, {
        ownKeys: function (target) {
            return [];
        }
    });
    Object.getOwnPropertyNames(p); // trap result did not include 'b'
複製程式碼
  • 如果目標物件不可擴充套件,那麼結果列表必須包含目標物件的所有自有( own )屬性的 key ,不能有其它值
    const obj = { a: 10 };
    Object.preventExtensions(obj);
    const p = new Proxy(obj, {
        ownKeys: function (target) {
            return ['a', 'd'];
        }
    });
    Object.getOwnPropertyNames(p); // trap returned extra keys but proxy target is non-extensible
複製程式碼

handler.preventExtensions()

用於攔截對物件的 Object.preventExtensions() 操作

preventExtensions: function(target) {}
複製程式碼
  • target : 所要攔截的目標物件

該方法會攔截目標物件的以下操作:

  • Object.preventExtensions()
  • Reflect.preventExtensions()

返回值: Boolean

約束:

  • 只有當 Object.isExtensible(proxy)falseObject.preventExtensions(proxy) 才能 true
    const p = new Proxy({}, {
        preventExtensions: function (target) {
            return true;
        }
    });
    Object.preventExtensions(p); // trap returned truish but the proxy target is extensible
複製程式碼

handler.setPrototypeOf()

用於攔截對物件的 Object.setPrototypeOf() 操作

setPrototypeOf: function(target, prototype) {}
複製程式碼
  • target : 被攔截目標物件
  • prototype : 物件新原型或為 null

該方法會攔截目標物件的以下操作:

  • Object.setPrototypeOf()
  • Reflect.setPrototypeOf()

返回值: Boolean

約束:

  • 如果 target 不可擴充套件, 原型引數必須與 Object.getPrototypeOf(target) 的值相
    const obj = {
        a: 10
    };
    Object.preventExtensions(obj);
    const p = new Proxy(obj, {
        setPrototypeOf(target, prototype) {
            Object.setPrototypeOf(target, prototype)
            return true;
        }
    });
    Object.setPrototypeOf(obj, null); // #<Object> is not extensible
複製程式碼

撤銷 Proxy

Proxy.revocable() 方法被用來建立可撤銷的 Proxy 物件。此種代理可以通過revoke函式來撤銷並且關閉代理。關閉代理後,在代理上的任意的操作都會導致 TypeError

const revocable = Proxy.revocable({}, {
    get: function (target, name) {
        return "[[" + name + "]]";
    }
});
const proxy = revocable.proxy;
console.log(proxy.foo); // "[[foo]]"

revocable.revoke();

console.log(proxy.foo); // Cannot perform 'get' on a proxy that has been revoked
proxy.foo = 1 // Cannot perform 'set' on a proxy that has been revoked
delete proxy.foo; // Cannot perform 'deleteProperty' on a proxy that has been revoked
typeof proxy // "object", typeof doesn't trigger any trap
複製程式碼

在 Mobx5 中的運用

在這裡打算用 Vue 和 Mobx 來體現出 Proxy 的優勢

首先看看 Vue2.x 因為使用 defineProperty 帶來的限制:

  1. 不能檢測利用索引直接設定一個項: vm.items[indexOfItem] = newValue
  2. 不能檢測修改陣列的長度: vm.items.length = newLength
  3. 不能檢測物件屬性的新增或刪除
  4. ...

隔壁 Mobx4 也是用的 defineProperty ,但是通過一系列 hack 來繞過一些限制:

  1. 使用自己擼的類陣列物件來解決上述 1、2 兩個問題。但是也有額外限制:
    1. Array.isArray 返回 false
    2. 傳遞給外部或者需要使用真正陣列時,必須使用 array.slice() 建立一份淺拷貝的真正陣列
    3. sortreverse 不會改變陣列本身,而只是返回一個排序過/反轉過的拷貝
  2. 沒能解決不能直接在物件上新增/刪除屬性的問題

因為使用了類陣列物件,所以 length 變成了物件上的屬性而不是陣列的 length ,因此可以被劫持。更多技巧可以檢視 observablearray.ts

    Object.defineProperty(ObservableArray.prototype, "length", {
        enumerable: false,
        configurable: true,
        get: function(): number {
            return this.$mobx.getArrayLength()
        },
        set: function(newLength: number) {
            this.$mobx.setArrayLength(newLength)
        }
    })
複製程式碼

Mobx5 在今年使用 Prxoy 重寫後正式釋出,成功解決了上述問題,接下來簡單看一看它是如何解決的

  1. 攔截修改陣列 length 的操作/直接設定值:
const arrayTraps = {
    get(target, name) {
        if (name === $mobx) return target[$mobx]
        // 成功攔截 length
        if (name === "length") return target[$mobx].getArrayLength()
        if (typeof name === "number") {
            return arrayExtensions.get.call(target, name)
        }
        if (typeof name === "string" && !isNaN(name as any)) {
            return arrayExtensions.get.call(target, parseInt(name))
        }
        if (arrayExtensions.hasOwnProperty(name)) {
            return arrayExtensions[name]
        }
        return target[name]
    },
    set(target, name, value): boolean {
        // 成功攔截 length
        if (name === "length") {
            target[$mobx].setArrayLength(value)
            return true
        }
        // 直接設定陣列值
        if (typeof name === "number") {
            arrayExtensions.set.call(target, name, value)
            return true
        }
        // 直接設定陣列值
        if (!isNaN(name)) {
            arrayExtensions.set.call(target, parseInt(name), value)
            return true
        }
        return false
    },
    preventExtensions(target) {
        fail(`Observable arrays cannot be frozen`)
        return false
    }
}
複製程式碼
  1. 直接在物件上新增/刪除屬性
    • 這一點本質是因為 defineProperty 是劫持的物件上的屬性引起的,沒有辦法劫持物件上不存在的屬性,而 Prxoy 劫持整個物件自然沒有了這個問題

polyfill

這個東西因為語言本身限制所以 polyfill 並不好搞,但是部分實現還是可以的:

proxy-polyfill 是谷歌基於 defineProperty 擼的,只支援 get , set , apply , construct, 也支援 revocable ,程式碼只有一百多行非常簡單,所以就不多做講解

實際運用

基礎知識瞭解了這麼多,接下來該看看實際運用了

設計模式

正好有個設計模式叫代理模式:為其他物件提供一種代理以控制對這個物件的訪問。在某些情況下,一個物件不適合或者不能直接引用另一個物件,而代理物件可以在客戶端和目標物件之間起到中介的作用。

優點有二:

  • 單一職責原則: 物件導向設計中鼓勵將不同的職責分佈到細粒度的物件中, Proxy 在原物件的基礎上進行了功能的衍生而又不影響原物件,符合鬆耦合高內聚的設計理念。
  • 開放-封閉原則:代理可以隨時從程式中去掉,而不用對其他部分的程式碼進行修改,在實際場景中,隨著版本的迭代可能會有多種原因不再需要代理,那麼就可以容易的將代理物件換成原物件的呼叫

需要注意的 this

在熱身之前有一個需要注意的小點 - this

    const target = {
        foo() {
            return {
                thisIsTarget: this === target,
                thisIsProxy: this === proxy,
            };
        }
    };
    const handler = {};
    const proxy = new Proxy(target, handler);

    console.log(target.foo()); // {thisIsTarget: true, thisIsProxy: false}
    console.log(proxy.foo()); // {thisIsTarget: false, thisIsProxy: true}
複製程式碼

通常情況下,通過 Proxy 中的 this 來呼叫方法或者獲取/設定屬性沒什麼問題,因為最終還是會被攔截到走到原始物件上,但是如果是本身用 this 進行騷操作或是有些內建方法需要 this 指向正確就需要額外注意了

  1. this 使用騷操作需要額外注意
    const _name = new WeakMap();
    class Person {
        constructor(name) {
            _name.set(this, name);
        }
        get name() {
            return _name.get(this);
        }
    }

    const juanni = new Person('Juanni');
    const proxy = new Proxy(juanni, {});
    console.log(juanni.name); // 'juanni'
    console.log(proxy.name); // undefined
複製程式碼
  1. 內建方法依賴 this
    const target = new Date();
    const handler = {};
    const proxy = new Proxy(target, handler);

    // 依賴 this 導致報錯
    proxy.getDate(); // this is not a Date object.

    // 修正方案
    const handler = {
        get(target, propKey, receiver) {
            if (propKey === 'getDate') {
                return target.getDate.bind(target);
            }
            return Reflect.get(target, propKey, receiver);
        },
    };
    const proxy = new Proxy(new Date('2020-12-24'), handler);
    proxy.getDate(); // 24
複製程式碼

熱身

首先讓我們簡單熱身一下,看一個簡單的:假設我們有一個函式tracePropAccess(obj, propKeys) ,只要設定或獲得了 obj 的在 propKeys 的屬性,就會被記錄下來。

由於這個是簡單的熱身 demo,因此就直接給出使用 definePropertyProxy 完成的程式碼來供對比

// ES5
    function tracePropAccess(obj, propKeys) {
        const propData = Object.create(null);
        propKeys.forEach(function (propKey) {
            propData[propKey] = obj[propKey];
            Object.defineProperty(obj, propKey, {
                get: function () {
                    console.log(`GET ${propKey}`);
                    return propData[propKey];
                },
                set: function (value) {
                    console.log(`SET ${propKey} = ${value}`);
                    propData[propKey] = value;
                },
            });
        });
        return obj;
    }

    class Point {
        constructor(x, y) {
            this.x = x;
            this.y = y;
        }
        toString() {
            return `Point( ${this.x} , ${this.y} )`;
        }
    }
    p = tracePropAccess(new Point(7), ['x', 'y']);
    p.x // GET x
    p.x = 666 // SET x = 666
    p.toString()
    // GET x
    // GET y
複製程式碼
// ES6 with Proxy
    function tracePropAccess(obj, propKeys) {
        const propKeySet = new Set(propKeys);
        return new Proxy(obj, {
            get(target, propKey, receiver) {
                if (propKeySet.has(propKey)) {
                    console.log(`GET ${propKey}`);
                }
                return Reflect.get(target, propKey, receiver);
            },
            set(target, propKey, value, receiver) {
                if (propKeySet.has(propKey)) {
                    console.log(`SET ${propKey} = ${value}`);
                }
                return Reflect.set(target, propKey, value, receiver);
            },
        });
    }

    class Point {
        constructor(x, y) {
            this.x = x;
            this.y = y;
        }
        toString() {
            return `Point( ${this.x} , ${this.y} )`;
        }
    }
    p = tracePropAccess(new Point(7), ['x', 'y']);
    p.x // GET x
    p.x = 666 // SET x = 666
    p.toString()
    // GET x
    // GET y
複製程式碼

負陣列索引

隔壁 python 等都可以通過負數索引訪問到陣列倒數第 N 個元素,現在我們有了一種新方法直接實現這一特性:

    function createArray(array) {
        if(!Array.isArray(array)) {
            throw Error('must be an array');
        }
        const handler = {
            get(target, propKey, receiver) {
                const index = Number(propKey);
                if (index < 0) {
                    propKey = String(target.length + index);
                }
                return Reflect.get(target, propKey, receiver);
            }
        };
        return new Proxy(array, handler);
    }
    const arr = createArray(['a', 'b', 'c']);
    console.log(arr[-1]); // c
複製程式碼

攔截呼叫

對於方法呼叫沒有單一操作可以進行攔截,因為方法呼叫被視為兩個獨立的操作:首先使用 get 檢索函式,然後呼叫該函式。

const obj = {
    multiply(x, y) {
        return x * y;
    },
    squared(x) {
        return this.multiply(x, x);
    },
};

function traceMethodCalls(obj) {
    const handler = {
        get(target, propKey, receiver) {
            const origMethod = target[propKey];
            return function (...args) {
                const result = origMethod.apply(this, args);
                console.log(propKey + JSON.stringify(args)
                    + ' -> ' + JSON.stringify(result));
                return result;
            };
        }
    };
    return new Proxy(obj, handler);
}
const tracedObj = traceMethodCalls(obj);

console.log(tracedObj.multiply(2,7));
// multiply[2,7] -> 14
// test.js:25 14
console.log(tracedObj.squared(9));
// multiply[9,9] -> 81
// test.js:16 squared[9] -> 81
// test.js:26 81
複製程式碼

我們可以看見即使 this 指向了 Proxy 在原始物件內部的方法呼叫(如 this.multiply(x, x) )也能被攔截到

單例模式

function singleton(func) {
    let instance,
        handler = {
            construct: function (target, args) {
                if (!instance) {
                    instance = new func();
                }
                return instance;
            }
        };
    return new Proxy(func, handler);
}
複製程式碼

讓某個屬性徹底消失

  • Reflect.hasObject.hasOwnPropertyObject.prototype.hasOwnPropertyin 運算子全部使用了 [[HasProperty]],可以通過 has 攔截。
  • Object.keysObject.getOwnPropertyNames , Object.entrie 都使用了 [[OwnPropertyKeys]],可以通過 ownKeys 攔截。
  • Object.getOwnPropertyDescriptor 使用了 [[GetOwnProperty]]可以通過 getOwnPropertyDescriptor 攔截。

因此我們可以寫出如下程式碼徹底讓某個屬性徹底消失掉

function hideProperty(object, ...propertiesToHide) {
    const proxyObject = new Proxy(object, {
        has(object, property) {
            if (propertiesToHide.indexOf(property) != -1) {
                return false;
            }
            return Reflect.has(object, property);
        },
        ownKeys(object) {
            return Reflect.ownKeys(object).filter(
                (property) => propertiesToHide.indexOf(property) == -1
            );
        },
        getOwnPropertyDescriptor(object, property) {
            if (propertiesToHide.indexOf(property) != -1) {
                return undefined;
            }
            return Reflect.getOwnPropertyDescriptor(object, property);
        }
    });
    return proxyObject;
}
複製程式碼

其他

這裡還有很多可以用 Proxy 來實現的,比如:

  • 型別校驗
    • 剝離校驗邏輯
  • 私有變數
    • 通過使用 has, ownKeys , getOwnPropertyDescriptorget, set 來讓屬性變成私有屬性
  • 資料繫結
  • 快取代理
  • 驗證代理
  • 圖片懶載入
  • 合併請求
  • 更多有趣的作品可以檢視 awesome-es2015-proxy

參考

相關文章