Proxy

2023/5/4

# Proxy(代理)

Proxy 是 ES6 带来的全新的特性,它为开发者提供了拦截并向基本操作嵌入额外行为的能力,Vue3 就是基于这点来实现了基本的响应式操作(具体实现当然更加复杂)。那么具体来说,Proxy 的原理是,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制(甚至影响外部状态)。

# 什么是代理?

在正式讲 Proxy 之前,首先要明白什么是代理?代理是目标对象的抽象,通俗来说就是目标对象的一个替身,但又完全独立于目标对象。目标对象既可以被操作,也可以通过代理来操作。但直接操作会失去代理所提供的额外行为。

# 空代理

空代理,即除了作为一个抽象的目标对象,什么也不做。默认情况下,在代理对象上执行的所有操作都会无障碍地传播到目标对象。

代理是使用 Proxy 构造函数 创建的。这个构造函数接收两个参数:目标对象处理程序对象。缺少其中任何一个参数都会抛出 TypeError。要创建空代理,可以传一个简单地对象字面量作为处理程序对象,从而让所有操作畅通无阻地抵达目标对象。

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

const target = {
  id: 'target'
}
const handler = {} // 处理程序对象
const proxy = new Proxy(target, handler)

// id 属性会访问同一个值
console.log(target.id) // target
console.log(proxy.id) // target

// 给目标属性赋值会反映在两个对象上
// 因为两个对象访问的是同一个值
target.id = 'foo'
console.log(target.id) // target
console.log(proxy.id) // target

// 给代理属性赋值会反映在两个对象上
// 因为这个赋值会转移到目标对象
proxy.id = 'bar'
console.log(target.id) // bar
console.log(proxy.id) // bar

// hasOwnProperty() 方法在两个地方
// 都会应用到目标对象
console.log(target.hasOwnProperty('id')) // true
console.log(proxy.hasOwnProperty('id')) // true

// 严格相等可以用来区分代理和目标
console.log(target === proxy) // false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 捕获器

使用代理的主要目的是可以定义捕获器(trap)。捕获器就是在处理程序对象中定义的 “基本操作拦截器”。每个处理程序对象可以包含零个或多个捕获器,每个捕获器都代表一种你要捕获的基本操作,可以直接或间接在代理对象上执行。每次在代理对象上执行这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应行为。

例如,可以定义一个 get() 捕获器,通过代理对象执行 get() 时触发。下面的例子定义了一个 get() 捕获器:

const target = {
  foo: 'bar'
}
const handler = {
  get() {
    return 'handle override'
  }
}
const proxy = new Proxy(target, handler)
1
2
3
4
5
6
7
8
9

这个操作在 JavaScript 代码中可以通过多种形式触发并被 get() 捕获器拦截到。proxy[property]、proxy.property、Object.create(proxy)[property] 等操作都会触发基本的 get() 操作以获取属性。因此所有这些操作只要发生在代理对象上,就会触发 get() 捕获器。注意,只有在代理对象上执行这些操作才会触发捕获器。 在目标对象上执行这些操作仍然会产生正常的行为。(即不会被 get() 捕获器拦截)

const target = {
  foo: 'bar'
}
const handler = {
  get() {
    return 'handler override'
  }
}
const proxy = new Proxy(target, handler)
console.log(target.foo) // bar
console.log(proxy.foo) // handler override
1
2
3
4
5
6
7
8
9
10
11

# 捕获器参数和反射API

所有捕获器(根据所捕获的操作的不同)都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为。比如,get() 捕获器会接收到目标对象、要查询的属性和代理对象三个参数。

const target = {
  foo: 'bar'
}
const handler = {
  get(trapTarget, property, receiver) {
    console.log(trapTarget === target)
    console.log(property)
    console.log(receiver === proxy)
  }
}
const proxy = new Proxy(target, handler)
proxy.foo

// true
// foo
// true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

有了这些参数,就可以重建被捕获方法的原始行为:

const target = {
  foo: 'bar'
}
const handler = {
  get(trapTarget, property, receiver) {
    return trapTarget[property]
  }
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar
console.log(target.foo) // bar
1
2
3
4
5
6
7
8
9
10
11

所有捕获器都可以基于自己的参数重建原始操作,但并非所有捕获器行为都像 get() 那么简单。因此,通过手动写码如法炮制的想法是不现实的。实际上,开发者并不需要手动重建原始行为,而是通过调用 全局 Reflect 对象 上(封装了原始行为)的同名方法来轻松重建。Reflect 在这里也可以理解为各种原始行为的反射。

处理程序对象中所有可以捕获的方法都有对应的反射(Relect)API 方法。这些方法与捕获器拦截的方法具有相同的名称和函数签名,而且也具有与被拦截方法相同的行为。因此,使用反射 API 也可以像下面这样定义出空代理对象:

const target = {
  foo: 'bar'
}
const handler = {
  get() {
    return Reflect.get(...arguments)
  }
}
1
2
3
4
5
6
7
8

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

const target = {
  foo: 'bar'
}
const proxy = new Proxy(target, Reflect)
console.log(proxy.foo)
console.log(target.foo)
1
2
3
4
5
6

反射 API 为开发者准备好了样板代码,在此基础上开发者可以用最少的代码修改捕获的方法。比如,下面的代码在某个属性被访问时,会对返回的值进行一番修饰:

const target = {
  foo: 'bar',
  baz: 'qux'
}
const handler = {
  get(trapTarget, property, receriver) {
    let decoration = ''
    if (property === 'foo') {
      decoration = '!!!'
    }
    return Reflect.get(...arguments) + decoration
  }
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar!!!
console.log(target.foo) // bar
console.log(proxy.baz) // qux
console.log(target.baz) // qux 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 捕获器不变式

使用捕获器几乎可以改变所有基本方法的行为,但也不是没有限制。根据 ECMAScript 规范,每个捕获的方法都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循 捕获器不变式。捕获器不变式因方法不同而异,但通常都会防止捕获器定义出现过于反常的行为。

比如,如果目标对象有一个不可配置且不可写的数据属性,那么在捕获器返回一个与该属性不同的值时,会抛出 TypeError:

const target = {}
Object.defineProperty(target, 'foo', {
  configurable: false,
  writable: false,
  value: 'bar'
})
const handler = {
  get() {
    return 'qux'
  }
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo)
// TypeError
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 可撤销代理

有时候可能需要中断代理对象与目标对象之间的联系。Proxy 也暴露了 revocable() 方法,这个方法支持撤销代理对象与目标对象的关联。撤销代理的操作是不可逆的。撤销代理之后再调用代理会抛出 TypeError。

撤销函数和代理对象是在实例化时同时生成的:

const target = {
  foo: 'bar'
}
const handler = {
  get() {
    return 'intercepted'
  }
}
const { proxy, revoke } = Proxy.revocable(target, handler)
console.log(proxy.foo) // intercepted
console.log(target.foo) // bar
revoke()
console.log(proxy.foo) // TypeError
1
2
3
4
5
6
7
8
9
10
11
12
13

# 代理另一个代理

代理可以拦截反射 API 的操作,而这意味着完全可以创建一个代理,通过它去代理另一个代理。这样就可以在一个目标对象上构建多层拦截网:

const target = {
  foo: 'bar'
}
const firstProxy = new Proxy(target, {
  get() {
    console.log('first proxy')
    return Reflect.get(...arguments)
  }
})
const secondProxy = new Proxy(firstProxy, {
  get() {
    console.log('second proxy')
    return Reflect.get(...arguments)
  }
})
console.log(secondProxy.foo)
// second proxy
// first proxy
// bar
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 常用捕获器与反射方法

代理可以捕获 13 种不同的基本操作。这些操作有各自不同的反射 API 方法、参数、关联 ECMAScript 操作和不变式。下面介绍几个常见的捕获器和反射方法。

# get()

get() 捕获器会在 获取属性值 的操作中被调用。操作对应的反射 API 方法为 Reflect.get()。

const myTarget = {}
const proxy = new Proxy(myTarget, {
  get(target, property, receiver) {
    return Reflect.get(...arguments)
  }
})
1
2
3
4
5
6
  1. 返回值 返回值无限制。
  2. 拦截的操作
    • proxy.property
    • proxy[property]
    • Object.create(proxy)[property]
    • Reflect.get(proxy, property, receiver)
  3. 捕获器处理程序参数
    • target:目标对象
    • property:引用的目标对象上的字符串键属性
    • receiver:代理对象
  4. 捕获器不变式 如果 target.property 不可写且不可配置,则捕获器处理程序返回的值必须与 target.property 匹配。 如果 target.property 不可配置且 [[GET]] 特性为 undefined,处理程序的返回值必须是 undefined。

# set()

set() 捕获器会在 设置属性值 的操作中被调用。操作对应的反射 API 方法为 Reflect.set()。

const myTarget = {}
const proxy = new Proxy(myTarget, {
  set(target, property, value, receiver) {
    return Reflect.set(...arguments)
  }
})
1
2
3
4
5
6
  1. 返回值 返回 true 表示成功;返回 false 表示失败,严格模式下会抛出 TypeError。
  2. 拦截的操作
    • proxy.property = value
    • proxy[property] = value
    • Object.create(proxy)[property] = value
    • Reflect.set(proxy, property, value, receiver)
  3. 捕获器处理程序参数
    • target:目标对象
    • property:引用的目标对象上的字符串键属性
    • value:要赋给属性的值
    • receiver:代理对象
  4. 捕获器不变式 如果 target.property 不可写且不可配置,则不能修改目标属性的值。 如果 target.property 不可配置且 [[Set]] 特性为 undefined,则不能修改目标属性的值。 在严格模式下,处理程序中返回 false 会抛出 TypeError。

# 代理模式

# 跟踪属性访问

通过捕获 get、set 和 has 等操作,可以知道对象属性什么时候被访问、被查询。把实现相应捕获器的某个对象代理放到应用中,可以监控这个对象何时在何处被访问过:

const user = {
  name: 'jake'
}
const proxy = new Proxy(user, {
  get(target, property, receiver) {
    console.log(`Getting ${property}`)
    return Reflect.get(...arguments)
  },
  set(target, property, value, receiver) {
    console.log(`Setting ${property}=${value}`)
    return Reflect.set(...arguments)
  }
})
proxy.name // Getting name
proxy.age = 27 // Setting age=27
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 隐藏属性

代理的内部实现对外部代码是不可见的,因此要隐藏目标对象上的属性也轻而易举。比如:

const hiddenProperties = ['foo', 'bar']
const targetObject = {
  foo: 1,
  bar: 2,
  baz: 3
}
const proxy = new Proxy(targetObject, {
  get(target, property) {
    if (hiddenProperties.includes(property)) {
      return undefined
    } else {
      return Reflect.get(...arguments)
    }
  },
  has(target, property) {
    if (hiddenProperties.includes(property)) {
      return false
    } else {
      return Reflect.has(...arguments)
    }
  }
})
console.log(proxy.foo) // undefined
console.log(proxy.bar) // undefined
console.log(proxy.baz) // 3

console.log('foo' in proxy) // false
console.log('bar' in proxy) // false
console.log('baz' in proxy) // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 属性验证

因为所有赋值操作都会触发 set() 捕获器,所以可以根据所赋的值决定是允许还是拒绝赋值:

const target = {
  onlyNumberGoHere: 0
}
const proxy = new Proxy(target, {
  set(target, property, value) {
    if (typeof value !== 'number') {
      return false
    } else {
      return Reflect.set(...arguments)
    }
  }
})
proxy.onlyNumberGoHere = 1
console.log(proxy.onlyNumberGoHere) // 1

proxy.onlyNumberGoHere = '2'
console.log(proxy.onlyNumberGoHere) // 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 函数与构造函数参数验证

跟保护和验证对象属性类似,也可对函数和构造函数参数进行审查。比如,可以让函数只接收某种类型的值:

function median(...nums) {
  return nums.sort()[Math.floor(nums.length / 2)]
}

const proxy = new Proxy(median, {
  apply(target, thisArg, argumensList) {
    for (const arg of argumentsList) {
      if (typeof arg !== 'number') {
        throw 'Non-number argument provided!'
      }
    }

    return Reflect.apply(...arguments)
  }
})
console.log(proxy(4, 7, 1)) // 4
console.log(proxy(4, '7', 1))
// Error: Non-number argument provided!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

类似地,可以要求实例化时必须给构造函数传参:

class User {
  constructor(id) {
    this.id_ = id
  }
}

const proxy = new Proxy(User, {
  constructor(target, argumentsList) {
    if (argumentsList[0] === undefined) {
      throw 'User cannot be instantiated without id'
    } else {
      return Reflect.construct(...arguments)
    }
  }
})

new proxy(1)
new proxy()
// Error: User cannot be instantiated without id
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19