@gyyin
2020-03-14T14:39:31.000000Z
字数 14220
阅读 289
慕课专栏
只听空相大声道:“请道长立即禀报张真人,事在紧急,片刻延缓不得!”那道人道:“大师来得不巧,敝师祖自去岁坐关,至今一年有余,本派弟子亦已久不见他老人家慈范。”
在武侠小说中,经常看到这样的桥段。某位武林人士前来拜访德高望重的帮派掌门,往往需要经过手下弟子的通报。如果掌门外出或者不想见来人,就会让弟子婉拒。
今天要讲的 Proxy 和这个有异曲同工之妙。顾名思义,Proxy 的意思是代理,作用是为其他对象提供一种代理以控制对这个对象的访问。
本文会涉及到 Proxy 和 Reflect、Function、扩展运算符 等知识,主要以实践为主,对语法不会进行详细地讲解,建议配合阮一峰的 ES6入门 中相关章节服用。
Proxy 常常被用于修改某些操作的默认行为,这种将代码视为数据,通过编写代码来生成代码,使程序获得额外能力的方式叫做元编程。更简单地理解起来,那就是“一段自动写程序的程序”。
在 JavaScript 中,eval
和 new Function
就是两种典型的用于元编程的特性。而 ES6 新出现的 Proxy 也是可以进行元编程的特性。
比如下面这段代码,通过 eval
自动生成一句声明 person
变量的语句:
eval("var person = 'ygy'")
在 Underscore 的 template 方法实现中,就使用到 new Function
来将传入的数据和 JavaScript 代码字符串解析成一段可执行的脚本。
// source是收集的模板语法
source = "var __p = '';" + source + 'return __p;'
// 使用with可以修改作用域
if (!settings.variable) source = "with(obj||{}) {\n" + source + "\n}"
var render = new Function(settings.variable || "obj", source);
如果对如何编写模板感兴趣,可以参考一下我的这篇文章: 60行代码实现简单模板语法
Proxy 一般是用来架设在目标对象之上的一层拦截,来实现对目标对象访问和修改的控制。Proxy 是一个构造函数,使用的时候需要配合 new
操作符,直接调用会报错。
Proxy 构造函数接收两个参数,第一个参数是需要拦截的目标对象,这个对象只可以是对象、数组或者函数;第二个参数则是一个配置对象,提供了拦截方法,如果这个配置对象为空对象,那么返回的 Proxy 实例就是原来的目标对象。
const person = {
name: 'tom'
}
// 如果第二个参数为空对象
const proxy = new Proxy(person, {});
proxy === person; // true
// 第二个参数不为空
const proxy = new Proxy(person, {
get(target, prop) {
console.log(`${prop} is ${target[prop]}`);
return target[prop];
}
})
proxy.name // 'name is tom'
Proxy 支持13种拦截操作,这里重点介绍其中四种。
proxy()
。new proxy()
。为了使 new操作符在生成的Proxy对象上生效,用于初始化代理的目标对象自身必须具有[[Construct]]内部方法(即 new target 必须是有效的)。delete proxy[prop]
的操作,返回一个布尔值。Object.getOwnPropertyNames(proxy)
、Object.keys(proxy)
、for in
循环等等操作,最终会返回一个数组。Object.getOwnPropertyDescriptor(proxy, propKey)
,返回属性的描述对象。Object.defineProperty(proxy, propKey, propDesc
)、Object.defineProperties(proxy, propDescs)
,返回一个布尔值。Object.preventExtensions(proxy)
,返回一个布尔值。Object.getPrototypeOf(proxy)
,返回一个对象。Object.isExtensible(proxy)
,返回一个布尔值。Object.setPrototypeOf(proxy, proto)
,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。在 Proxy 出现之前,JavaScript 中就提供过 Object.defineProperty
,允许对对象的 getter/setter
进行拦截,那么两者的区别在哪里呢?
Object.defineProperty
无法一次性监听对象所有属性,必须遍历或者递归来实现。
let girl = {
name: "ltt",
age: 22
}
/* Proxy 监听整个对象*/
girl = new Proxy(girl, {
get() {}
set() {}
})
/* Object.defineProperty */
Object.keys(girl).forEach(key => {
Object.defineProperty(girl, key, {
set() {},
get() {}
})
})
Proxy
可以监听到新增加的属性,而 Object.defineProperty
不可以,需要你手动再去做一次监听。因此,在 vue 中想动态监听属性,一般用 Vue.set(girl, "hobby", "game")
这种形式来添加。
let girl = {
name: "ltt",
age: 22
}
/* Proxy 监听整个对象*/
girl = new Proxy(girl, {
get() {}
set() {}
})
/* Object.defineProperty */
Object.keys(girl).forEach(key => {
Object.defineProperty(girl, key, {
set() {},
get() {}
})
});
/* Proxy 生效,Object.defineProperty 不生效 */
girl.hobby = "game";
Proxy
可以监听数组的变化,Object.defineProperty
无法对 push
、shift
、pop
、unshift
等方法进行响应。
const arr = [1, 2, 3];
/* Proxy 监听数组*/
arr = new Proxy(arr, {
get() {},
set() {}
})
/* Object.defineProperty */
arr.forEach((item, index) => {
Object.defineProperty(arr, `${index}`, {
set() {},
get() {}
})
})
arr[0] = 10; // 都生效
arr[3] = 10; // 只有 Proxy 生效
arr.push(10); // 只有 Proxy 生效
对于新增加的数组项,Object.defineProperty
依旧无法监听到。因此,在 mobx 中为了监听数组的变化,默认将数组长度设置为1000,监听 0-999 的属性变化。
/* mobx 的实现 */
const arr = [1, 2, 3];
/* Object.defineProperty */
[...Array(1000)].forEach((item, index) => {
Object.defineProperty(arr, `${index}`, {
set() {},
get() {}
})
});
arr[3] = 10; // 生效
arr[4] = 10; // 生效
如果想要监听到 push
、shift
、pop
、unshift
等方法,该怎么做呢?在 vue 和 mobx 中都是通过重写原型实现的。
在定义变量的时候,判断其是否为数组,如果是数组,那么就修改它的 __proto__
,将其指向 subArrProto
,从而实现重写原型链。
const arrayProto = Array.prototype;
const subArrProto = Object.create(arrayProto);
const methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push'];
methods.forEach(method => {
/* 重写原型方法 */
subArrProto[method] = function() {
arrayProto[method].call(this, ...arguments);
};
/* 监听这些方法 */
Object.defineProperty(subArrProto, method, {
set() {},
get() {}
})
})
Proxy
提供了13种拦截方法,包括拦截 constructor
、apply
、deleteProperty
等等,而 Object.defineProperty
只有 get
和 set
。Object.defineProperty
的兼容性更好,Proxy 是新出的 API,兼容性还不够好。get 方法用来拦截对目标对象属性的读取,它接收三个参数,分别是目标对象、属性名和 Proxy 实例本身。
基于 get 方法的特性,可以实现很多实用的功能,比如在对象里面设置私有属性(一般定义属性我们以 _
开头表明是私有属性) ,实现禁止访问私有属性的功能。
const person = {
name: 'tom',
age: 20,
_sex: 'male'
}
const proxy = new Proxy(person, {
get(target, prop) {
if (prop[0] === '_') {
throw new Error(`${prop} is private attribute`);
}
return target[prop]
}
})
proxy.name; // 'tom'
proxy._sex; // _sex is private attribute
还可以给对象中未定义的属性设置默认值。通过拦截对属性的访问,如果是 undefined
,那就返回最开始设置的默认值。
let person = {
name: 'tom',
age: 20
}
const defaults = (obj, initial) => {
return new Proxy(obj, {
get(target, prop) {
if (prop in target) {
return target[prop]
}
return initial
}
})
}
person = defaults(person, 0);
person.name // 'tom'
person.sex // 0
person = defaults(person, null);
person.sex // null
set 方法可以拦截对属性的赋值操作,一般来说接收四个参数,分别是目标对象、属性名、属性值、Proxy 实例。
下面是一个 set 方法的用法,在对属性进行赋值的时候打印出当前状态。
const proxy = new Proxy({}, {
set(target, key, value, receiver) {
console.log(`${key} has been set to ${value}`);
Reflect.set(target, key, value);
}
})
proxy.name = 'tom'; // name has been setted ygy
第四个参数 receiver
则是指当前的 Proxy 实例,在下例中指代 proxy
。
const proxy = new Proxy({}, {
set(target, key, value, receiver) {
if (key === 'self') {
Reflect.set(target, key, receiver);
} else {
Reflect.set(target, key, value);
}
}
})
proxy.self === proxy; // true
如果你写过表单验证,也许会被各种验证规则搞得很头疼。使用 Proxy 可以在填写表单的时候,拦截其中的字段进行格式校验。
通常来说,大家都会用一个对象来保存验证规则,这样会更容易对规则进行扩展。
// 验证规则
const validators = {
name: {
validate(value) {
return value.length > 6;
},
message: '用户名长度不能小于六'
},
password: {
validate(value) {
return value.length > 10;
},
message: '密码长度不能小于十'
},
moblie: {
validate(value) {
return /^1(3|5|7|8|9)[0-9]{9}$/.test(value);
},
message: '手机号格式错误'
}
}
然后编写验证方法,用 set 方法对 form
表单对象设置属性进行拦截,拦截的时候用上面的验证规则对属性值进行校验,如果校验失败,则弹窗提示。
// 验证方法
function validator(obj, validators) {
return new Proxy(obj, {
set(target, key, value) {
const validator = validators[key]
if (!validator) {
target[key] = value;
} else if (validator.validate(value)) {
target[key] = value;
} else {
alert(validator.message || "");
}
}
})
}
let form = {};
form = validator(form, validators);
form.name = '666'; // 用户名长度不能小于六
form.password = '113123123123123';
但是,如果这个属性已经设置为不可写,那么 set 将不会生效(但 set 方法依然会执行)。
const person = {
name: 'tom'
}
Object.defineProperty(person, 'name', {
writable: false
})
const proxy = new Proxy(person, {
set(target, key, value) {
console.log(666)
target[key] = 'jerry'
}
})
proxy.name = '';
apply 一般是用来拦截函数的调用,它接收三个参数,分别是目标对象、上下文对象(this)、参数数组。
function test() {
console.log('this is a test function');
}
const func = new Proxy(test, {
apply(target, context, args) {
console.log('hello, world');
target.apply(context, args);
}
})
func();
通过 apply 方法可以获取到函数的执行次数,也可以打印出函数执行消耗的时间,常常可以用来做性能分析。
function log() {}
const func = new Proxy(log, {
_count: 0,
apply(target, context, args) {
target.apply(context, args);
console.log(`this function has been called ${++this._count} times`);
}
})
func()
construct 方法用来拦截 new 操作符。它接收三个参数,分别是目标对象、构造函数的参数列表、proxy 对象,最后需要返回一个对象。
使用方式可以参考下面这么一个例子:
function Person(name, age) {
this.name = name;
this.age = age;
}
const P = new Proxy(Person, {
construct(target, args, newTarget) {
console.log('construct');
return new target(...args);
}
})
const p = new P('tom', 21); // 'construct'
我们知道,如果构造函数没有返回任何值或者返回了原始类型的值,那么默认返回的就是 this
,如果返回的是一个引用类型的值,那么最终 new
出来的就是这个值。
因此,你可以代理一个空函数,然后返回一个新的对象。
function noop() {}
const Person = new Proxy(noop, {
construct(target, args, newTarget) {
return {
name: args[0],
age: args[1]
}
}
})
const person = new Person('tom', 21); // { name: 'tom', age: 21 }
Proxy 的使用场景非常广泛,可以用来拦截对象的 set/get 从而实现数据响应。在 Vue3 和 Mobx5 中都使用了 Proxy 代替 Object.defineProperty
。那么接下来就来看看 Proxy 都可以做哪些事情吧。
使用 construct 可以代理类,你可能会好奇,Proxy 不是只能代理 Object 类型吗?类该怎么代理呢?
其实类的本质也是构造函数和原型(对象)组成的,完全可以对其进行代理。
考虑有这么一个需求,需要拦截对属性的访问,以及计算原型上函数的执行时间,这样该怎么去做就比较清晰了。可以对属性设置 get 拦截,对原型函数设置 apply 拦截。
先考虑对下面的 Person
类的原型函数进行拦截。使用 Object.getOwnPropertyNames
来获取原型上面所有的函数,遍历这些函数并对其使用 apply 拦截。
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
say() {
console.log(`my name is ${this.name}, and my age is ${this.age}`)
}
}
const prototype = Person.prototype;
// 获取 prototype 上所有的属性名
Object.getOwnPropertyNames(prototype).forEach((name) => {
Person.prototype[name] = new Proxy(prototype[name], {
apply(target, context, args) {
console.time();
target.apply(context, args);
console.timeEnd();
}
})
})
拦截了原型函数后,开始考虑拦截对属性的访问。前面刚刚讲过 construct 方法的作用,那么是不是可以在 new 的时候对所有属性的访问设置拦截呢?
没错,由于 new 出来的实例也是个对象,那么完全可以对这个对象进行拦截。
new Proxy(Person, {
// 拦截 construct 方法
construct(target, args) {
const obj = new target(...args);
// 返回一个代理过的对象
return new Proxy(obj, {
get(target, prop) {
console.log(`${target.name}.${prop} is being getting`);
return target[prop]
}
})
}
})
所以,最后完整的代码如下:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
say() {
console.log(`my name is ${this.name}, and my age is ${this.age}`)
}
}
const proxyTrack = (targetClass) => {
const prototype = targetClass.prototype;
Object.getOwnPropertyNames(prototype).forEach((name) => {
targetClass.prototype[name] = new Proxy(prototype[name], {
apply(target, context, args) {
console.time();
target.apply(context, args);
console.timeEnd();
}
})
})
return new Proxy(targetClass, {
construct(target, args) {
const obj = new target(...args);
return new Proxy(obj, {
get(target, prop) {
console.log(`${target.name}.${prop} is being getting`);
return target[prop]
}
})
}
})
}
const MyClass = proxyTrack(Person);
const myClass = new MyClass('tom', 21);
myClass.say();
myClass.name;
平时取数据的时候,经常会遇到深层数据结构,如果不做任何处理,很容易造成 JS 报错。
为了避免这个问题,也许你会用多个 && 进行处理:
const country = {
name: 'china',
province: {
name: 'guangdong',
city: {
name: 'shenzhen'
}
}
}
const cityName = country.province
&& country.province.city
&& country.province.city.name;
但这样还是过于繁琐了,于是 lodash 提供了 get 方法帮处理这个问题:
_.get(country, 'province.city.name');
虽然看起来似乎还不错,但总觉得哪里不太对。对,这种写法看起来很不自然,有没有更自然的写法呢?
最新的 ES 提案中提供了可选链的语法糖,支持我们用下面的语法来深层取值。
country?.province?.city?.name
但是这个特性只是处于 stage3阶段,还没有被正式纳入 ES 规范中,更没有浏览器已经支持了这个特性。
所以,我们只能另辟蹊径。这时你可能会想到如果使用 Proxy 的 get 方法拦截对属性的访问,这样是不是就可以实现深层取值了呢?
接下来,我将会带着你一步步实现下面的这个 get 方法。
const obj = {
person: {}
}
// 预期结果(这里为什么要当做函数执行呢?)
get(obj)() === obj;
get(obj).person(); // {}
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined
首先,创建一个 get 方法,使用 Proxy 中的 get 对传入的对象进行拦截。
function get (obj) {
return new Proxy(obj, {
get(target, prop) {
return target[prop];
}
})
}
来运行一下上面的三个例子,看一下结果如何:
get(obj).person; // {}
get(obj).person.name; // undefined
get(obj).person.name.xxx.yyy.zzz; // Cannot read property 'xxx' of undefined
前两个测试用例是成功了,但第三个还是不行,因为 get(obj).person.name
是 undefined
,所以接下来的重点是处理属性为 undefined
的情况。
对这个 get 方法进行一下简单的改造,这次不再直接返回 target[prop]
,而是返回一个代理对象,让第三个例子不再报错。
function get (obj) {
return new Proxy(obj, {
get(target, prop) {
return get(target[prop]);
}
})
}
嗯,看起来有点儿高大上了,但是 target[prop]
为 undefined
的时候,传给 get 方法的就是 undefined
了,而 Proxy 第一个参数必须为对象,这样岂不是会报错?
所以,需要对 obj 为 undefined
的时候进行特殊处理,为了能够深层取值,只能对值为 undefined
的属性设置默认值为空对象。
function get (obj = {}) {
return new Proxy(obj, {
get(target, prop) {
return get(target[prop]);
}
})
}
get(obj).person; // {}
get(obj).person.name; // {}
get(obj).person.name.xxx.yyy.zzz; // {}
虽然不报错了,可是后两个返回值却不对了。不设置默认值为空对象就无法继续访问,设置默认值为空对象就会改变返回值。这可该怎么办呢?
仔细看一下上面的预期设计,是不是发现少了一个括号,这就是为什么每个属性都被当做函数来执行。
所以需要对这个函数稍加修改,让其支持 apply 拦截的方式。
function noop() {}
function get (obj) {
// 注意这里拦截的是 noop 函数
return new Proxy(noop, {
// 这里支持返回执行的时候传入的参数
apply(target, context, [arg]) {
return obj;
},
get(target, prop) {
return get(obj[prop]);
}
})
}
所以这个 get 方法已经可以这样使用了。
get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // Cannot read property 'xxx' of undefined
我们理想中的应该是,如果属性为 undefined
就返回 undefined
,但仍要支持访问下级属性,而不是抛出错误。顺着这个思路来的话,很明显当属性为 undefined
的时候也需要用 Proxy 进行特殊处理。
所以我们需要一个具有下面特性的 get 方法:
get(undefined)() === undefined; // true
get(undefined).xxx.yyy.zzz() // undefined
和前面的困扰不一样的地方是,这里完全不需要注意 get(undefined).xxx
是否为正确的值,因为想获取值必须要执行才能拿到。那么只需要对所有 undefined
后面访问的属性都默认为 undefined
就好了。
function noop() {}
function get (obj) {
if (obj === undefined) {
return proxyVoid;
}
// 注意这里拦截的是 noop 函数
return new Proxy(noop, {
// 这里支持返回执行的时候传入的参数
apply(target, context, [arg]) {
return obj === undefined ? arg : obj;
},
get(target, prop) {
if (
obj !== undefined &&
obj !== null &&
obj.hasOwnProperty(prop)
) {
return get(obj[prop]);
}
return proxyVoid;
}
})
}
接下来思考一下这个 proxyVoid
函数该如何实现呢?很明显它应该是一个代理了 undefined
后返回的对象。直接这样好不好?
const proxyVoid = get(undefined);
但是这样很明显会造成死循环了,那么就需要判断临界值了,让 get 方法第一次接收 undefined
的时候不会死循环。
let isFirst = true;
function noop() {}
let proxyVoid = get(undefined);
function get(obj) {
if (obj === undefined && !isFirst) {
return proxyVoid;
}
if (obj === undefined && isFirst) {
isFirst = false;
}
// 注意这里拦截的是 noop 函数
return new Proxy(noop, {
// 这里支持返回执行的时候传入的参数
apply(target, context, [arg]) {
return obj === undefined ? arg : obj;
},
get(target, prop) {
if (
obj !== undefined &&
obj !== null &&
obj.hasOwnProperty(prop)
) {
return get(obj[prop]);
}
return proxyVoid;
}
})
}
我们再来验证一下,这种方式是否可行:
get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined
bingo,这个方法完全实现了我们的需求。最后,完整的代码如下:
let isFirst = true;
function noop() {}
let proxyVoid = get(undefined);
function get(obj) {
if (obj === undefined) {
if (!isFirst) {
return proxyVoid;
}
isFirst = false;
}
// 注意这里拦截的是 noop 函数
return new Proxy(noop, {
// 这里支持返回执行的时候传入的参数
apply(target, context, [arg]) {
return obj === undefined ? arg : obj;
},
get(target, prop) {
if (
obj !== undefined &&
obj !== null &&
obj.hasOwnProperty(prop)
) {
return get(obj[prop]);
}
return proxyVoid;
}
})
}
this.get = get;
这个基于 Proxy 的 get 方法的灵感来自于 Github 上的一个名为 safe-touch 的库,感兴趣的可以去看一下它的源码实现:safe-touch
在最新的 ECMA 提案中,出现了原生的管道操作符 |>
,在 rxjs 和 nodejs 中都有类似的 pipe
概念。
使用 Proxy 也可以实现 pipe
功能,只要使用 get 对属性访问进行拦截就能轻易实现,将访问的方法都放到 stack
数组里面,一旦最后访问了 execute
就返回结果。
const pipe = (value) => {
const stack = [];
const proxy = new Proxy({}, {
get(target, prop) {
if (prop === 'execute') {
return stack.reduce(function (val, fn) {
return fn(val);
}, value);
}
stack.push(window[prop]);
return proxy;
}
})
return proxy;
}
var double = n => n * 2;
var pow = n => n * n;
pipe(3).double.pow.execute;
注意:这里为了在
stack
存入方法,使用了window[prop]
的形式,是为了获取到对应的方法。也可以将double
和pow
方法挂载到一个对象里面,用这个对象替换window
。
众所周知,Vue 相对于 React 的一个卖点就是 Reactive ,而 React 的状态管理库 Mobx 也是基于依赖追踪的,这两个库的核心实现都是 Object.defineProperty
,后来都改为了 Proxy。
那么,接下来会带着你实现一个简单的类似下面的双向绑定。
我们的 HTML 结构如下,要求在输入框输入内容的时候,会同步显示在下方的 span
中。
<input type="text" id="input" />
<span id="span"></span>
传统的事件绑定形式实现这个很简单,只需要对 input
绑定事件,一旦输入内容就去修改 span
的 innerHTML
。但这里我们想使用数据驱动的形式,用数据绑定视图的形式来做。
所以,这里完全可以使用 Proxy 监听一个对象,当在输入框输入内容的时候去修改这个对象属性的时候,在 set 函数中修改 span
的内容。
let obj = {
text: '1'
};
const input = document.getElementById('input'),
span = document.getElementById('span');
obj = new Proxy(obj, {
get: function() {
console.log('get val');
},
set: function(target, prop, newVal) {
if (prop === 'text') {
target[prop] = newVal;
input.value = newVal;
span.innerHTML = newVal;
}
}
});
input.addEventListener('keyup', function(e){
obj.text = e.target.value;
})
这个功能就完美实现了,但是这个代码缺陷有很多:
span
中也要展示输入内容呢?每次修改 set 方法不符合开闭原则。而在 Vue 中,还增加了发布订阅的形式来实现双向绑定,这里篇幅有限,就不具体展示讲解了。感兴趣的同学可以参考一下这篇文章:实现双向绑定Proxy比defineproperty优劣如何?