Skip to content

深入理解 ES6 #12-代理(Proxy)和反射(Reflection)API

🏷️ 《深入理解 ES6》

代理(Proxy)是一种可以拦截并改变底层 JavaScript 引擎操作的包装器,在新语言中通过它暴露内部运作的对象,从而让开发者可以创建内建的对象。

数组问题

在 ECMAScript6 出现之前,开发者不能通过自己定义的对象模仿 JavaScript 数组对象的行为方式。当给数组的特定元素赋值时,影响到该数组的 length 属性,也可以通过 length 属性修改数组元素。

js
let colors = ["red", "green", "blue"];

console.log(colors.length); // 3

colors[3] = "black";

console.log(colors.length); // 4
console.log(colors[3]); // "black"

colors.length = 2;

console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"

NOTE

数值属性和 length 属性具有这种非标准行为,因而在 ECMAScript 中数组被认为是奇异对象(exotic object,与普通对象相对)。

代理和反射

调用 new Proxy() 可创建代替其他目标(taget)对象的代理,它虚拟化了目标,所以二者看起来功能一致。

代理可以拦截 JavaScript 引擎内部目标的底层对象操作,这些底层操作被拦截后会触发响应特定操作的陷阱函数。

反射 API 可以 Reflect 对象的形式出现,对象中方法的默认特性与相同的底层操作一致,而代理可以覆写这些操作,每个代理陷阱对应一个命名和参数都相同的 Reflect 方法。

代理陷阱覆写的特性默认特性
get读取一个属性值Reflect.get()
set写入一个属性值Reflect.set()
hasin 操作符Reflect.has()
deletePropertydelete 操作符Reflect.deleteProperty()
getPrototypeOfObject.getPrototypeOf()Reflect.getPrototypeOf()
setPrototypeOfObject.setPrototypeOf()Reflect.setPrototypeOf()
isExtensibleObject.isExtensible()Reflect.isExtensible()
preventExtensionsObject.preventExtensions()Reflect.preventExtensions()
getOwnPropertyDescriptorObject.getOwnPropertyDescriptor()Reflect.getOwnPropertyDescriptor()
definePropertyObject.defineProperty()Reflect.defineProperty()
ownKeysObject.keys()Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Reflect.ownKeys()
apply调用一个函数Reflect.apply()
constructnew 调用一个函数Reflect.construct()

创建一个简单的代理

Proxy 构造函数有两个参数

  • 目标(target)
  • 处理程序(handler)

处理程序是定义一个或多个陷阱的对象,在代理中,出了专门为操作定义的陷阱外,其余操作均使用默认特性。

不使用任何陷阱的处理程序等价于简单的转发代理。

js
let target = {};
let proxy = new Proxy(target, {});

proxy.name = "proxy";
console.log(proxy.name); // "proxy"
console.log(target.name); // "proxy"

target.name = "target";
console.log(proxy.name); // "target"
console.log(target.name); // "target"

使用 set 陷阱验证属性

set 陷阱接受 4 个参数:

  • trapTarget
    用于接收属性(代理的目标)的对象
  • key
    要写入的属性键(字符串或 Symbol 类型)
  • value
    被写入属性的值
  • receiver
    操作发生的对象(通常是代理)

Reflect.set()set 陷阱对应的反射方法和默认特性,它和 set 陷阱一样也接受同样的 4 个参数。

js
let target = {
    name: "target"
};

let proxy = new Proxy(target, {
    set(trapTarget, key, value, receiver) {
        if (!trapTarget.hasOwnProperty(key)) {
            if (isNaN(value)) {
                throw new TypeError("属性必须是数字");
            }
        }

        return Reflect.set(trapTarget, key, value, receiver);
    }
});

proxy.count = 1;
console.log(proxy.count); // 1
console.log(target.count); // 1

proxy.name = "proxy";
console.log(proxy.name); // "proxy"
console.log(target.name); // "proxy"

// 抛出错误:
// Uncaught TypeError: 属性必须是数字
proxy.anotherName = "proxy";

用 get 陷阱验证对象结构(Object Shape)

get 陷阱接受 3 个参数:

  • trapTarget
    被读取属性的源的对象(代理的目标)
  • key
    被读取的属性键
  • value
    操作发生的对象(通常是代理)

Reflect.get() 也接受同样 3 个参数并返回属性的默认值。

js
let proxy = new Proxy({}, {
    get(trapTarget, key, receiver) {
        if (!(key in receiver)) {
            throw new TypeError("属性" + key + "不存在");
        }

        return Reflect.get(trapTarget, key, receiver);
    }
});

proxy.name = "proxy";
console.log(proxy.name); // "proxy"

// 抛出错误:
// Uncaught TypeError: 属性 nme 不存在
console.log(proxy.nme);

使用 has 陷阱隐藏已有属性

可以用 in 操作符来检测给定对象中是否含有某个属性,如果自由属性或原型属性匹配的名称或 Symbol 就返回 true

js
let target = {
    value: 42
};

console.log("value" in target); // true
console.log("toString" in target); // true

在代理中使用 has 陷阱可以拦截这些 in 操作并返回一个不同的值。

in 陷阱接受 2 个参数:

  • trapTarget
    读取属性的对象(代理的目标)
  • key
    要检查的属性值(字符串或 Symbol)
js
let target = {
    name: "target",
    vlaue: 42
};

let proxy = new Proxy(target, {
    has (trapTarget, key) {
        if (key === "value") {
            return false;
        } else {
            return Reflect.has(trapTarget, key);
        }
    }
});

console.log("value" in proxy); // false
console.log("name" in proxy); // true
console.log("toString" in proxy); // true

用 deleteProperty 陷阱防止删除属性

delete 操作符可以从对象中删除属性,如果成功则返回 true,不成功则返回 false

在严格模式下,如果你尝试删除一个不可配置(nonconfigurable)属性则会导致程序抛出错误,而在非严格模式下只是返回 false

每当通过 delete 操作符删除对象属性时,deleteProperty 陷阱都会被调用,它接受 2 个参数:

  • trapTarget
    要删除属性的对象(代理的目标)
  • key
    要删除的属性键(字符串或 Symbol)

Reflect.deleteProperty() 方法为 deleteProperty 陷阱提供默认实现,并且接受同样的两个参数。

js
let target = {
    name: "target",
    value: 42
};

let proxy = new Proxy(target, {
    deleteProperty(trapTarget, key) {
        if (key === "value") {
            return false;
        } else {
            return Reflect.deleteProperty(trapTarget, key);
        }
    }
});

console.log("value" in proxy); // true

let result1 = delete proxy.value;
console.log(result1); // false

console.log("value" in proxy); // true

console.log("name" in proxy); // true

let result2 = delete proxy.name;
console.log(result2); // true

console.log("name" in proxy); // false

原型代理陷阱

ES6 中新增的 Object.setPrototypeOf() 方法,它被用于作为 ES5 中的 Object.getPrototype() 方法的补充。通过代理中的 setPrototypeOf 陷阱和 getPrototypeOf 陷阱可以拦截这两个方法的执行过程。

setPrototypeOf 陷阱接受 2 个参数:

  • trapTarget
    接受原型设置的对象(代理的目标)
  • proto
    作为原型使用的对象

传入 Object.setPrototypeOf() 方法和 Reflect.setPrototypeOf() 方法的均是以上两个参数。

getPrototypeOf 陷阱、Object.getPrototypeOf() 方法和 Reflect.getPrototypeOf() 方法只接受参数 trapTarget

原型代理陷阱的运行机制

原型代理陷阱有一些限制:

  1. getPrototypeOf 陷阱必须返回对象或 null

  2. setPrototypeOf 陷阱中,如果操作失败则返回的一定是 false,此时 Object.setPrototypeOf() 会抛出错误,如果 setPrototypeOf 返回了任何不是 false 的值,那么 Object.setPrototypeOf() 便假设操作成功。

js
let target = {};
let proxy = new Proxy(target, {
    getPrototypeOf(trapTarget) {
        return null;
    },
    setPrototypeOf(trapTarget, proto) {
        return false;
    }
});

let targetProto = Object.getPrototypeOf(target);
let proxyProto = Object.getPrototypeOf(proxy);

console.log(targetProto === Object.prototype); // true
console.log(proxyProto === Object.prototype); // false
console.log(proxyProto); // null

// 成功
Object.setPrototypeOf(target, {});

// 给不存在的属性赋值会抛出错误:
// Uncaught TypeError: 'setPrototypeOf' on proxy: trap returned falsish
Object.setPrototypeOf(proxy, {});

可以使用 Reflect 上的对应方法实现这两个陷阱的默认行为。

js
let target = {};
let proxy = new Proxy(target, {
    getPrototypeOf(trapTarget) {
        return Reflect.getPrototypeOf(trapTarget);
    },
    setPrototypeOf(trapTarget, proto) {
        return Reflect.setPrototypeOf(trapTarget, proto);
    }
});

let targetProto = Object.getPrototypeOf(target);
let proxyProto = Object.getPrototypeOf(proxy);

console.log(targetProto === Object.prototype); // true
console.log(proxyProto === Object.prototype); // true

// 成功
Object.setPrototypeOf(target, {});

// 成功:
Object.setPrototypeOf(proxy, {});

对象可扩展性陷阱

ECMAScript 5 已经通过 Object.preventExtensions() 方法和 Object.isExtensible() 方法修正了对象的可扩展性;
ECMAScript 6 可以通过代理中的 preventExtensionsisExtensible 陷阱拦截这两个方法并调用底层对象。

两个陷阱都接受唯一参数 trapTarget 对象,并调用它上面的方法。

isExtensible 陷阱返回的一定是一个 boolean 值,表示对象是否可扩展;
preventExtensions 陷阱返回的也一定是布尔值,表示操作是否成功。

Reflect.preventExtensions() 方法和 Reflect.isExtensible() 方法实现了相应陷阱中的默认行为,二两都返回布尔值。

两个基础示例

默认实现:

js
let target = {};
let proxy = new Proxy(target, {
    isExtensible(trapTarget) {
        return Reflect.isExtensible(trapTarget);
    },
    preventExtensions(trapTarget) {
        return Reflect.preventExtensions(trapTarget);
    }
});

console.log(Object.isExtensible(target)); // true
console.log(Object.isExtensible(proxy)); // true

Object.preventExtensions(proxy);

console.log(Object.isExtensible(target)); // false
console.log(Object.isExtensible(proxy)); // false

使用陷阱使 Object.preventExtensions()proxy 失效。

js
let target = {};
let proxy = new Proxy(target, {
    isExtensible(trapTarget) {
        return Reflect.isExtensible(trapTarget);
    },
    preventExtensions(trapTarget) {
        return false;
    }
});

console.log(Object.isExtensible(target)); // true
console.log(Object.isExtensible(proxy)); // true

// 抛出错误:
// Uncaught TypeError: 'preventExtensions' on proxy: trap returned falsish
Object.preventExtensions(proxy);

console.log(Object.isExtensible(target)); // true
console.log(Object.isExtensible(proxy)); // true

属性描述符陷阱

ECMAScript 5 最重要的特性之一是可以使用 Object.defineProperty() 方法定义属性特性(property attribute)。可以通过 Object.getOwnPropertyDescriptor() 方法来获取这些属性。

在代理中可以分别用 defineProperty 陷阱和 getOwnPropertyDescriptor 陷阱拦截 Object.defineProperty() 方法和 Object.getOwnPropertyDescriptor() 方法的调用。

defineProperty 陷阱接受以下参数:

  • trapTarget
    要定义属性的对象(代理的目标)
  • key
    属性的键(字符串或 Symbol)
  • descriptor
    属性的描述符对象

操作成功后返回 true,否则返回 false

getOwnPropertyDescriptor 陷阱接受以下参数:

  • trapTarget
    要定义属性的对象(代理的目标)
  • key
    属性的键(字符串或 Symbol)

最终返回描述符。

陷阱默认行为示例:

js
let proxy = new Proxy({}, {
    defineProperty(trapTarget, key, descriptor) {
        return Reflect.defineProperty(trapTarget, key, descriptor);
    },
    getOwnPropertyDescriptor(trapTarget, key) {
        return Reflect.getOwnPropertyDescriptor(trapTarget, key);
    }
});

Object.defineProperty(proxy, "name", {
    value: "proxy"
});

console.log(proxy.name); // "proxy"

let descriptor = Object.getOwnPropertyDescriptor(proxy, "name");

console.log(descriptor.value); // "proxy"

给 Object.defineProperty() 添加限制

defineProperty 陷阱返回布尔值来表示操作是否成功。

返回 true 时,Object.defineProperty() 方法成功执行;
返回 false 时,Object.defineProperty() 方法抛出错误。

例:阻止 Symbol 类型的属性

js
let proxy = new Proxy({}, {
    defineProperty(trapTarget, key, descriptor) {
        if (typeof key === "symbol") {
            return false;
        }

        return Reflect.defineProperty(trapTarget, key, descriptor);
    }
});

Object.defineProperty(proxy, "name", {
    value: "proxy"
});

console.log(proxy.name); // "proxy"

let nameSymbol = Symbol("name");

// 抛出错误:
// Uncaught TypeError: 'defineProperty' on proxy: trap returned falsish for property 'Symbol(name)'
Object.defineProperty(proxy, nameSymbol, {
    value: "proxy"
});

描述符对象限制

defineProperty 陷阱

defineProperty 陷阱的描述对象已规范化,只有下列属性会被传递给 defineProperty 陷阱的描述符对象。

  • enumerable
  • configurable
  • value
  • writable
  • get
  • set
js
let proxy = new Proxy({}, {
    defineProperty(trapTarget, key, descriptor) {
        console.log(descriptor.value); // "proxy"
        console.log(descriptor.name); // undefined

        return Reflect.defineProperty(trapTarget, key, descriptor);
    }
});

Object.defineProperty(proxy, "name", {
    value: "proxy",
    name: "custom"
});

getOwnPropertyDescriptor 陷阱

getOwnPropertyDescriptor 陷阱的返回值必须是 nullundefined 或一个对象;

如果返回对象,则对象自己的属性只能是 enumerableconfigurablevlauewritablegetset
在返回的对象中使用不被允许的属性会抛出一个错误。

js
let proxy = new Proxy({}, {
    getOwnPropertyDescriptor(trapTarget, key) {
        return {
            name: "proxy"
        };
    }
});

// 给不存在的属性赋值会抛出错误
// Uncaught TypeError: 'getOwnPropertyDescriptor' on proxy: trap reported non-configurability for property 'name' which is either non-existant or configurable in the proxy target
let descriptor = Object.getOwnPropertyDescriptor(proxy, "name");

这条限制可以确保无论代理中使用了什么方法,Object.getOwnPropertyDescriptor() 返回值的结构总是可靠的。

ownKeys 陷阱

ownKeys 陷阱可以拦截内部方法 [[OwnPropertyKeys]],通过返回一个数组的值可以覆写其行为。

这个数组被用于 Object.keys()Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign() 4 个方法,Object.assign() 方法用数组来确定需要复制的属性。

ownKeys 陷阱通过 Reflect.ownKeys() 方法实现默认的行为,返回的数组中包含所有自有属性的键名,字符串类型和 Symbol 类型的都包含在内。

Object.getOwnPropertyNames() 方法和 Object.keys() 方法返回的结果将 Symbol 类型的属性名排除在外;
Object.getOwnPropertySymbols() 方法返回的结果将字符串类型的属性名排除在外;
Object.assign() 方法支持字符串和 Symbol 两种类型。

ownKeys 陷阱唯一接受的参数是操作的目标,返回值必须是一个数组或类数组对象,否则就抛出错误。

例:过滤任何以下划线字符开头的属性名称。

js
let proxy = new Proxy({}, {
    ownKeys(trapTarget) {
        return Reflect.ownKeys(trapTarget).filter(key => {
            return typeof key !== "string" || key[0] !== "_";
        });
    }
});

let nameSymbol = Symbol("name");

proxy.name = "proxy";
proxy._name = "private";
proxy[nameSymbol] = "symbol";

let names = Object.getOwnPropertyNames(proxy),
    keys = Object.keys(proxy),
    symbols = Object.getOwnPropertySymbols(proxy);

console.log(names.length); // 1
console.log(names[0]); // "name"

console.log(keys.length); // 1
console.log(keys[0]); // "name"

console.log(symbols.length); // 1
console.log(symbols[0]); // "Symbol(name)"

函数代理的 apply 和 construct 陷阱

所有代理陷阱中,只有 applyconstruct 的代理目标是一个函数。

函数有两个内部方法 [[Call]][[Construct]]apply 陷阱和 construct 陷阱可以覆写这些内部方法。

若使用 new 操作符调用函数,则执行 [[Construct]] 方法;若不用,则执行 [[Call]] 方法。

apply 陷阱和 Reflect.apply() 都接受以下参数:

  • trapTarget
    被执行的函数(代理的目标)
  • thisArg
    函数被调用时内部 this 的值
  • argumentList
    传递给函数的参数数组

当使用 new 调用函数时调用的 construct 陷阱接受以下参数:

  • trapTarget
    被执行的函数(代理的目标)
  • argumentList
    传递给函数的参数数组

Reflect.construct() 方法也接受这两个参数,其还有一个可选的第三个参数 newTarget。

js
let target = function() { return 42; },
    proxy = new Proxy(target, {
        apply: function(trapTarget, thisArg, argumentList) {
            return Reflect.apply(trapTarget, thisArg, argumentList);
        },
        construct: function(trapTarget, argumentList) {
            return Reflect.construct(trapTarget, argumentList);
        }
    });

// 一个目标是函数的代理开起来也像一个函数
console.log(typeof proxy); // function
console.log(proxy()); // 42
var instance = new proxy();
console.log(instance instanceof proxy); // true
console.log(instance instanceof target); // true

验证函数参数

例:验证所有参数必须是数字:

js
// 将所有参数相加
function sum(...values) {
    return values.reduce((previous, current) => previous + current, 0);
}

let sumProxy = new Proxy(sum, {
    apply: function(trapTarget, thisArg, argumentList) {
        argumentList.forEach((arg) => {
            if (typeof arg !== "number") {
                throw new TypeError("所有参数必须是数字");
            }
        });

        return Reflect.apply(trapTarget, thisArg, argumentList);
    },
    construct: function(trapTarget, argumentList) {
        throw new TypeError("该函数不可通过 new 来调用");
    }
});

console.log(sumProxy(1, 2, 3, 4)); // 10

// 抛出错误
// Uncaught TypeError: 所有参数必须是数字
console.log(sumProxy(1, "2", 3, 4));

// 抛出错误
// Uncaught TypeError: 该函数不可通过 new 来调用
let result = new sumProxy();

可调用的类构造函数

使用 apply 陷阱创建实例

js
class Person {
    constructor(name) {
        this.name = name;
    }
}

let PersonProxy = new Proxy(Person, {
    apply: function(trapTarget, thisArg, argumentList) {
        return new trapTarget(...argumentList);
    }
});

let me = PersonProxy("JiaJia");
console.log(me.name); // JiaJia
console.log(me instanceof Person); // true
console.log(me instanceof PersonProxy); // true

可撤销代理

可以使用 Proxy.revocable() 方法创建可撤销的代理,该方法采用与 Proxy 构造函数相同的参数:目标对象和代理处理程序。
返回值是具有以下属性的对象:

  • proxy
    可被撤销的代理对象
  • revoke
    撤销代理要调用的函数
js
let target = {
    name: "target"
};

let { proxy, revoke } = Proxy.revocable(target, {});

console.log(proxy.name); // target

revoke();

// 抛出错误
// Uncaught TypeError: Cannot perform 'get' on a proxy that has been revoked
console.log(proxy.name);

解决数组问题

数组问题

js
let colors = ["red", "green", "blue"];

console.log(colors.length); // 3

colors[3] = "black";

console.log(colors.length); // 4
console.log(colors[3]); // black

colors.length = 2;

自定义数组类型

js
function toUint32(value) {
    return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}

function isArrayIndex(key) {
    let numericKey = toUint32(key);
    return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}


class MyArray {
    constructor(length = 0) {
        this.length = length;
        return new Proxy(this, {
            set(trapTarget, key, value) {
                let currentLength = Reflect.get(trapTarget, "length");

                if (isArrayIndex(key)) {
                    let numericKey = Number(key);
                    if (numericKey >= currentLength) {
                        Reflect.set(trapTarget, "length", numericKey + 1);
                    }
                } else if (key === "length") {
                    if (value < currentLength) {
                        for (let index = currentLength - 1; index >= value; index--) {
                            Reflect.deleteProperty(trapTarget, index);
                        }
                    }
                }

                return Reflect.set(trapTarget, key, value);
            }
        });
    }
}

let colors = new MyArray(3);
console.log(colors instanceof MyArray); // true
console.log(colors.length); // 3

let colors2 = new MyArray(5);
console.log(colors.length); // 3
console.log(colors2.length); // 5

colors[0] = "red";
colors[1] = "green";
colors[2] = "blue";
colors[3] = "black";

console.log(colors.length); // 4

colors.length = 2;
console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"
console.log(colors[0]); // "red"

将代理作为原型

js
let target = {};
let proxy = new Proxy(target, {
    defineProperty(trapTarget, name, descriptor) {
        return false;
    }
});
let newTarget = Object.create(proxy);

Object.defineProperty(newTarget, "name", {
    value: "newTarget"
});

console.log(newTarget.name); // "newTarget"
console.log(newTarget.hasOwnProperty("name")); // true
console.log(Object.getPrototypeOf(newTarget) === proxy); // true

关于 Object.create() 方法可以参照 这里

上例中 newTarget 的原型是代理,但是在对象上定义属性的操作不需要操作对象原型,所以没有触发代理中的陷阱。

尽管代理作为原型使用时及其受限,但有几个陷阱仍然有用。

在原型上使用 get 陷阱

js
let target = {};
let thing = Object.create(new Proxy(target, {
    get(trapTarget, key, value) {
        throw new ReferenceError(`${key} deesn't exist`);
    }
}));

thing.name = "thing";
console.log(thing.name); // "thing"

// 抛出异常:
// Uncaught ReferenceError: unknown deesn't exist
let unknown = thing.unknown;

访问对象上不存在的属性时,会触发原型中的 get 陷阱。

在原型上使用 set 陷阱

js
let target = {};
let thing = Object.create(new Proxy(target, {
    set (trapTarget, key, value, receiver) {
        return Reflect.set(trapTarget, key, value, receiver);
    }
}));

console.log(thing.hasOwnProperty("name")); // false

// 触发 set 陷阱
thing.name = "thing";
console.log(thing.name); // "thing"
console.log(thing.hasOwnProperty("name")); // true

// 不触发 set 陷阱
thing.name = "boo";
console.log(thing.name); // "boo"

在原型上使用 has 陷阱

js
let target = {}
let thing = Object.create(new Proxy(target, {
    has (trapTarget, key) {
        return Reflect.has(trapTarget, key);
    }
}));

// 触发 has 陷阱
console.log("name" in thing); // false
thing.name = "thing";
// 不触发 has 陷阱
console.log("name" in thing); // true

第一次 in 操作符触发 has 陷阱,是因为 name 不是 thing 的自有属性。

将代理用作类的原型

js
function NoSuchProperty() {

}

let proxy = new Proxy({}, {
    get (trapTarget, key, receiver) {
        throw new ReferenceError(`${key} doesn't exist`);
    }
});

NoSuchProperty.prototype = proxy;

class Square extends NoSuchProperty {
    constructor(length, width) {
        super();
        this.length = length;
        this.width = width;
    }
}

let shape = new Square(2, 6);

let shapeProto = Object.getPrototypeOf(shape);
console.log(shapeProto === proxy); // false

let secondLevelProto = Object.getPrototypeOf(shapeProto);
console.log(secondLevelProto === proxy); // true

let area1 = shape.length * shape.width;
console.log(area1); // 12

// 由于 wdth 不存在,抛出错误:
// Uncaught ReferenceError: wdth doesn't exist
let area2 = shape.length * shape.wdth;