目录

🎪 JavaScript Proxy 和 Reflect

Vue 的新版中 响应式 数据实现原理。

ECMA 关于 Proxy 的规范: ECMAScript® 2023 Language Specification (tc39.es) (opens new window)

MDN 参考:Proxy - JavaScript | MDN (mozilla.org) (opens new window)

# Proxy

使用 Proxy 对象可以 包装另一个对象 并且 拦截读写操作,然后在 自行处理 读写操作、或者允许该对象处理读写操作。

使用语法:

let proxy = new Proxy(target, handler)
1
  • target : 要包装的对象,可以是任意对象(包括函数)。
  • handler :代理的配置。(带有 拦截操作的方法 的对象), get 拦截读操作, set 拦截写操作。

proxy 进行操作,如果在 handler 中存在相应的 拦截方法,则运行该拦截方法,并且 Proxy 有机会对它进行处理。如果没有则对 target 直接处理。

🌰 例子:

let target = {}
let proxy = new Proxy(target, {})

proxy.test = 5
console.log(target.test)
console.log(proxy.test)
1
2
3
4
5
6

上面的例子, handler 为空,则对 target 直接进行读写操作,对于 proxy 的操作直接转发给了 target

提示

Proxy 是一个特殊的对象,没有自己的属性,如果 handler 为空,则透明地将操作转发给 target

能给 Proxy 添加的 handler 捕捉器:

handler 能拦截 JavaScript 中的内部工作方法,例如 [[get]] 读取属性的内部方法、 [[set]] 写入属性的内部方法。因为这些方法名称仅在规范中使用,不能通过直接调用使用。

所以要通过 Proxyhandler 捕捉器拦截这些方法的调用。

常用:

内部方法 Handler 方法 何时触发
[[Get]] get 读取属性
[[Set]] set 写入属性
[[HasProperty]] has in 操作符
[[Delete]] deleteProperty delete 操作符
[[Call]] apply 函数调用
[[Construct]] construct new 操作符
点击查看
[[GetPrototypeOf]] getPrototypeOf Object.getPrototypeOf (opens new window)
[[SetPrototypeOf]] setPrototypeOf Object.setPrototypeOf (opens new window)
[[IsExtensible]] isExtensible Object.isExtensible (opens new window)
[[PreventExtensions]] preventExtensions Object.preventExtensions (opens new window)
[[DefineOwnProperty]] defineProperty Object.defineProperty (opens new window), Object.defineProperties (opens new window)
[[GetOwnProperty]] getOwnPropertyDescriptor Object.getOwnPropertyDescriptor (opens new window), for..in , Object.keys/values/entries
[[OwnPropertyKeys]] ownKeys Object.getOwnPropertyNames (opens new window), Object.getOwnPropertySymbols (opens new window), for..in , Object.keys/values/entries s

JavaScript 将强制执行 内部方法和捕捉器必须遵循的条件不变量)。例如:

  • [[Set]] 如果值已成功写入,则必须返回 true ,否则返回 false

  • [[Delete]] 如果已成功删除该值,则必须返回 true ,否则返回 false

  • [[GetPrototypeOf]] ,必须返回与应用于被代理对象的 [[GetPrototypeOf]] 相同的值。读取代理对象的原型必须始终返回被代理对象的原型。

不变量确保了语言功能的正确和一致的行为。

# 读取和写入操作

使用捕捉器拦截读取操作, handler 应该有 get(target, property, receiver) 方法,在读取属性时触发该方法。

  • target :目标对象,该对象被作为第一个参数传递给 new Proxy
  • property :目标属性名。
  • receiver :如果目标属性是一个 getter 访问器属性,则 receiver 就是本次读取属性所在的 this 对象。一般是 proxy 对象的本身。

🌰 例子 / 使用 handler 实现对象的默认值:

let numbers = [1,2,3]

numbers = new Proxy(numbers, {
  get(target, property) {
    if(property in target) {
      return target[prop]
    } else {
      return 0 // 默认值
    }
  }
})

console.log(numbers[0]) // 1
console.log(numbers[3]) // 0 因为没有这个数组项
1
2
3
4
5
6
7
8
9
10
11
12
13
14

利用 捕捉器 拦截读取操作,可以避免 要读取的目标属性中没有 目标值 时,赋予默认值可以替代 undefined 的情况。

numbers = new Proxy(numbers, ...) 完全替代了原来的目标对象。代理该目标对象后,不应该在引用目标对象。

使用捕捉器拦截写入操作, set 捕捉器被触发 set(target, property, value, receiver)

  • target :是目标对象。(要写入的对象)
  • property :目标属性名称。
  • value :目标属性的值。(要写入的值)
  • receiver :与 get 捕捉器类似,仅与 setter 访问器属性相关。

🌰 例子:

let numbers = []

numbers = new Proxy(numbers, {
  set(target, property, value) {
    if(typef value === 'number') {
      target[property] = value
      return true
    } else {
      return false
    }
  }
})

numbers.push(1)
numbers.push("test") // TypeError
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

使用 set 拦截了 写入的操作,简单实现了规定(限制)了要写入的值的类型。因此不需要重写添加元素数组方法就可以检查写入数组的值的类型。

并且 set 必须要在写入成功时返回 true。如果没有返回,或者返回 false ,都会报错。

# 迭代操作

Object.keysfor..in 循环和大多数其他遍历对象属性的方法都使用内部方法 [[OwnPropertyKeys]] (由 ownKeys 捕捉器拦截) 来获取属性列表。

可以使用 ownkeys 拦截遍历操作。

🌰 例子 / 使用 ownkeys 拦截,遍历时跳过带有 _ 开头的属性:

let user = {
  name: "John",
  age: 30,
  _password: "***"
};

user = new Proxy(user, {
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startWith('_'))
  }
})

for(let key in user) console.log(key)

// 对下列方法同样有效
console.log(Object.keys(user))
console.log(Object.values(user))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

但是当 返回对象中不存在的键 时, ownKeys 失效:

let user = { };

user = new Proxy(user, {
  ownKeys(target) {
    return ['a', 'b', 'c'];
  }
});

console.log(Object.keys(user)) // <empty>
1
2
3
4
5
6
7
8
9

因为 Object.keys 仅返回带有 enumerable 标志的属性。该方法会对每个属性调用内部方法 [[GetOwnProperty]] 来获取 描述符。而 新返回的键, 没有属性、描述符为空,自然会被忽略。

如果要使得 新返回的键 能带有 enumerable 描述符,可以 拦截 [[GetOwnProperty]] 的调用,返回带有 enumberable: true 的描述符。

let user = { };

user = new Proxy(user, {
  // 一旦获取属性列表就会调用
  ownKeys(target) { 
    return ['a', 'b', 'c'];
  },

  // 一旦获取属性就会调用
  getOwnPropertyDescriptor(target, prop) { 
    return {
      enumerable: true,
      configurable: true
      // ... 
    };
  }

});

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 对内部受保护属性操作

受保护的属性一般使用 开头 _ 标识属性名称。但技术上,还是能够从外部获取。所以可以通过使用捕捉器,例如拦截 [[get]] 读取操作实现保护 _ 开头的 受保护属性

只需要实现:

  • get 读取属性时抛出错误;
  • set 写入属性时抛出错误;
  • deleteProperty 删除属性时抛出错误;
  • 遍历时( for...inObject.keys ) 排除掉这些属性。

🌰 例子:

let user = {
  name: "simon",
  _password: "****"
}

user = new Proxy(user, {
  get(target, property) {
    if(property.startsWith("_")) {
      throw new Error("Accress Denied")
    }
  },
  set(target, property, value) {
    if(property.startsWith("_")) {
      throw new Error("Accress Denied")
    } else {
      target[property] = value
      return true
    }
  },
  deleteProperty(target, property) {
    if(property.startsWith("_")) {
      throw new Error("Accress Denied")
    } else {
      delete target[property]
      return true
    }
  },
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startsWith("_"))
  }
})

try {
  console.log(user._password)
} catch (error) { console.log(error)}

try {
	user._password = "test"
} catch (error) { console.log(error)}

try {
  delete user._password
} catch (error) { console.log(error)}

for(key in user) {
  console.log(user) // name
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

在要利用一个 访问器 访问该保护属性时,要在拦截方法 get 中检测 属性的类型

🌰 例子:

当要调用 访问器时:

user = {
  // ... 
  checkPassword(value) {
    return value === this._password
  }
}
1
2
3
4
5
6

检测输入的密码是否与 保护的属性 密码值相同。

get(target, property) {
  let value = target[property]
  return (typeof value === 'function') ? value.bind(target) : value
}
1
2
3
4

在拦截之后,调用该 target[property] 可能会丢失原来的上下文对象,所以要使用 bind 绑定原来的 target ,使得它将来的调用都会使用 targetthis

上述的 保护内部属性 的代理方式,虽然可行,但是存在未知性的错误。如果一个方法可能会降未被代理的对象传递到其他地方,然后 此时原始对象和被代理对象都可能丢失。并且一个对象也可能会被代理多次,如果将未被包装的对象传递给方法,就会产生未知的错误。

所以不应该这样使用代理对象。在 新的 JavaScript 特性中,已经使用 # 标识私有属性。

# 使用 has 拦截 in

当使用 in 操作符来检查一个数字是否在 range 范围内。

has 捕捉器可以拦截 in 的调用, has(target, property)

  • target :目标对象;
  • property :属性名称;

🌰 例子:

let range = {
  start: 1,
  end: 10
}

range = new Proxy(range, {
  has(target, property) {
    return prop >= target.start && prop <= target.end
  }
})

console.log(5 in range)  // true
console.log(50 in range) // false
1
2
3
4
5
6
7
8
9
10
11
12
13

# 包装函数

将 代理 包装在函数的周围,使用 apply(target, thisArg, args) 捕捉器代理 以函数的方式被调用

  • target :目标对象(函数);
  • thisArgthis 的值;
  • args :参数列表;

🌰 例子 / 延迟函数的调用装饰器 delay(f, ms)

  • 在 基于函数 方式实现:
点击查看
function delay(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms)
  }
}

function sayHi(user) {
  console.log(`Hi, ${user}`)
}

console.log(sayHi.length) // 1(参数个数)

sayHi = delay(sayHi, 3000)

console.log(sayHi.length) // 0 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

基于函数实现的包装函数,不会转发 属性 读取和写入操作,进行包装后就会失去对原始函数属性的访问,例如 namelength

  • 使用 apply 捕捉器拦截:
function delay(f, ms) {
  return new Proxy(f, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArgs, args), ms)
    }
  })
} 

sayHi = delay(sayHi, 3000)
console.log(sayHi.length) // 1
1
2
3
4
5
6
7
8
9
10

使用 Proxy 的功能要强大得多,因为它可以将所有东西转发到目标对象。

这时得到一个 更加丰富 的包装器。

# Reflect

是 JavaScript 的内建对象,简化 Proxy 的创建。

Proxy 中不可以直接调用 [[Get]][[Set]] 等规范性内部方法,在 Reflect 对象中可以调用这些方法的最小包装。

并且, Reflect 运行将 操作符newdelete 等)作为函数执行调用(对应方法 constructdeleteProperty )。

点击查看
操作 Reflect 调用 内部方法
obj[prop] Reflect.get(obj, prop) [[Get]]
obj[prop] = value Reflect.set(obj, prop, value) [[Set]]
delete obj[prop] Reflect.deleteProperty(obj, prop) [[Delete]]
new F(value) Reflect.construct(F, value) [[Construct]]

🌰 例子:

let user = {}
Reflect.set(user, 'name', 'John')
console.log(user.name) // John
1
2
3

对于每个可被 Proxy 捕获的内部方法,在 Reflect 中都有一个对应的方法,其名称和参数与 Proxy 捕捉器相同。 所以,可以使用 Reflect 来将操作转发给原始对象。

🌰 例子 / 结合 ProxyReflect 的使用:

let user = {}

user = new Proxy(user, {
  get(target, property, receiver) {
    console.log(`GET ${property}`)
    return Reflect.get(target, property, receiver)
  },
  set(target, property, value, receiver) {
    console.log(`SET ${property} = ${value}`)
    return Reflect.set(target, property, value, receiver)
  }
})
1
2
3
4
5
6
7
8
9
10
11
12

如果一个捕捉器想要将调用转发给对象,则只需使用相同的参数调用 Reflect.<method> 就足够了。 简化了 Proxy 的工作。

# 代理 getter 访问器

🌰 例子:

let user = {
  _name: "Guest",
  get name() {
    return this._name
  }
}

let userProxy = new Proxy(user, {
  get(target, property, receiver) {
    return target[prop]
  }
})
console.log(userProperty.name)
1
2
3
4
5
6
7
8
9
10
11
12
13

此处,一切还正常。

创建一个以 userProxy 为原型的 admin 对象:

let admin = {
  __proto__: userProxy,
  _name: "Admin"
}

console.log(admin.name) // "Guest"
1
2
3
4
5
6

可以看出,如果直接读取 admin.name ,没有找到 amdin 中的 name 属性,转而向 admin 的原型对象 userProxy 找,此时 userProxy.name 会代理 读取属性 的方法,找到的是上下文是 user 的上下文,所以输出的是 guest

要解决这种情况,需要使用捕捉器 get 的第三个参数 receiver 将正确的 this 转发给 getter 。但是按照规范 Proxy 中的 get 不能被调用,所以可以利用 Reflect.get

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver)
  }
})
1
2
3
4
5

此时通过 receiver 保留了对正确 this 的引用(即 admin )。

简化 Reflect 的使用:

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return Reflect.get(...arguments)
  }
})
1
2
3
4
5

Reflect 调用的命名与捕捉器的命名完全相同,并且接受相同的参数。它们是以这种方式专门设计的。

因此, return Reflect... 提供了一个安全的方式,可以轻松地转发操作,并确保不会忘记与此相关的任何内容。

# Proxy 的局限

# 内建对象的内部插槽

许多内建对象,例如 MapSetDatePromise 等,都使用「内部插槽」。它们类似于属性,但仅限于内部使用,仅用于规范目的

例如, Map 将项目(item)存储在 [[MapData]] 中。内建方法可以直接访问它们,而不通过 [[Get]]/[[Set]] 内部方法。

所以,当使用 Proxy 代理这些内建对象,这些内部插槽并不能被拦截。

🌰 例子:

let map = new Map()
let proxy = new Proxy(map, {})
proxy.set('test', 1) // Error
1
2
3

由于 this =。proxy ,在 proxy 中无法找到 this.[[MapData] ,所以报错。

解决方法:

let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

proxy.set('test', 1);
console.log(proxy.get('test'));
1
2
3
4
5
6
7
8
9

这时, proxy.set 的内部 this 不是 proxy 而是原始的 map ,所以找得到 this.[[MapData]] 内部插槽。

# 私有字段

私有字段也是通过 内部插槽 实现的,所以直接代理并不能正常 [[GET]]/[[SET]] 。同样要使用 Reflectbind 方法解决:

class User {
  #name = "Guest";

  getName() {
    return this.#name;
  }
}

let user = new User();

user = new Proxy(user, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

console.log(user.getName()); 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

但是这种方法也有缺点,将原始对象暴露给该方法,可能会进一步传递破坏其他代理功能。

# 代理对象之后…

🌰 例子:

let allUsers = new Set();

class User {
  constructor(name) {
    this.name = name;
    allUsers.add(this);
  }
}

let user = new User("John");
console.log(allUsers.has(user)) // true

user = new Proxy(user, {})
console.log(allUsers.has(user)) // false
1
2
3
4
5
6
7
8
9
10
11
12
13
14

user 被代理后,不能再 allusers 中找到。因为代理是一个不同的对象。

注意

Proxy 无法拦截严格相等性检查 === :一个对象只严格等于其自身,没有其他值。因此,比较对象是否相等的所有操作和内建类都会区分对象和代理。这里没有透明的替代品。

# 可撤销的代理

一个 可撤销 的代理是可以被禁用的代理。

假设有一个资源,并且想随时关闭对该资源的访问。

可以做的是将它包装成可一个撤销的代理,没有任何捕捉器。这样的代理会将操作转发给对象,并且可以随时将其禁用。

let {proxy, revoke} = Proxy.revocable(target, handler)
1

这个 Proxy 调用返回一个带有 proxyrevoke 函数的对象以将其禁用。

🌰 例子:

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

conosole.log(proxy.data) 
revoke()
console.log(proxy.data) // Error
1
2
3
4
5
6
7
8
9

revoke() 的调用会从代理中删除对目标对象的所有内部引用,因此它们之间再无连接。

🌰 例子 / 利用 WeakMap 的不阻止「垃圾回收」 的机制绑定 proxyrevoke

let revokes = new WeakMap()

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});
revokes.set(proxy, revoke);

// ... then
revoke = revokes.get(proxy)
revoke()
console.log(proxy.data) // Error
1
2
3
4
5
6
7
8
9
10
11
12
13

如果一个代理对象变得「不可访问」(例如,没有变量再引用它),则 WeakMap 允许将其与它的 revoke 一起从内存中清除,因为我们不再需要它了。

# 总结

  • Proxy 是对象的包装器,将代理上的操作转发到对象,并可以选择捕获其中一些操作。它可以包装任何类型的对象,包括类和函数。
📢 上次更新: 2022/09/02, 10:18:16