反射和代理的具体应用

2,214 阅读14分钟

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

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

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

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

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

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

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

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

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

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

var obj = {};
obj.sayHello = function () {
	console.log('hello');
};

在初始化的时候便定义了 sayHello 方法, 但可能有时候我们觉得这没必要, 毕竟一个函数表达式也是有开销的. 当然你可以说我们直接在字面量里写好 sayHello 不就行了, 为什么一定要用函数表达式? 这里只是演示, 不用在意细节, 总之我们希望在运行时某一时刻再实例化这个 sayHello 方法, 而不是一开始就实例化它, 原因可能是应用启动速度比较重要. 那我们可能会这么写.

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

参考资料