你不知道的 Proxy

序言:对于 vue-next 项目中的 @vue/reactivity 模块,也是利用 Proxy 来实现响应式。因此,如果你要学习 @vue/reactivity 模块的话,就需要先掌握 Proxy。

小码哥将从 6 个方面入手,带你一步一步揭开 Proxy 对象的神秘面纱。阅读完本文,你将了解以下内容:

  • 代理的作用;
  • Proxy 对象与 Reflect 对象的相关知识;
  • Proxy 对象的 6 个使用场景;
  • 使用 Proxy 对象时的一些注意事项;
  • Proxy 在开源项目中的应用。

一、聊一聊代理

在日常工作中,相信挺多小伙伴都用过 Web 调试代理工具,比如 FiddlerCharles。通过使用 Web 调试代理工具,我们可以抓取 HTTP/HTTPS 协议请求,还可以手动修改请求参数和响应结果。不仅如此,在调试线上问题时,利用 Web 调试代理工具,你还可以把线上 压缩混淆过 的 JS 文件映射成本地 未压缩混淆过 的 JS 文件。

在简单介绍了 Web 调试代理工具的基本功能之后,我们来看一下使用 Web 调试代理工具的 HTTP 请求流程:

通过上图可知,在引入 Web 调试代理工具之后,我们发起的 HTTP 请求都会通过 Web Proxy 进行转发和处理。增加了 Web Proxy 代理层,让我们能够更好地控制 HTTP 请求的流程。对于单页应用程序来说,当从服务器获取数据之后,我们就会读取相应的数据在页面上显示出来:

 

以上流程与浏览器直接从服务器获取数据类似:

为了能够灵活控制 HTTP 请求的流程,我们增加了的 Web Proxy 层。那么我们能否控制数据对象的读取流程呢?答案是可以的,我们可以利用 Web API,比如 Object.definePropertyProxy API。在引入 Web API 之后,数据的访问流程如下图所示:

接下来,小码哥将重点介绍 Proxy API,它可是 Vue3 实现数据响应式幕后的 “功臣” 哟。对它感兴趣的小伙伴,跟小码哥一起学起来吧。

二、Proxy 对象简介

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。Proxy 的构造函数语法为:

const p = new Proxy(target, handler)

相关的参数说明如下:

相关的参数说明如下:

  • target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
  • handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

在介绍 Proxy 对象的使用示例前,我们先来了解一下它的兼容性:

由上图可知,Proxy API 的兼容性并不是很好,所以大家在使用的时候要注意其兼容性问题。

2.1 Proxy 对象使用示例

了解完 Proxy 构造函数,我们来看一个简单的例子:

const man = {
  name: "小码哥",
};

const proxy = new Proxy(man, {
  get(target, property, receiver) {
    console.log(`正在访问${property}属性`);
    return target[property];
  },
});

console.log(proxy.name);
console.log(proxy.age);

在以上示例中,我们使用了 Proxy 构造函数为 man 对象,创建了一个代理对象。在创建代理对象时,我们定义了一个 get 捕获器,用于捕获属性读取的操作。 捕获器的作用就是用于拦截用户对目标对象的相关操作,在这些操作传播到目标对象之前,会先调用对应的捕获器函数,从而拦截并修改相应的行为。

在设置了 get 捕获器之后,当成功运行以上的示例代码,控制台会输出以下结果:

通过观察以上输出结果,我们可以发现 get 捕获器 不仅可以拦截已知属性的读取操作,也可以拦截未知属性的读取操作。在创建 Proxy 对象时,除了定义 get 捕获器 之外,我们还可以定义其他的捕获器,比如 has、set、delete、apply 或 ownKeys 等。

2.2 handler 对象支持的捕获器

handler 对象支持 13 种捕获器,这里小码哥只列举以下 5 种常用的捕获器:

  • handler.get():属性读取操作的捕获器。
  • handler.set():属性设置操作的捕获器。
  • handler.deleteProperty():delete 操作符的捕获器。
  • handler.ownKeys():Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法的捕获器。
  • handler.has():in 操作符的捕获器。

需要注意的是,所有的捕获器是可选的。如果没有定义某个捕获器,那么就会保留源对象的默认行为。 看完上面的捕获器介绍,是不是觉得 Proxy 对象很强大。

三、Reflect 对象简介

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与 proxy handlers 的方法相同。Reflect 不是一个函数对象,因此它是不可构造的。

在介绍 Reflect 对象的使用示例前,我们先来了解一下它的兼容性:

3.1 Reflect 对象使用示例

const man = {
  name: "小码哥",
  city: "changsha",
};

console.log(Reflect.set(man, "sex", 1)); // true
console.log(Reflect.has(man, "name")); // true
console.log(Reflect.has(man, "age")); // false
console.log(Reflect.ownKeys(man)); // [ 'name', 'city', 'sex' ]

除了示例中介绍的 sethas 和 ownKeys 方法之外,Reflect 对象还支持 getdefineProperty 和 deleteProperty 等方法。下面小码哥将简单介绍 Reflect 对象所支持的一些静态方法。

3.2 Reflect 对象支持的静态方法

Reflect 的所有属性和方法都是静态的,该对象提供了与 Proxy handler 对象相关的 13 个方法。同样,这里小码哥只列举以下 5 个常用的方法:

  • Reflect.get(target, propertyKey[, receiver]):获取对象身上某个属性的值,类似于 target[name]。
  • Reflect.set(target, propertyKey, value[, receiver]):将值赋值给属性的函数。返回一个布尔值,如果更新成功,则返回 true。
  • Reflect.deleteProperty(target, propertyKey):删除 target 对象的指定属性,相当于执行 delete target[name]。
  • Reflect.has(target, propertyKey):判断一个对象是否存在某个属性,和 in 运算符的功能完全相同。
  • Reflect.ownKeys(target):返回一个包含所有自身属性(不包含继承属性)的数组。

在实际的 Proxy 使用场景中,我们往往会结合 Reflect 对象提供的静态方法来实现某些特定的功能。为了让大家能够更好地理解并掌握 Proxy 对象,接下来的环节,小码哥将列举 Proxy 对象的 6 个使用场景。

四、Proxy 使用场景

这里我们先来介绍 Proxy 对象的第一个使用场景 —— 增强型数组

4.1 增强型数组

定义 enhancedArray 函数

function enhancedArray(arr) {
  return new Proxy(arr, {
    get(target, property, receiver) {
      const range = getRange(property);
      const indices = range ? range : getIndices(property);
      const values = indices.map(function (index) {
        const key = index < 0 ? String(target.length + index) : index;
        return Reflect.get(target, key, receiver);
      });
      return values.length === 1 ? values[0] : values;
    },
  });

  function getRange(str) {
    var [start, end] = str.split(":").map(Number);
    if (typeof end === "undefined") return false;

    let range = [];
    for (let i = start; i < end; i++) {
      range = range.concat(i);
    }
    return range;
  }

  function getIndices(str) {
    return str.split(",").map(Number);
  }
}

使用 enhancedArray 函数

const arr = enhancedArray([1, 2, 3, 4, 5]);

console.log(arr[-1]); //=> 5
console.log(arr[[2, 4]]); //=> [ 3, 5 ]
console.log(arr[[2, -2, 1]]); //=> [ 3, 4, 2 ]
console.log(arr["2:4"]); //=> [ 3, 4]
console.log(arr["-2:3"]); //=> [ 4, 5, 1, 2, 3 ]

由以上的输出结果可知,增强后的数组对象,就可以支持负数索引、分片索引等功能。除了可以增强数组之外,我们也可以使用 Proxy API 来增强普通对象。

4.2 增强型对象

创建 enhancedObject 函数

const enhancedObject = (target) =>
  new Proxy(target, {
    get(target, property) {
      if (property in target) {
        return target[property];
      } else {
        return searchFor(property, target);
      }
    },
  });

let value = null;
function searchFor(property, target) {
  for (const key of Object.keys(target)) {
    if (typeof target[key] === "object") {
      searchFor(property, target[key]);
    } else if (typeof target[property] !== "undefined") {
      value = target[property];
      break;
    }
  }
  return value;
}

使用 enhancedObject 函数

const data = enhancedObject({
  user: {
    name: "小码哥",
    settings: {
      theme: "dark",
    },
  },
});

console.log(data.user.settings.theme); // dark
console.log(data.theme); // dark

以上代码运行后,控制台会输出以下代码:

dark
dark

通过观察以上的输出结果可知,使用 enhancedObject 函数处理过的对象,我们就可以方便地访问普通对象内部的深层属性。

4.3 创建只读的对象

创建 Proxy 对象

const man = {
  name: "semlinker",
};

const handler = {
  set: "Read-Only",
  defineProperty: "Read-Only",
  deleteProperty: "Read-Only",
  preventExtensions: "Read-Only",
  setPrototypeOf: "Read-Only",
};

const proxy = new Proxy(man, handler);

使用 proxy 对象

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

以上代码运行后,控制台会输出以下代码

semlinker
proxy.name = "kakuqo";
           ^
TypeError: 'Read-Only' returned for property 'set' of object '#<Object>' is not a function

观察以上的异常信息可知,导致异常的原因是因为 handler 对象的 set 属性值不是一个函数。如果不希望抛出运行时异常,我们可以定义一个 freeze 函数:

function freeze (obj) {
  return new Proxy(obj, {
    set () { return true; },
    deleteProperty () { return false; },
    defineProperty () { return true; },
    setPrototypeOf () { return true; }
  });
}

定义好 freeze 函数,我们使用数组对象来测试一下它的功能:

let frozen = freeze([1, 2, 3]);
frozen[0] = 6;
delete frozen[0];
frozen = Object.defineProperty(frozen, 0, { value: 66 });
console.log(frozen); // [ 1, 2, 3 ]

上述代码成功执行后,控制台会输出 [ 1, 2, 3 ],很明显经过 freeze 函数处理过的数组对象,已经被 “冻结” 了。

4.4 拦截方法调用

定义 traceMethodCalls 函数

function traceMethodCalls(obj) {
  const handler = {
    get(target, propKey, receiver) {
      const origMethod = target[propKey]; // 获取原始方法
      return function (...args) {
        const result = origMethod.apply(this, args);
        console.log(
          propKey + JSON.stringify(args) + " -> " + JSON.stringify(result)
        );
        return result;
      };
    },
  };
  return new Proxy(obj, handler);
}

使用 traceMethodCalls 函数

const obj = {
  multiply(x, y) {
    return x * y;
  },
};

const tracedObj = traceMethodCalls(obj);
tracedObj.multiply(2, 5); // multiply[2,5] -> 10

上述代码成功执行后,控制台会输出 multiply[2,5] -> 10,即我们能够成功跟踪 obj 对象中方法的调用过程。其实,除了能够跟踪方法的调用,我们也可以跟踪对象中属性的访问,具体示例如下:

function tracePropAccess(obj, propKeys) {
  const propKeySet = new Set(propKeys);
  return new Proxy(obj, {
    get(target, propKey, receiver) {
      if (propKeySet.has(propKey)) {
        console.log("GET " + propKey);
      }
      return Reflect.get(target, propKey, receiver);
    },
    set(target, propKey, value, receiver) {
      if (propKeySet.has(propKey)) {
        console.log("SET " + propKey + "=" + value);
      }
      return Reflect.set(target, propKey, value, receiver);
    },
  });
}

const man = {
  name: "semlinker",
};
const tracedMan = tracePropAccess(man, ["name"]);

console.log(tracedMan.name); // GET name; semlinker
console.log(tracedMan.age); // undefined
tracedMan.name = "kakuqo"; // SET name=kakuqo

在以上示例中,我们定义了一个 tracePropAccess 函数,该函数接收两个参数:obj 和 propKeys,它们分别表示需跟踪的目标和需跟踪的属性列表。调用 tracePropAccess 函数后,会返回一个代理对象,当我们访问被跟踪的属性时,控制台就会输出相应的访问日志。

4.5 隐藏属性

创建 hideProperty 函数

const hideProperty = (target, prefix = "_") =>
  new Proxy(target, {
    has: (obj, prop) => !prop.startsWith(prefix) && prop in obj,
    ownKeys: (obj) =>
      Reflect.ownKeys(obj).filter(
        (prop) => typeof prop !== "string" || !prop.startsWith(prefix)
      ),
    get: (obj, prop, rec) => (prop in rec ? obj[prop] : undefined),
  });

使用 hideProperty 函数

const man = hideProperty({
  name: "小码哥",
  _pwd: "www.semlinker.com",
});

console.log(man._pwd); // undefined
console.log('_pwd' in man); // false
console.log(Object.keys(man)); // [ 'name' ]

通过观察以上的输出结果,我们可以知道,利用 Proxy API,我们实现了指定前缀属性的隐藏。除了能实现隐藏属性之外,利用 Proxy API,我们还可以实现验证属性值的功能。

4.6 验证属性值

创建 validatedUser 函数

const validatedUser = (target) =>
  new Proxy(target, {
    set(target, property, value) {
      switch (property) {
        case "email":
          const regex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
          if (!regex.test(value)) {
            console.error("The user must have a valid email");
            return false;
          }
          break;
        case "age":
          if (value < 20 || value > 80) {
            console.error("A user's age must be between 20 and 80");
            return false;
          }
          break;
      }

      return Reflect.set(...arguments);
    },
  });

使用 validatedUser 函数

let user = {
  email: "",
  age: 0,
};

user = validatedUser(user);
user.email = "semlinker.com"; // The user must have a valid email
user.age = 100; // A user's age must be between 20 and 80

上述代码成功执行后,控制台会输出以下结果:

The user must have a valid email
A user's age must be between 20 and 80

介绍完 Proxy 对象的使用场景之后,我们来继续介绍与 Proxy 对象相关的一些问题。

五、Proxy 相关问题

5.1 this 的指向问题

const target = {
  foo() {
    return {
      thisIsTarget: this === target,
      thisIsProxy: this === proxy,
    };
  },
};

const handler = {};
const proxy = new Proxy(target, handler);
console.log(target.foo()); // { thisIsTarget: true, thisIsProxy: false }
console.log(proxy.foo()); // { thisIsTarget: false, thisIsProxy: true }

上述代码成功执行后,控制台会输出以下结果:

{ thisIsTarget: true, thisIsProxy: false }
{ thisIsTarget: false, thisIsProxy: true }

通过以上输出的结果,foo 方法中的 this 指向与当前的调用者有关。看起来挺简单的,但在一些场景下如果稍不注意的话,就会出现问题,比如以下这个示例:

const _name = new WeakMap();

class Person {
  constructor(name) {
    _name.set(this, name);
  }
  
  get name() {
    return _name.get(this);
  }
}

在以上示例中,我们使用 WeakMap 对象来存储 Person 对象的私有信息。定义完 Person 类,我们就可以通过以下方式来使用它:

const man = new Person("小码哥");
console.log(man.name); // 小码哥

const proxy = new Proxy(man, {});
console.log(proxy.name); // undefined

对于以上的代码,当我们通过 proxy 对象来访问 name 属性时,你会发现输出的结果是 undefined。这是因为当使用 proxy.name 的方式访问 name 属性时,this 指向的是 proxy 对象,而 _name WeakMap 对象中存储的是 man 对象,所以输出的结果是 undefined

然而,对于以上的问题,如果我们按照以下方式定义 Person 类,就不会出现以上问题:

class Person {
  constructor(name) {
    this._name = name;
  }
  get name() {
    return this._name;
  }
}

const man = new Person("小码哥");
console.log(man.name); // 小码哥

const proxy = new Proxy(man, {});
console.log(proxy.name); // 小码哥

另外,如果你对 WeakMap 感兴趣的话,可以阅读 你不知道的 WeakMap 这篇文章。

5.2 get 捕获器 receiver 参数是什么

const p = new Proxy(target, {
  get: function(target, property, receiver) {
    // receiver
  }
});

get 捕获器用于拦截对象的读取属性操作,该捕获器含有三个参数:

  • target:目标对象。
  • property:被读取的属性名。
  • receiver:指向当前的 Proxy 对象或者继承于当前 Proxy 的对象。

为了更好地了解 receiver 参数的描述信息,我们来举个具体的示例:

const proxy = new Proxy({},
  {
    get: function (target, property, receiver) {
      return receiver;
    },
  }
);

console.dir(proxy.getReceiver === proxy); // true
var inherits = Object.create(proxy);
console.dir(inherits.getReceiver === inherits); // true

那么我们能否改变 receiver 指向的对象呢?答案是可以的,通过 Reflect 对象提供的 get 方法,我们可以动态设置 receiver 对象的值,具体使用方式如下所示:

console.dir(Reflect.get(proxy, "getReceiver", "小码哥"));

其实 receiver 的名称是来源于 ECMAScript 规范:

  • [[Get]] (propertyKey, Receiver) → any

    Return the value of the property whose key is propertyKey from this object. If any ECMAScript code must be executed to retrieve the property value, Receiver is used as the this value when evaluating the code.

  • [[Set]]   (propertyKey, value, Receiver) → Boolean

    Set the value of the property whose key is propertyKey to value. If any ECMAScript code must be executed to set the property value, Receiver is used as the this value when evaluating the code. Returns true if the property value was set or false if it could not be set.

以上的 [[Get]][[Set]] 被称为内部方法,ECMAScript 引擎中的每个对象都与一组内部方法相关联,这些内部方法定义了其运行时行为。需要注意的是,这些内部方法不是 ECMAScript 语言的一部分。对于对象的访问器属性来说,在执行内部代码时,Receiver 将被作为 this 的值,同样使用 Reflect 对象提供的 API,我们也可以通过设置 receiver 参数的值来改变 this 的值

const obj = {
  get foo() {
    return this.bar;
  },
};

console.log(Reflect.get(obj, "foo")); // undefined
console.log(Reflect.get(obj, "foo", { bar: 2021 })); // 2021

5.3 包装内置构造函数的实例

当使用 Proxy 包装内置构造函数实例的时候,可能会出现一些问题。比如使用 Proxy 代理 Date 构造函数的实例:

const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);

proxy.getDate(); // Error

当以上代码运行后,控制台会输出以下异常信息:

proxy.getDate();
      ^
TypeError: this is not a Date object.

出现以上问题的原因是因为有些原生对象的内部属性,只有通过正确的 this 才能拿到,所以 Proxy 无法代理这些原生对象的属性。那么如何解决这个问题呢?要解决这个问题,我们可以为 getDate 方法绑定正确的 this

const target = new Date();
const handler = {
  get(target, property, receiver) {
    if (property === "getDate") {
      return target.getDate.bind(target);
    }
    return Reflect.get(target, property, receiver);
  },
};

const proxy = new Proxy(target, handler);
console.log(proxy.getDate());

5.4 创建可撤销的代理对象

通过 Proxy.revocable() 方法可以用来创建一个可撤销的代理对象,该方法的签名为:

Proxy.revocable(target, handler);

相关的参数说明如下:

  • target:将用 Proxy 封装的目标对象。可以是任何类型的对象,包括原生数组,函数,甚至可以是另外一个代理对象。
  • handler:一个对象,其属性是一批可选的函数,这些函数定义了对应的操作被执行时代理的行为。

调用 Proxy.revocable 方法之后,其返回值是一个对象,其结构为: {"proxy": proxy, "revoke": revoke},其中:

  • proxy:表示新生成的代理对象本身,和用一般方式 new Proxy(target, handler) 创建的代理对象没什么不同,只是它可以被撤销掉。
  • revoke:撤销方法,调用的时候不需要加任何参数,就可以撤销掉和它一起生成的那个代理对象。

了解完 revocable 方法之后,我们来举一个具体的示例:

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

proxy.name = "小码哥";
console.log(proxy.name); // 小码哥

revoke();
console.log(proxy.name); // TypeError: Revoked

当以上代码成功运行之后,控制台会输出以下内容:

小码哥
Uncaught TypeError: Cannot perform 'get' on a proxy that has been revoked
  at <anonymous>

通过观察以上的结果,我们可知当 proxy 对象被撤销之后,我们就没有办法对已撤销的 proxy 对象执行任何操作。

总结

关于 Proxy 今天就介绍到这。希望对正在学习 Proxy 的你会有所帮助。谢谢大家的阅读,想学习更多前端知识,可关注作者,每天会更新让你受益颇多不一样的知识。

链接:https://pan.baidu.com/s/1_4PIUb-Yl68aTW9Bw95iJA 
提取码:tnav 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值