【红宝书】第9章:代理与反射(一)

221 阅读9分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情

ES6新增的代理与反射为开发者提供了拦截并向基本操作嵌入额外行为的能力。具体地说,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。 在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制。

上面这段话是红宝书第9章开头对代理的介绍,我们可以将代理与第8章学到的一个方法 Object.defineProperty()进行比较,先回想一下Object.defineProperty()的作用是什么,它可以定义对象属性的内部特性,比如 gettersetter,这样就可以在我们访问对象属性之前先执行一些操作,那么我们把范围从对象的属性拓展到整个对象,就和代理的介绍中的话很像了,大概也能看明白这段话了。

一、代理基础

代理是目标对象的抽象,它类似于C++指针,因为它可以用作目标对象的替身,但又完全独立于目标对象。 目标对象可以直接被操作,也可以通过代理被操作,但是直接操作会绕过代理施予的行为。(注意,这里的类似只是为了帮助理解)

(一)创建空的代理

空代理是最简单的代理,即除了作为一个抽象的目标对象,什么也不做。

代理是使用 Proxy构造函数 创建的。这个函数接收两个参数:1.目标对象,2.处理程序对象; (两个参数缺一不可)

const target = {
  name:'Macc'
}

const handler = {}
// 要创建空代理,只需要将一个简单的对象字面量作为处理程序对象即可
const proxy = new Proxy(target,handler)

// 访问对象属性(这也是对对象的各种操作之一)
console.log(target.name); // Macc
// 通过代理访问对象的属性
console.log(proxy.name); // Macc

目标(对象的)属性赋值也会反映在两个对象上,因为两个对象访问的是同一个值

target.name = 'Deing' // 给目标属性赋值
console.log(target.name); // Deing
console.log(proxy.name); // Deing

代理(对象的)属性赋值会反映在两个对象上,因为这个赋值会转移到目标对象(的对应属性)上

proxy.name = 'Gaga' // 给代理属性赋值
console.log(target.name); // Gaga
console.log(proxy.name); // Gaga

hasOwnProperty() 方法在两个对象上使用都会应用到目标对象上。

console.log(target.hasOwnProperty('name')); // true
console.log(proxy.hasOwnProperty('name')); // true

Proxy.prototypeundefined,因此不可以对其使用 instanceof 操作符,会报错

console.log(target instanceof Proxy); // 报错
console.log(proxy instanceof Proxy); // 报错

image.png

严格相等可以用来区分代理和目标。 因为就像上面提到的代理可以用作目标对象的替身,但又完全独立于目标对象。

console.log(target === proxy); // false

就像上面的代码所演示的,在代理对象上所执行的任何操作实际上都会应用到目标对象。 唯一可感知的不同就是代码中操作的是代理对象。


小结:

上面这个小节,我们要注意的地方是,现在有两种对象了,一种是通过Object构造函数创建的普通对象,也就是我们的目标对象,另一种是通过Proxy构造函数创建的代理对象,代理对象是对目标对象的抽象, 在代理对象上执行的操作实际上都会应用到目标对象上,然后目标对象上的属性叫目标属性,代理对象上的属性叫代理属性,然后还有一个新的东西叫做处理程序对象,也就是 handler对象。


(二)定义捕获器

使用代理的主要目的是可以定义捕获器(trap)。捕获器就是在处理程序对象中定义的 “基本操作拦截器”

每个处理程序对象可以包含0个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。

每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应行为。

捕获器是从操作系统中借用的概念。在操作系统中,捕获器是程序流中的一个同步中断, 可以暂停程序流,转而执行一段子例程,之后再返回原始程序流。

// 目标对象
const target = {
  name:'Macc'
}
// 处理程序对象
const handler = {
  // 定义get捕获器
  get(){
    return 'Deing'
  }
}
// 代理对象
const proxy = new Proxy(target,handler)

console.log(target.name); // Macc
console.log(proxy.name); // Deing

看上面的最后两行代码,需要我们注意的地方是,我们定义的捕获器只有在我们通过代理对象执行某些操作时才会触发,在目标对象上执行相同的操作仍然会产生正常的行为,这也就对应了上面我们提到的目标对象既可以直接被操作,也可以通过代理来操作,但直接操作会绕过代理施予的行为。

倒数第二行代码就是直接操作目标对象,直接访问目标对象的name属性,绕过了捕获器,产生正常的行为,所以输出的是Macc。而倒数第一行代码则是通过代理对象去访问的name属性,因此触发了get捕获器,所以输出了Deing。

(三)捕获器参数和反射API

所有的捕获器都可以访问相应的参数, 基于这些参数可以重建被捕获方法的原始行为。

1.get捕获器的参数

get捕获器会接收到目标对象,要查询的属性,代理对象三个参数

// 目标对象
const target = {
  name:'Macc'
}
// 处理程序对象
const handler = {
  /**
   * get捕获器
   * @param {Object} trapTarget 目标对象
   * @param {String} property 要查询的属性
   * @param {Proxy} receiver 代理对象
   * @returns 
   */
  get(trapTarget,property,receiver){
    console.log(trapTarget === target); // true
    console.log(property); // 'name'
    console.log(receiver === proxy); // true
  }
}
// 代理对象
const proxy = new Proxy(target,handler)

console.log(proxy.name); // undefined 因为捕获器未返回任何值

可以像下面这样使用这些参数重建被捕获方法的原始行为

  get(trapTarget,property,receiver){
    // 重建被捕获方法的原始行为
    return trapTarget[property]
  }

所有捕获器都可以基于自己的参数重建原始行为, 但是并非所有捕获器都像get捕获器那么简单,因此手写代码重建原始行为不现实。我们可以通过调用全局Reflect对象上(封装了原始行为)的同名方法来重建原始行为。

get(){
    return Reflect.get(...arguments) // 重建原始行为
}
// 简化版
get:Reflect.get

如果想要创建一个可以捕获所有方法,然后将每个方法转发给对应反射API的空代理,那么甚至不需要定义处理程序对象。

const proxy = new Proxy(target,Reflect)

上面这种方法和处理程序对象传一个空对象的空代理是有区别的。

(四)捕获器不变式

使用捕获器几乎可以改变所有基本方法的行为,但也不是没有限制。

捕获器不变式(trap invariant) 因方法不同而异,但通常都会防止捕获器定义出现过于反常的行为。比如说,目标对象有一个不可配置且不可写的数据属性, 那么捕获器在返回一个与该属性不同的值时,就会抛出错误。

(五)可撤销代理

对于使用 new Proxy() 创建的普通代理而言,这种联系会在代理对象的生命周期中一直持续存在。

Proxy暴露了 revocable() 方法,这个方法支持撤销代理对象与目标对象的关联。

撤销代理的操作是不可逆的。

撤销函数 revoke()幂等的,调用多少次的结果都是一样的。

在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

撤销代理后再调用代理会抛错。

// 目标对象
const target = {
  name: 'Macc'
}
// 处理程序对象 
const handler = {
  get() {
    return 'Deing'
  }
}

// 撤销函数与代理对象实在实例化时同时生成的
const { proxy, revoke } = Proxy.revocable(target, handler)

// 调用代理
console.log(proxy.name); // Deing
// 撤销代理
revoke()
// 撤销代理后再次调用代理
console.log(proxy.name); // 报错

(六)实用反射API

上面有提到过反射API(ReflectAPI),那么在什么场景使用最好呢?

1. 反射API与对象API

首先要记住的两点是:

  1. 反射API并不限于捕获处理程序;
  2. 大多数反射API方法在Object类型上都能找到对应的方法;

通常,object上的方法适用于通用程序,反射方法则适用于细粒度的对象控制与操作。

2. 状态标记

状态标记就是对反射API返回的布尔类型的值的一种称呼,这个状态标记表示我们想要执行的操作是否执行成功了。

有什么用呢?可以看下面的示例:

const obj = {}
try {
    Object.defineProperty(obj, 'name', 'macc')
    console.log('success')
} catch (e) {
    console.log('failure')
}

Object.defineProperty() 方法在定义新属性发生问题时,会抛出错误,所以我们想要知道这个操作结果就要使用 try...catch 来获取,但是 Reflect.defineProperty() 则会在发生问题时返回 false,因此通过使用反射API,我们可以将上面的代码重构:

const obj = {}
const mark = Reflect.defineProperty(obj,'name','macc')
if(mark){
    console.log('success')
}else{
    console.log('failure)
}

除了 Reflect.defineProperty 会提供状态标记,下面这些API都会提供状态标记

方法名
Reflect.preventExtensions
Reflect.setPropertyOf
Reflect.set
Reflect.deleteProperty

3.用一等函数代替操作符

方法名替代
Reflect.get可替代对象访问操作符 .
Reflect.set可替代 = 赋值操作符
Reflect.has可替代 in 操作符 和 with()
Reflect.deleteProperty可替代 delete 操作符
Reflect.construct可替代 new 操作符

4.安全地应用函数

设想一个场景,假设我们在通过 apply 函数调用函数时,被调用的函数自身也定义了自己的 apply 属性,那么调用的结果肯定不是我们所期望的。这种问题可以通过使用定义在 Function 原型上的 apply 方法来解决。

Function.prototype.apply.call(Fun,thisVal,args)

但是更好的解决方式则是使用反射API Reflect.apply 来解决

Reflect.apply(Fun,thisVal,args)