Proxy 代理对象使用详解即原理总结

Proxy简单介绍

ECMAscript 6新增的代理可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用,在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制。代理对象可以作为目标对象的替身,但又完全独立于目标对象。(摘自Javascript高级程序设计)

我的理解就是:Proxy就是使用者和目标对象之间的一个中间商,使用者和对象之间的操作需要中间商代为完成,并且在完成使用者对对象的操作的前提下,中间商可以多加一点操作达到自己的目的。

重点

  1. Proxy 只能代理对象
  2. 代理指的是对一个对象基本语义的代理。读取和设置对象的属性都是基本语义,因为它们是一步就可以完成的操作。而调用一个对象内的函数就不是基本操作,因为它需要两步操作,第一步先拿到函数属性,第二步执行。
  3. 不能用 instanceof 判断代理对象的类型,因为 Proxy.prototype 是 undefined。
  4. 代理也可以代理另外一个代理形成多层拦截,如下例子:
let obj = {name:'孤城浪人'}
let proxy = new Proxy(obj,{
  get(target,property){
    console.log('第一层代理触发');
    return target[property];
  }
});
let proxy1 = new Proxy(proxy,{
  get(target,property){
    console.log('第二层代理触发');
    return target[property];
  }
});
console.log(proxy1.name);

在这里插入图片描述

Proxy 基本使用

代理对象 Proxy 通过 new 操作符实例化,实例化时要传入两个参数,参数一:目标对象;参数二:处理程序对象(捕获器),这两个参数是必须的,少任何一个都会报错。

创建空代理

可以看到在实例化 Proxy 时,传入的处理程序对象为空对象,也就是说本次代理对象(中间商)不会私自做任何操作。

const obj = {
  name: "孤城浪人",
  age: 18,
  sex: "男",
};
const ProxyObj = new Proxy(obj, {});

简单代理

get 拦截参数:

  1. 要操作的目标对象,下面的例子是 obj;
  2. 要操作的属性名;
  3. 代理对象(可选);

set 拦截参数:

  1. 要操作的目标对象,下面的例子是 obj;
  2. 要操作的属性名;
  3. 新值;
  4. 接收最初赋值的对象(可选)。
const obj = {
  name: "孤城浪人",
  age: 18,
  sex: "男",
};
const ProxyObj = new Proxy(obj, {
  get(target, property, ProxyTarget) {
    console.log(`get触发,读取${property}属性`);
    return target[property];
  },
  set(target, property, value, receiver) {
    console.log(`set触发,赋值${property}属性,最初赋值对象:`,receiver);
    target[property] = value;
  },
});
console.log(ProxyObj.name); 
console.log(ProxyObj.age);
ProxyObj.sex = "女";

结果
在这里插入图片描述

注意:在 set 的时候,输出了第四个参数为代理对象 ProxyObj,这是因为我的赋值语句就是赋值给 ProxyObj.sex,所以 ProxyObj 就是最初赋值的对象。

捕获器不变式

每个捕获器都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循“捕获器不变式”,防止捕获器定义出现过于反常的行为(摘自Javascript高级程序设计)。

我的理解就是在给对象添加属性时可以给它添加描述限制属性的行为,那么我们在捕获器里设置的操作就不能违背该属性的描述

例:

const obj = {
  name: "孤城浪人",
};
Object.defineProperty(obj, "age", {
  writable: false,//不可写
  value: 18,
});
const ProxyObj = new Proxy(obj, {
  get(target, property, ProxyTarget) {
    console.log(`get触发,读取${property}属性`);
    return 12;
  },
});
console.log(ProxyObj.name); //12
console.log(ProxyObj.age); // 报错

在这里插入图片描述

由例子的结果可知,当我给对象添加不可写(不能更改属性值)的属性 age=18 时,但是我在代理中返回值是 12 ,与属性值不同,就相当于更改了属性值,这时就会报错。

可撤销代理

既然可以代理对象,那么也可通过 Proxy 暴露的 revocable() 撤销代理对象与目标对象的联系,并且撤销操作不可逆的。撤销函数 revoke() 是幂等的。在撤销之后再调用代理对象会报错。如下例子:

const obj = {
  name: "孤城浪人",
};
const { proxy, revoke } = Proxy.revocable(obj, {
  get(target, property, ProxyTarget) {
    console.log(`get触发,读取${property}属性`);
    return 12;
  },
});
console.log(proxy.name);
revoke();// 撤销代理
console.log(proxy.name);

在这里插入图片描述

代理的 this 问题

当使用 Proxy 代理对象时,目标对象内部的 this 会自动改变为 Proxy 代理对象。也就是 this 变成了 Proxy 代理对象。

const obj = {
  name: "孤城浪人",
  test() {
    console.log(this === proxyObj);
  },
};
const proxyObj = new Proxy(obj, {
  get(target, property) {
    return target[property];
  },
});
obj.test();//false
proxyObj.test();//true

当通过代理对象调用 test 方法时,this 就指向了代理对象 proxyObj,这很合理,因为调用对象的属性函数,那么该函数的 this 就指向该实例。

再来看一个应用的例子:

const obj = {
  _name: "孤城浪人",
  get name() {
    console.log(this)
    return this._name;
  },
};
const proxyObj = new Proxy(obj, {
  get(target, property, receiver) {
    return target[property];
  },
});
const obj1 = {
  __proto__:proxyObj,
  _name:'李四'
}
console.log(obj.name);
console.log(obj1.name);

在这里插入图片描述

当我把 obj1 对象的原型设置为 proxyObj 时,再输出obj1.name,obj1 对象没有该属性,所以会到原型对象 proxyObj 上找,继而会触发 obj 对象的读取 name 的钩子,但是由结果可以看到两次输出 get 钩子的 this 都是 obj,这显然不合理。

此时就需要用到 Reflect 对象来救场了,具体怎么使用可以看这篇文章《你能懂的 JS 之 Reflect 反射》。

捕获器

Proxy 代理可以捕获 13 种不同的基本操作,如下表(参考文章:JS进阶 | Proxy代理对象)

对象中的方法 对应触发条件
handler.getPrototypeOf() Object.getPrototypeOf 方法的捕获器
handler.setPrototypeOf() Object.setPrototypeOf 方法的捕获器
handler.isExtensible() Object.isExtensible 方法的捕获器
handler.preventExtensions() Object.preventExtensions 方法的捕获器
handler.getOwnPropertyDescriptor() Object.getOwnPropertyDescriptor 方法的捕获器
handler.defineProperty() Object.defineProperty 方法的捕获器
handler.has() in 操作符的捕获器
handler.get() 属性读取操作的捕获器
handler.set() 属性设置操作的捕获器
handler.deleteProperty() delete 操作符的捕获器
handler.ownKeys() Object.getOwnPropertyNames方法和 Object.getOwnPropertySymbols 方法的捕获器
handler.apply() 函数被apply调用操作的捕获器
handler.construct() new 操作符的捕获器

关于对象的所有操作都是由这十三种基本操作或它们中的部分组合完成,所以在代理是只要拦截其中一个就能正常工作了。

Proxy 工作原理

我们知道 JS 中一切皆对象,即便函数也是一个对象,并且 Proxy 代理的目标也是对象,但是你真的了解 JS 中的对象吗?

ECMAscript 规范中规定 JS 中有两种对象,常规对象和异质对象(了解即可),任何不属于常规对象的对象都属于异质对象。并且对象的实际语义是由对象的内部方法(内部方法就是我们操作一个对象时引擎内部调用的方法)指定的

例:读取obj.name的值时,引擎内部会调用[[Get]]内部方法读取属性值。这里的[[xxx]]也叫内部槽

ECMAscript 规范要求的所有必要的内部方法如下(摘自 《Vue设计与实现》):

对象必要的内部方法

内部方法 描述
[[GetPrototypeOf]] 查明为该对象提供继承属性的对象, null 代表没有继承属性
[[SetPrototypeOf]] 将该对象与提供继承属性的另一个对象相关联。传递 null 表示没有继承属性,返回 true 表示操作成功完成,返回 false 表示操作失败
[[IsExtensible]] 查明是否允许向该对象添加其他属性
[[PreventExtensions]] 控制能否向该对象添加新属性。如果操作成功则返回 true ,如果操作失败则返 false
[[GetOwnPropertyDescriptor]] 返回该对象自身属性的描述符,其键为 propertyKey ,如果不存在这样的属性,则返回 undeftned
[[DefineProperty]] 创建或更改自己的属性,其键为 propertyKey ,以具有由 PropertyDescriptor 描述的状态。如果该属性已成功创建或更新,则返回 true ;如果无法创建或更新该属性,则返 false
[[HasProperty]] 返回一个布尔值,指示该对象是否已经拥有键为 propertyKey 的自己的或继承的属性
[[Get]] 从该对象返回键为 propertyKey 的属性的值。如果必须运行 ECMAScript 代码来检索属性值,则在运行代码时使用 Receiver 作为 this 值
[[Set]] 将键值为 propertyKey 的属性的值设置为 value 。如果必须运行 ECMAScript 代码来设置属性值,则在运行代码时使用 Receiver 作为 this 值。如果成功设置了属性值,则返回 true ;如果无法设置,则返回 false
[[Delete]] 从该对象中删除属于自身的键为 propertyKey 的属性。如果该属性未被删除并且仍然存在,则返回 false ;如果该属性已被删除或不存在,则返回 true
[[OwnKeys]] 返回一个 List ,其元素都是对象自身的属性键

额外的必要内部方法

内部方法 描述
[[Call]] 将运行的代码与 this 对象关联。由函数调用触发。该内部方法的参数是一个 this 值和参数列表
[[Construct]] 创建一个对象。通过 new 运算符或 super 调用触发。该内部方法的第一个参数是一个 List ,该 List 的素是构造幽数调用或 super 调用的参数,第二个参数是最初应用 new 还算符的对象。实现该内部方法的对象称为构造函数

了解了这些就可以谈谈如何区分常规对象和异质对象(不是重点,了解即可)

  • 对于对象必要的内部方法,必须使用 ECMA 规范10.1.x节给的定义实现。
  • 对于内部方法[[Call]]必须使用 ECMA 规范10.2.1节给出的定义实现。
  • 对于内部方法[[Construct]]必须使用ECMA规范10.2.2节给出的定义实现。

所有不满足这三点要求的对象都是异质对象。

好了,现在可以聊聊 Proxy 的工作原理了。因为 Proxy 本质上是异质对象,所以 Proxy 也部署了必要的内部方法,与常规对象不同的是实例代理对象时指定的处理程序对象,实际上是用来自定义代理对象本身的内部方法和行为逻辑的。这意味着我们在引擎内部添加了自己的逻辑。

let obj = {name:'孤城浪人'};
let proxy = new Proxy(obj,{
  get(target,property){
    console.log('触发拦截');
    return target[property];
  }
})
console.log(proxy.name);
proxy.name = '孤城浪人wpf';
console.log(obj.name);

在这里插入图片描述

分析上面的代码和结果:在实例 proxy 代理对象时,指定了 proxy 对象的内部方法[[Get]]的逻辑,所以在调用proxy.name时,浏览器引擎会自动调用 proxy 的[[Get]]方法,于是输出了“触发拦截”,返回的实际上是obj.name,又会调用 obj 对象的[[Get]]方法,拿到值“孤城浪人”。

如果创建代理对象时没有指定处理程序对象,那么代理对象的内部方法会直接调用原始对象的内部方法。这意味着即使我们没有给代理对象指定某些内部方法,程序也能正常使用。

还是分析上面的代码:代理对象没有指定 proxy 的内部方法[[set]]的逻辑,所以在执行proxy.name = '孤城浪人wpf';时调用 proxy [[set]],它会直接调用 obj 的 [[set]]方法,直接输出obj.name可以看到属性值已经修改了。

小结

小结一下 Proxy 工作流程:我们指定的程序处理对象会变成 Proxy 对应内部方法的逻辑,所以当我们操作代理对象时,引擎会自动调用并执行对应内部方法(我们自定义的逻辑),自定义的内部方法又会调用原始对象的对应内部方法,继而完成对原始对象的操作。并且由于对象的所有操作都是直接调用内部方法或由几个内部方法组合完成,所以只要指定对应拦截函数并不会出现拦截不到的情况。Vue3 的响应式系统就是利用 Proxy 在引擎内部自定义逻辑的能力。

总结

ECMAscript 6规范算是JS的一个里程碑,它的改动非常大,新增了很多内容,像本文的 Proxy 就是新增的内容。总的来说 Proxy 就是一个异质对象,我们在处理程序对象中指定的拦截函数会变为它的内部方法的逻辑,因此引擎会自动调用,就实现了拦截处理。

我是孤城浪人,一名正在前端路上摸爬滚打的菜鸟,欢迎你的关注。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐