反射和代理的具体应用

原文发布于 github.com/ta7sudan/no…, 如需转载请保留原做者 @ta7sudan.javascript

ES6 为咱们提供了许多新的 API, 其中我的以为最有用的(之一)即是代理了. 代理和反射都被归为反射 API, 那什么是反射? 根据 wiki 上的解释.java

反射是指计算机程序在运行时(Run time)能够访问、检测和修改它自己状态或行为的一种能力。git

因此广义上来讲, 并不是只有使用了 Proxy Reflect 相关的 API 才叫反射, 而是只要在运行时访问, 检测和修改自身状态和行为的均可以认为是用到了反射. 拿比较常见的 new 无关的构造函数来讲, 咱们经常会这样实现.es6

function Person() {
	var self = this instanceof Person ? this : Object.create(Person.prototype);
	return self;
}
复制代码

像上面这样, 咱们在运行时经过检测 this 进而检测是不是经过 new 调用的函数, 从而决定返回值, 这也算是反射.github

不少语言都提供了反射机制, 即便是汇编, 也可以在运行时修改自身的代码(谁让指令和数据是在一块儿呢...不过即使不在一块儿也是能够的). 那反射和代理到底有什么用?数组

有人认为反射破坏了封装, 可是它也带来了更多的灵活性, 使得本来没法实现或难以实现的事情变得很容易实现, 尽管有缺点, 但缺点是咱们能够避免的(若是有人使用了反射来破坏封装, 那说明他在使用的时候已经清楚这样作的结果, 产生的后果也应当本身承担, 而若是不是必要, 则大部分时候也不会用到反射, 不存在破坏封装), 而带来的好处相较缺点则是明显划算的.函数

就 API 而言, 反射和代理用起来是很简单的, 因此这里就不提了. 下面以比较经常使用的 get trap 来讲明代理的实际应用场景. 后文中的反射泛指反射 API, 即包含了 ProxyReflect, 并再也不区分.性能

考虑咱们有一个对象 obj, 对象具备一个 sayHello 方法, 咱们可能会这么写.优化

var obj = {};
obj.sayHello = function () {
	console.log('hello');
};
复制代码

在初始化的时候便定义了 sayHello 方法, 但可能有时候咱们以为这不必, 毕竟一个函数表达式也是有开销的. 固然你能够说咱们直接在字面量里写好 sayHello 不就好了, 为何必定要用函数表达式? 这里只是演示, 不用在乎细节, 总之咱们但愿在运行时某一时刻再实例化这个 sayHello 方法, 而不是一开始就实例化它, 缘由多是应用启动速度比较重要. 那咱们可能会这么写.ui

var obj = {};

setTimeout(() => {
	obj.sayHello = function () {
		console.log('hello');
	};
}, 3000);
复制代码

如今咱们过了 3 秒才实例化了 sayHello 方法, 的确知足了咱们前面说的, 在运行时某一时刻实例化的需求, 至少咱们的启动速度提高了那么一点. 那假如如今咱们但愿这个某一时刻不是 3 秒, 而是咱们调用 sayHello 的时候呢? 换句话说, 若是调用 sayHello 时它尚未实例化, 则咱们先实例化它, 再调用它.

Proxy 咱们能够这样写.

var obj = {};

var pobj = new Proxy(obj, {
	get(target, key) {
		if (key === 'sayHello') {
			if (!target[key]) {
				target[key] = function () {
					console.log('hello');
				};
			}
			return target[key];
		}
	}
});

pobj.sayHello();
复制代码

很好, 这样咱们就实现了在调用时实例化, 而且只实例化一次 sayHello 以后不会重复实例化. 可是也有人会说, 这不就是一个 getter 吗? 这种事情用 Object.defineProperty() 也能作到. 好比.

var obj = {};

Object.defineProperty(obj, 'sayHello', {
	get() {
		if (!this._sayHello) {
			this._sayHello = function () {
				console.log('hello');
			};
		}
		return this._sayHello;
	}
});

obj.sayHello();
复制代码

的确, 从这个角度来看, 使用 Proxy 和使用 Object.defineProperty() 几乎没什么区别. 而另外一方面是, 尽管这两种方法是没有在初始化的时候实例化 sayHello 而是把这一过程推迟到调用 sayHello 的时刻了, 可是使用 Proxy 要建立一个代理对象, 使用 Object.defineProperty() 也要执行一次函数调用, 它们的开销可能比初始化时候使用一个函数表达式来得更大, 这有什么意义?

get trap 不只仅是 getter

前面的例子中咱们遇到了两个问题, 一个是 Object.defineProperty() 某种意义上也能完成 Proxy 同样的功能, 那 Proxy 有什么意义? get trap 有什么意义? 另外一个是建立一个 Proxy 对象的开销并不必定比使用一个函数表达式来得小, 这又有什么意义?

为了回答这两个问题, 如今咱们考虑 obj 不只仅有一个 sayHello 方法, 它有成百上千个方法, 每一个方法打印了方法名. 使用 Proxy 的话, 咱们能够这样写.

var obj = {};

var pobj = new Proxy(obj, {
	get(target, key) {
		if (!target[key]) {
			target[key] = function () {
				console.log(key);
			};
		}
		return target[key];
	}
});

pobj.sayHello();
pobj.sayGoodBye();
// sayHello
// sayGoodBye
复制代码

依旧简短. 那用 Object.defineProperty() 呢? 不可能实现, 而即使是肯定只有 100 个方法, 而且它们名字肯定, 也须要调用 100 次 Object.defineProperty(), 对于函数表达式来讲, 也是同样的. 而从开销的角度来看呢? 这时候 Proxy 依然只建立了一个代理对象, 而即使是可使用 Object.defineProperty() 或函数表达式, 它们也要调用成百上千次.

当咱们使用 obj.xxx() 去调用一个 xxx() 方法时, obj 对象自己并不知道本身是否具备 xxx() 方法, 而反射就像是一面镜子, 让 obj 可以知道本身是否具备 xxx() 方法, 而且根据状况作出对应的处理.

尽管咱们能够在运行时经过 Object.defineProperty() 或函数表达式动态地为 obj 对象添加方法, 但这是由于咱们知道 obj 在那个时候是否存在对应方法, 而不是 obj 自己知道本身当时是否存在对应方法. 换句话说, 咱们在使用对象的方法时, 老是要先知道方法名, 哪怕可以在运行时知道, 可是[知道]这个动做也必须发生在[方法调用]这个动做以前. 这就致使了一些现实问题难以被优雅地解决.

好比前面的 obj 对象是咱们暴露的 API, 给用户使用, 它的方法都是按需实例化的. 若是没有 Proxy, 则用户何时调用 obj 的方法咱们是不知道的, 因此[知道]这一动做是不可能在[方法调用]以前, 咱们也就没办法按需实例化. 固然用户是可以在[方法调用]以前[知道]何时会有方法调用的, 但咱们不可能让用户本身来实例化方法.

从编译器角度来看, Proxy 是拦截了对象全部属性的右值查询, 而 Object.defineProperty() 则只是拦截了特定属性的右值查询, 这意味着 Object.defineProperty() 必须知道属性名这一信息, 而 Proxy 则不须要知道.

前置代理和后置代理

大部分时候咱们使用的都是前置代理, 即咱们把直接和代理对象进行交互(全部操做都发生在代理对象身上)的方式叫作前置代理. 那什么是后置代理? 看代码.

var pobj = new Proxy({}, {
	get(target, key) {
		if (!target[key]) {
			target[key] = function () {
				console.log(key);
			};
		}
		return target[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayGoodBye();
复制代码

借助原型链机制, 咱们直接和 obj 进行交互而不是和代理对象进行交互, 只有当 obj 不存在对应方法时才会经过原型链去查找代理对象.

能够看出来的是, 对于本来存在于目标对象(target)上的属性, 使用代理前置开销更大, 由于明明已经具备对应属性了却还要通过一次代理对象, 而使用代理后置开销更小. 对于那些不存在的属性, 使用后置代理开销更大, 由于不只要通过原型链查找还要通过一次代理对象, 而使用前置代理只须要通过一次代理对象. 固然也可能引擎有特殊的优化技巧使得这种性能差别并不明显, 因此也看我的喜欢采用哪一种方式吧.

Reflect

讲了这么多都是在讲 Proxy, 那 Reflect 呢? 它和之前的一些方法只有一些细微差异, 因此它的意义是什么? 有什么用?

Reflect 的方法和 Proxy 的方法是成对出现的, 和之前的一些方法相比, Reflect 的方法对参数的处理不一样或返回值不一样, 尽管很细微的差异, 可是当和 Proxy 配合使用的时候, 使用之前的方法可能致使 Proxy 对象和普通对象的一些行为不一致, 而使用 Reflect 则不会有这样的问题, 因此建议在 Proxy 中都使用 Reflect 的对应方法.

另外一方面是 Reflect 暴露的 API 相对更加底层, 性能会好一些.

最后是有些事情只能经过 Reflect 实现, 具体参考这个例子. 可是我的感受这个例子并非很好, 毕竟这个场景太少见了.

让咱们先来回顾一下前面后置代理的例子.

var pobj = new Proxy({}, {
	get(target, key) {
		if (!target[key]) {
			target[key] = function () {
				console.log(key);
			};
		}
		return target[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayGoodBye();
复制代码

在这个例子中, 调用 obj 上一开始不存在的方法最终都会经过原型链找到代理对象, 进而找到 target 也即空对象, 而后对空对象实例化对应的方法. 这里的原型链查找老是让人感受不太爽, 明明进入到 get trap 就确定说明 obj 一开始不存在对应方法, 那咱们理应能够在这时候给 obj 设置对应方法, 这样下次调用的时候就不会进行原型链的查找了, 为何非要给那个毫无卵用的空对象设置方法, 致使每次对 obj 进行方法调用仍是要进行原型链查找?

因而咱们想起 get trap 还有个 receiver 参数, 大多数地方都写着 receiver 就是代理对象, 也即咱们这里的 pobj, 其实不是, 准确说它是实际发生属性查找的对象, 也即咱们这里的 obj, 有点像 DOM 事件中 event.target 的意思.

因而咱们立刻将原有的写法改为这样.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!receiver[key]) {
			receiver[key] = function () {
				console.log(key);
			};
		}
		return receiver[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
// RangeError: Maximum call stack size exceeded
复制代码

看上去没什么毛病, 而后咱们立马获得一个堆栈溢出的错误. 仔细看看咱们发现关键问题就出在这个 receiver[key], 它对 obj.sayHello 进行了查找, 但此时 obj.sayHello 还未实例化, 因而无限对 obj.sayHello 进行查找, 最终致使堆栈溢出.

这里出现问题的根本缘由是 a[b] 这样的取值操做妥妥地会触发 Proxy 的 get trap 的, 由于 Proxy 是更为底层的存在, 可是仔细想一想咱们的需求其实不是为了取值, 而是为了知道 obj 自身是否存在 sayHello 属性, 从这一点来讲, 咱们不必使用 a[b] 这样的方式来判断, 咱们能够用 hasOwnProperty(). 因而继续改造.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!receiver.hasOwnProperty(key)) {
			receiver[key] = function () {
				console.log(key);
			};
		}
		return receiver[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
// RangeError: Maximum call stack size exceeded
复制代码

仍是堆栈溢出, 由于 hasOwnProperty() 实际上是 Object.prototype.hasOwnProperty(), 意味着在原型链的尽头, 而 pobj 在原型链上更近的位置, 因而至关于 receiver/obj 并不存在 hasOwnProperty(), 因而变成了对 obj.hasOwnProperty() 无限查找致使堆栈溢出.

那继续吧, 咱们直接用 Object.prototype.hasOwnProperty() 总行了吧.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!Object.prototype.hasOwnProperty.call(receiver, key)) {
			receiver[key] = function () {
				console.log(key);
			};
		}
		return receiver[key];
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayHello();
// sayHello
// sayHello
复制代码

到这里其实问题已经解决了, 咱们的后置代理只会在第一次未实例化方法时进行原型链查找, 以后调用 obj.sayHello() 都是直接和 obj 进行交互, 既没有原型链查找也没有代理. 那这和 Reflect 有什么关系?

其实这里用 Reflect 会更好一点, 一方面相对于长长的 Object.prototype.hasOwnProperty.call 来讲会更短更直观, 一方面性能也好一点(反正 Node 源码中是把 call 换成了 Reflect).

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (!Reflect.has(receiver, key)) {
			Reflect.set(receiver, key, function () {
				console.log(key);
			});
			return Reflect.get(receiver, key);
		} else {
			return Reflect.get(target, key);
		}
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayHello();
console.log(obj.hasOwnProperty('sayHello'));
复制代码

最终咱们改为了这样子, 和前面又稍稍有一些不同, 有个 else 把非 obj 自身的属性查找转发给了 target, 由于后面有个 hasOwnProperty() 调用, 若是不转发给 target 的话, 则致使继承自 Object 的属性和方法全都会产生堆栈溢出.

后续补充: 这里我犯了两个错误, 为了说明这个错误因此前面的内容再也不修改, 看成标本.

先让咱们来看看最终版本的 if (!Reflect.has(receiver, key)) 这段逻辑和以前的 if (!receiver[key]), 咱们说, 最终咱们但愿的是检测对应属性是否存在, 这话严格来讲也不算错. 但每一个人对存在的定义可能都不一样, 有人认为 receiver[key] === undefined 就算不存在, 而若是有人以为 Reflect.has(receiver, key)false 算不存在, 但其实它们是很不同的. 这里咱们准肯定义应该是, receiver[key] === undefined 是作的可用性检测, 而 Reflect.has(receiver, key) 是作的存在性检测. 因此这里用 Reflect.has(receiver, key) 严格来讲也不能算错, 可是很容易被人忽视的一点就是, 在后置代理中, receiver 对象的任何存在但不可用的属性, 都会致使没法委托到原型链上的代理对象. 这也算是使用后置代理的一点限制吧.

而第二个错误, 则是实实在在的错误了. 前面说过, 一旦进入到 get trap 就确定说明 obj 一开始不存在对应方法, 既然咱们已经知道不存在对应方法了, 那为何还要用 if (!Reflect.has(receiver, key)) 作存在性检测? 因此这步逻辑是多余的. 可是另外一方面是, 不少 Object.prototype 上的方法, 其实 receiver 也是不存在的, 因此当调用这些方法的时候也是会进入到 get trap 的, 咱们依旧须要把它们转发到 target 上去. 因而咱们应当写成这样.

var pobj = new Proxy({}, {
	get(target, key, receiver) {
		if (Reflect.has(target, key)) {
			return Reflect.get(target, key);
		}
		Reflect.set(receiver, key, function () {
			console.log(key);
		});
		return Reflect.get(receiver, key);
	}
});

var obj = Object.create(pobj);
obj.sayHello();
obj.sayHello();
console.log(obj.hasOwnProperty('sayHello'));
复制代码

其实也没有省太多事就是了, 虽然咱们去掉了一个判断, 可是为了保证继承自 Object 的方法正常使用, 又引入了一个新的判断, 看上去只是把 if-else 中的逻辑调换了位置而已, 不过逻辑上讲, 这样更合理一些吧.

其余细节

对于数组使用代理的话, get trap 和 set trap 也能够拦截到数组方法, 好比 forEach push 等, 由于实际上这些方法也会对数组使用如 arr[index] 这样的形式去获取和设置值.

另外 Proxy 的各个 trap 中的 this 均是指向 handler 对象, 而不是代理对象, 也不是目标对象, 而 trap 中返回函数(若是能够返回一个函数的话)的 this 指向的是代理对象而不是目标对象. 即

var obj = {}, handler = {
	get(target, key, receiver) {
		console.log(this === target);
		console.log(this === receiver);
		console.log(this === handler);
	}
};

var pobj = new Proxy(obj, handler);
pobj.name;
// false
// false
// true


var obj = {}, handler = {
	get(target, key, receiver) {
		return function () {
			console.log(this === target);
			console.log(this === receiver);
			console.log(this === handler);
		};
	}
};

var pobj = new Proxy(obj, handler);
pobj.test();
// false
// true
// false
复制代码

这里也顺便提下 Object.defineProperty()this 的处理. Object.defineProperty() 的 getter/setter 中的 this 指向的是目标对象而非属性描述符对象, 若是 getter 中返回函数, 则函数的 this 也是指向目标对象.

var obj = {
	name: 'aaa'
};

Object.defineProperty(obj, 'test', {
	get() {
		console.log(this.name);
	}
});

obj.test;
// aaa


var obj = {
	name: 'aaa'
};

Object.defineProperty(obj, 'test', {
	get() {
		return function () {
			console.log(this.name);
		};
	}
});

obj.test();
// aaa
复制代码

参考资料

相关文章
相关标签/搜索