跳到主要內容
黯羽輕揚每天積累一點點

proxy(代理機制)_ES6 筆記 9

免費2016-07-22#JS#js proxy#js代理#es6 proxy#es6代理

js 有內置的代理機制了,配合反射機制(雖然很弱)溫水煎服

一。簡介

new Proxy(target, handler) 語法,允許句柄對象 handler 攔截目標對象 target 的屬性訪問方法

代理機制支持重寫對象的 14 個內部方法,比如 [[Get]](key, receiver)[[Set]](key, value, receiver) 等等,其中 receiver 是最先開始搜索的對象(想要訪問的屬性可能在原型鏈上)

先創建一個 proxy

var obj = {
    key: 'value'
};
var handler = {
    get: function(target, key, receiver) {
        console.log(`proxy key = ${key}`);
        return Reflect.get(target, key, receiver);
    },
    set: function(target, key, value, receiver) {
        console.log(`proxy key = ${key}, value = ${value}`);
        return Reflect.set(target, key, value, receiver);
    }
    // , apply
    // , construct
    // , defineProperty
    // , deleteProperty
    // , enumerate
    // , getOwnPropertyDescriptor
    // , getPrototypeOf
    // , has
    // , isExtensible
    // , ownKeys
    // , preventExtensions
    // , setPrototypeOf
};
var proxy = new Proxy(obj, handler);

然後試用一下:

// test
// 從目標對象 obj 複製初始狀態
console.log(proxy); // Object { key: "value" }
proxy.a = 1;    // proxy key = a, value = 1
proxy['b'] = 2; // proxy key = b, value = 2

proxy.b++;
// log print:
// proxy key = b
// proxy key = b, value = 3

// 代理對象 proxy 的狀態和目標對象 proxy 完全一致
// 因為所有內部方法都被轉發了
console.log(`proxy.key = ${proxy.key}, proxy.a = ${proxy.a}, proxy.b = ${proxy.b}`);
console.log(`obj.key = ${obj.key}, obj.a = ${obj.a}, obj.b = ${obj.b}`);
// log print:
// proxy key = key
// proxy key = a
// proxy key = b
// proxy.key = value, proxy.a = 1, proxy.b = 3
// obj.key = value, obj.a = 1, obj.b = 3

// 逆向
obj.x = 2;
console.log(`obj.x = ${obj.x}, proxy.x = ${proxy.x}`);
// log print:
// proxy key = x
// obj.x = 2, proxy.x = 2

就像複製引用(var proxy = obj;)一樣,objproxy 的狀態完全一致,雙向同步,唯一的區別就是 handler 偷偷做了些事情

二。特點

1. 行為轉發與狀態同步

代理的行為:將代理的所有內部方法轉發至目標

代理對象 proxy 和目標對象 target 保持狀態一致:創建代理對象時從 target 複製當前狀態,內部方法被全部轉發

2. proxy !== target

比如 targetDOMElement,而 proxy 不是,調用 document.body.appendChild(proxy) 將觸發 TypeError

而且,在上例中

console.log(obj === proxy); // false
console.log(obj == proxy);  // false

這很合理,因為代理本來就不同於引用複製

3. handler 攔截

可以通過 handler 對象重寫 14 個內部方法,攔截並改變 target 的默認行為

未被 handler 攔截(未在 handler 對象中重寫)的內部方法會直接指向目標

14 個內部方法都可以在 handler 對象中重寫,屬性名和 Reflect 對象的 14 個屬性名一致,詳細 MDN

4. 代理關係可以解除

代理可以解除,用 Proxy.revocable(target, handler) 創建返回 Object { proxy: Object, revoke: revoke() },調用 revoke() 解除代理關係,解除後訪問代理對象會報錯,例如:

// 解除代理關係
var rProxy = Proxy.revocable({}, {
    set: function(target, key, value, receiver) {
        return Reflect.set(target, key, 0, receiver);
    }
});
var p = rProxy.proxy;
p.a = 123;
console.log(p.a);   // 0
// 解除代理
rProxy.revoke();
// p.b = 213;  // 報錯 TypeError: illegal operation attempted on a revoked proxy

5. 對象不變性

除非 target 不可擴展,否則代理對象不能被聲明為不可擴展

三。應用場景

1. 自動填充對象

// 1. 自動填充
var Tree = function() {
    return new Proxy({}, {
        get: function(target, key, receiver) {
            if (typeof target[key] === 'undefined') {
                console.log(`${key} is undef, but it will be auto filled`);
                // 利用當前 handler 創建 proxy 作為 value
                Reflect.set(target, key, new Proxy({}, this), receiver);
                // 上面代碼等價於
                // target[key] = new Proxy({}, this);
                // 也等價於
                // target[key] = Tree();
            }
            
            return Reflect.get(target, key, receiver);
        }
    });
}
var tree = Tree();
console.log(tree);  // Object { branch1: Object }
tree.branch1.branch2.twig = 'green';
// log print:
// branch1 is undef, but it will be auto filled
// branch2 is undef, but it will be auto filled
console.log(tree);  // Object { branch1: Object }
tree.branch1.branch3.twig = "yellow";
// log print:
// branch3 is undef, but it will be auto filled
console.log(tree);  // Object { branch1: Object }

且不說有沒有用,但在 ES6 之前的時代確實無法實現自動填充對象(「編譯」源碼的黑魔法除外)

P.S. 上面的 log 結果來自 Firefox47,因為 Chrome51.0.2704.106 下出現了詭異的東西:

// chrome log print:
Object {splice: Object}
splice is undef, but it will be auto filled
splice is undef, but it will be auto filled
branch1 is undef, but it will be auto filled
branch2 is undef, but it will be auto filled
Object {splice: Object, branch1: Object}
splice is undef, but it will be auto filled
branch3 is undef, but it will be auto filled
Object {splice: Object, branch1: Object}

splice 是哪裡來的?而且查看 splice 的值 Object,會發現可以無限展開(console 會顯示一個很長很寬的 > 形),感覺像是 splice 循環引用自身。姑且認為是 Chrome 實現上的bug,不必深究

2. 防篡改對象

創建代理需要非常謹慎,否則就有漏洞

function readOnly(obj) {
    var err = new Error('can\'t modify read-only object');
    return new Proxy(obj, {
        // 1. 重寫所有 setter
        set: function(target, key, value, receiver) {
            throw err;
        },
        IsExtensible: function(target, receiver) {
            return false;
        },
        deleteProperty: function(target, key, receiver) {
            throw err;
        },
        defineProperty: function(target, key, desc, receiver) {
            throw err;
        },
        setPrototypeOf: function(target, proto, receiver) {
            throw err;
        },

        // 2. 重寫所有 getter
        // getter 存在漏洞,x 只讀,但 x.prop 仍然是可寫的
        // 篡改 x.prop 可能影響 x 內部邏輯
        // 所以還要重寫 get
        get: function(target, key, receiver) {
            var res = Reflect.get(target, key, receiver);
            // if (typeof res === 'object') {
            if (Object(res) === res) {  // 判斷 res 是不是 Object 類型
                res = readOnly(res);
            }
            return res;
        },
        getPrototypeOf: this.get,
        getOwnPropertyDescriptor: this.get
    });
}
var openObj = {
    attr: 1,
    obj: {
        val: 1,
        getVal: function() {
            return this.val;
        }
    },

    fn: function() {
        console.log('fn is called');
    }
};
var closeObj = readOnly(openObj);
// 避免 throw err 中斷執行流,影響測試
var nextTick = fn => setTimeout(fn, 50);
nextTick(() => console.log(closeObj.attr));
nextTick(() => closeObj.fn());
nextTick(() => closeObj.fn = console.log);
nextTick(() => delete closeObj.obj);
// 如果忘記限制 getter 就會出現 bug
nextTick(() => {
    closeObj.obj.val = 'hack';
    console.log(closeObj.obj.getVal()); // hack
})

readOnly 的實現超乎想象的長,一個防篡改對象需要考慮這麼多嗎?需要。而且疏忽任何一點都會引發漏洞,用代理機制重寫默認行為需要非常謹慎。上面的方案仍然存在問題:

// 訪問 2 次,new2 個 proxy 對象
console.log(closeObj.obj === closeObj.obj); // false

proxy 緩存起來可以避免創建不必要的代理對象,緩存 Object 最簡單的方式當然是 [WeakMap](/articles/集合(set 和 map)-es6 筆記 8/),如下:

function readOnlyEx(obj) {
    var proxyCache = new WeakMap();
    //...
    if (Object(res) === res) {  // 判斷 res 是不是 Object 類型
        // 緩存
        if (!proxyCache.get(res)) {
            proxyCache.set(res, readOnly(res));
        }
        res = proxyCache.get(res);
    }
    //...
}
// test
var _closeObj = readOnlyEx(openObj);
// 同一個 proxy 對象,因為有 WeakMap
console.log(_closeObj.obj === _closeObj.obj);   // true

此外,proxy !== target 是個需要注意的問題,比如 new Proxy(window, {}).alert(1) 報錯無法執行,需要 apply 處理,如下:

// proxy !== target
// new Proxy(window, {}).alert(1);
// 會報錯 TypeError: 'alert' called on an object that does not implement interface Window.

var mWin = new Proxy(window, {
    get: function(target, key, receiver) {
        var res = Reflect.get(target, key, receiver);
        //! function 判斷可能不夠嚴密
        if (typeof res === 'function') {
            return function() {
                return res.apply(target, arguments);
            }
        }
    }
});
// test
mWin.alert(123);    // alert 123

3. 分離校驗邏輯

setter/getter 的優點,通過代理機制能將校驗邏輯內置於屬性訪問行為中(校驗邏輯有地方放了)

4. 記錄對象訪問

主要用於測試/調試中,比如測試框架,通過代理機制可以全程記錄一切

5. 增強普通對象

例如自動填充對象和防篡改對象,在 API 設計方面有很大想象空間

6. 配合 WeakMap

避免多次創建相同代理,比如 readOnlyEx,用 WeakMap 管理代理對象是再自然不過的事情了

四。對象的內部方法

譯自:http://www.ecma-international.org/ecma-262/6.0/index.html#table-5

Table 5 — 必要的內部方法
內部方法簽名描述說明
[[GetPrototypeOf]]() Object | Null確定給當前對象提供繼承屬性的對象,null 值表示沒有繼承來的屬性執行 obj.__proto__ 或 obj['__proto__'] 或 Object.getPrototypeOf(obj) 時被調用
[[SetPrototypeOf]](Object | Null) Boolean建立當前對象與另一個提供繼承屬性的對象的關聯,傳入 null 表示不需要繼承屬性,返回 true 表示操作成功完成,false 表示操作失敗
[[IsExtensible]]( ) Boolean決定是否允許給當前對象添加額外的屬性
[[PreventExtensions]]( ) Boolean控制新屬性能否被添加到當前對象上,操作成功返回 true,否則返回 false
[[GetOwnProperty]](propertyKey) Undefined | Property Descriptor如果當前對象擁有返回當前對象擁有名為 propertyKey 的屬性,則返回對應的 屬性描述符 for the own property of this object whose key is , 否則返回 undefined
[[HasProperty]](propertyKey) Boolean返回一個布爾值表示當前對象是否已經擁有名為 propertyKey 的屬性(該屬性可以是當前對象的也可以是繼承得到的)執行 key in object 時被調用
[[Get]](propertyKey, Receiver)
any
返回當前對象名為 propertyKey 的屬性的值,如果需要執行 ES 代碼來(從原型鏈)檢索該屬性值,會把 Receiver 作為 this執行 obj.attr 或 obj['attr'] 時被調用
[[Set]](propertyKey,value, Receiver)
Boolean
把名為 propertyKey 的屬性值設置為 value,如果需要執行 ES 代碼來設置該屬性值(屬性來自原型鏈),會把 Receiver 作為 this 值。屬性值設置成功返回 true,否則返回 false執行 obj.attr/obj[attr] = val 時被調用,執行 +=,++,-- 時先調用 [[Get]] 再調用 [[Set]]
[[Delete]](propertyKey) Boolean從當前對象上移除名為 propertyKey 的屬性,如果屬性未被刪除仍然存在就返回 false,如果屬性被刪掉了或者不存在就返回 true
[[DefineOwnProperty]](propertyKey, PropertyDescriptor)
Boolean
創建或修改現有的名為 propertyKey 的屬性,設置為 PropertyDescriptor 描述的狀態。如果屬性被成功創建/更新則返回 true,否則返回 false
[[Enumerate]]()Object返回能夠生成當前對象的可枚舉屬性鍵集的迭代器對象執行 for (var key in obj) 時被調用,內部方法返回一個可迭代對象,for...in 循環可以通過該內部方法獲得對象鍵集
[[OwnPropertyKeys]]()List of propertyKey返回一個由當前對象的所有已擁有屬性(不包括來自原型鏈的屬性)的鍵組成的 List
Table 6 — Function 對象的額外必要內部方法
內部方法簽名描述說明
[[Call]](any, a List of any)
any
執行當前對象的相關代碼,通過函數調用表達式來調用。內部方法的參數是 this 值和通過函數調用表達式傳入的參數列表。實現了該內部方法的對象是 callable(可調用的).執行 fn() 或 obj.fn() 時被調用
[[Construct]](a List of any, Object)
Object
創建一個對象,通過 newsuper 操作符調用。內部方法的第一個參數是操作符的參數列表,第二個參數是最初 new 操作符被應用的對象。實現了該內部方法的對象被稱為 constructors(構造器),函數對象不一定是構造器,這種非構造器函數對象沒有 [[Construct]] 內部方法執行 new Type() 或者 super() 時被調用

P.S.super 關鍵字用來調用父類構造器(super(args))或者訪問父類屬性(super.attr/super.fn(args),配合 class 關鍵字定義類,詳細見 super

五。總結

js 有內置的代理機制了,配合反射機制(雖然很弱)溫水煎服

難道還會有註解?這樣看來,js 自動測試機器人有眉目了

參考資料

  • 《ES6 in Depth》:InfoQ 中文站提供的免費電子書

評論

暫無評論,快來發表你的看法吧

提交評論