JS进阶篇 Proxy

JS进阶篇 Proxy

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

使用 Proxy 可以创建一个代理对象,该对象包装了另一个目标对象,并可以通过在代理对象上定义各种附加行为来改变原始对象的行为。这样,当对代理对象进行操作时,会首先调用相关的拦截器函数,然后再根据定义的行为来处理。

Proxy 类似于JOJO里面的替身,它能扩展目标对象的能力,同时在对代理对象产生影响时,也会影响目标对象。

本文结构如下:

  • 1 基本用法

  • 2 最简单的 Proxy

  • 3 Proxy 拦截内容

    • 3.1 handler.get()

    • 3.2 handler.set()

    • 3.3 获取属性列表细节

    • 3.4 定制属性访问

    • 3.5 has 捕捉器

    • 3.6 包装函数 apply

    • 3.7 定制化初始化 construct

  • 4 Reflect

    • 4.1 Reflect 常用方法

    • 4.2 Reflect 的优势

  • 5. Proxy 的局限性

    • 5.1 内建对象:内部插槽(Internal slot)

    • 5.2 私有字段

    • 5.3 Proxy != target


1 基本用法

下面是 Proxy 的基本用法:

const target = {}; // 目标对象

const handler = {
  get: function(target, prop, receiver) {
    console.log(`正在访问属性:${prop}`);
    return Reflect.get(target, prop, receiver);
  },
  setfunction(target, prop, value, receiver) {
    console.log(`正在设置属性:${prop} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  },
  // 其他拦截器函数...
};

const proxy = new Proxy(target, handler); // 创建代理对象

proxy.foo = 42; // 正在设置属性:foo = 42
console.log(proxy.foo); // 正在访问属性:foo,输出:42

在上述示例中,我们创建了一个空对象 target,然后创建了一个代理对象 proxy,将目标对象 target 和一个处理程序 handler 传递给 Proxy 构造函数。handler 是一个包含拦截器函数的对象,用于定义代理对象的行为,也就是这里用来扩展目标对象的能力

在 handler 中,我们定义了 get 和 set 拦截器函数。当访问代理对象的属性时,get 函数会被调用,并打印出被访问的属性名。当设置代理对象的属性时,set 函数会被调用,并打印出被设置的属性名和值。通过使用 Reflect 对象上的对应方法,我们可以实现对目标对象的操作。

需要注意的是,Proxy 并不是透明的,它会覆盖或扩展原始对象上的各种操作。因此,在使用 Proxy 时,要确保所有对目标对象的操作都在代理对象上进行。

Proxy 还提供了其他拦截器函数,例如 apply(函数调用)、construct(new 操作符)、deleteProperty(删除属性)等。你可以根据具体需求来选择并定义相应的拦截器函数。

通过使用 Proxy,你可以实现一些高级的功能,例如数据绑定、属性验证、日志记录等。

2 最简单的 Proxy

最简单的 Proxy 就是将 handler代理配置 设置为空对象,此时对代理对象的操作将直接转发给目标对象。

let proxy = new Proxy(target, {})
proxy.demo = 2333// 操作转发到目标

console.log(target.demo); // 2333. 操作已经被正确地转发

没有任何捕捉器,proxy 是一个 target 的透明包装器(wrapper)。

3 Proxy 拦截内容

根据上面的描述,我们知道 handler 代理配置用来拦截、触发一些额外的操作。但具体有哪些操作呢?

对于对象的大多数操作,JavaScript 规范中有一个所谓的“内部方法”,它描述了最底层的工作方式。例如 [[Get]],用于读取属性的内部方法,[[Set]],用于写入属性的内部方法,等等。这些方法仅在规范中使用,我们不能直接通过方法名调用它们。

在ECMAScript规范中,Proxy中可以拦截这些方法的调用:

JS进阶篇 Proxy

3.1 handler.get()

handler.get()方法用于拦截对象的读取属性操作。

定义如下:

var p = new Proxy(target, {
  getfunction (target, property, receiver{},
  // target: 目标对象
  // property:被访问的属性名
  // receiver:Proxy 或者继承 Proxy 的对象
});

// 示例
const target = {}
var p = new Proxy(
  {},
  {
    getfunction (target, prop, receiver{
      console.log("目标对象: " + target);
      console.log("访问属性名称: " + prop);
      return 1000;
    },
  },
);

console.log(p.a); // "called: a"; ouptut 1000
  • 如果在get中访问receiver,可能会造成无限递归的情况。

如果无限递归,那么receiver在这里的意义是什么呢?根据查阅资料,发现常常在handlerget中使用Reflect.get(target, prop, receiver)方法来将访问转发给下一个代理或目标对象。这样可以确保 Proxy 链中的每个代理都有机会处理属性访问,并最终返回正确的结果。

3.2 handler.set()

handler.set()方法是设置属性值操作的捕获器,定义如下:

const p = new Proxy(target, {
  setfunction(target, property, value, receiver{
    // 比get方法多了一个:
    // value:新属性值。
  }
});

//示例
const target = {};
const proxy = new Proxy(target, {
  setfunction(target, prop, value, receiver{
    target[prop] = value;
    console.log(`设置属性 ${prop} 的值为 ${value}`);
    return true;
  }
});

proxy.name = 'Alice';
// 输出:设置属性 name 的值为 Alice

3.3 获取属性列表细节

在对对象进行访问时,常常需要获取这个对象中有哪些属性,通常都是使用for...in...或者Object.keys方法。

const obj = {
  name'Alice',
  age25,
  hobbies: ['reading''painting''coding']
};

// 使用Object.keys获取属性列表
const keys = Object.keys(obj);
console.log(keys);  // 输出: ["name", "age", "hobbies"]

// 使用for..in循环遍历属性
for (let key in obj) {
  console.log(key);  // 输出:name age hobbies
}

// 使用Reflect.ownKeys获取所有属性(包括Symbol类型的属性)
const allKeys = Reflect.ownKeys(obj);
console.log(allKeys);  // 输出:["name", "age", "hobbies"]

我们还可以使用Object.getOwnPropertyNames(obj)Object.getOwnPropertySymbols(obj)获取属性,它们之间区别在于前者获取返回非 symbol 键,而后者获取symbol 键。

const obj = {
  name'Alice',
  age25,
  [Symbol('hobbies')]: ['reading''painting''coding']
};

// 获取所有字符串类型属性
const propertyNames = Object.getOwnPropertyNames(obj);
console.log(propertyNames); // 输出: ["name", "age"]

// 获取所有Symbol类型属性
const symbols = Object.getOwnPropertySymbols(obj);
console.log(symbols); // 输出: [Symbol(hobbies)]

这些方法都是使用[[OwnPropertyKeys]](由 ownKeys 捕捉器拦截) 来获取属性列表的。如何使用ownKeys拦截器呢?我们以“过滤_开头的属性”功能为例:

let user = {
  name"John",
  age30,
  _password"***"
};

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

// "ownKeys" 过滤掉了 _password
for(let key in user) alert(key); // name,然后是 age

// 对这些方法的效果相同:
alert( Object.keys(user) ); // name,age
alert( Object.values(user) ); // John,30

不可返回不存在的属性

可能这个时候你会突发奇想,为什么不能返回些自定义的属性呢。但是事与愿违,如果我们返回对象中不存在的键,Object.keys 并不会列出这些键:

let user = { };

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

alert( Object.keys(user) ); // <empty>

原因在于Object.keys仅返回带有enumerable标志的属性。为了检查它,该方法会对每个属性调用内部方法[[GetOwnProperty]]来获取它的描述符(descriptor)。在这个例子中,由于没有对应属性,其描述符为空,没有enumerable标志,因此它被略过。也就是不存在enumerable就不予返回

为了让 Object.keys 返回一个属性,我们需要它要么存在于带有 enumerable 标志的对象,要么我们可以拦截对[[GetOwnProperty]]的调用(捕捉器 getOwnPropertyDescriptor 可以做到这一点),并返回带有 enumerable: true 的描述符。下面这个例子通过getOwnPropertyDescriptor捕捉器将每个自定义属性都附加上了enumerable标识,这样在使用Object.keys等方法时就可以获取到这些属性了。

let user = { };

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

  getOwnPropertyDescriptor(target, prop) { // 被每个属性调用
    return {
      enumerabletrue,
      configurabletrue
      /* ...其他标志,可能是 "value:..." */
    };
  }

});

alert( Object.keys(user) ); // a, b, c

3.4 定制属性访问

在高级开发过程中,有效的控制外界对于属性的访问是极其重要的。在众多设计上的控制理念如中间件、类的访问修饰符等都是在目标外边再套一个保护层的思想。为了有效的精细化控制对象,Proxy类孕育而生

本小节中,我们加入deleteProperty拦截器进行补充说明,它的作用是在删除属性时进行拦截控制。

在开发中,我们一般将下划线 _ 开头的属性和方法是内部的作为约定,对象不应该外部访问它们。但是如果user对象中存在_pass的属性,我们通过user._pass也是能够访问的。接下来我们对属性访问进行控制:

let user = {
  name"Max",
  _password"***"
};

// 创建 Proxy 以控制对象属性的访问
user = new Proxy(user, {
  // 拦截属性读取
  get(target, prop) {
    if (prop.startsWith('_')) { // 如果属性名以"_"开头
      throw new Error("Access denied"); // 抛出访问被拒绝的错误
    }
    let value = target[prop];
    return (typeof value === 'function') ? value.bind(target) : value; // 如果属性是函数,则将其绑定到目标对象上返回,否则直接返回属性值
  },
  
  // 拦截属性写入
  set(target, prop, val) {
    if (prop.startsWith('_')) { // 如果属性名以"_"开头
      throw new Error("Access denied"); // 抛出访问被拒绝的错误
    } else {
      target[prop] = val; // 设置属性值
      return true;
    }
  },
  
  // 拦截属性删除
  deleteProperty(target, prop) {
    if (prop.startsWith('_')) { // 如果属性名以"_"开头
      throw new Error("Access denied"); // 抛出访问被拒绝的错误
    } else {
      delete target[prop]; // 删除属性
      return true;
    }
  },
  
  // 拦截读取属性列表
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startsWith('_')); // 返回排除以"_"开头的属性名的属性列表
  }
});

// "get" 不允许读取 _password
try {
  alert(user._password); // Error: Access denied
catch(e) { alert(e.message); }

// "set" 不允许写入 _password
try {
  user._password = "test"// Error: Access denied
catch(e) { alert(e.message); }

// "deleteProperty" 不允许删除 _password
try {
  delete user._password; // Error: Access denied
catch(e) { alert(e.message); }

// "ownKeys" 将 _password 过滤出去
for(let key in user) alert(key); // name

上述代码使用了Proxy对象来创建一个代理,以控制对user对象属性的访问。具体的注释如下:

  • get(target, prop):拦截属性读取操作。在这里,如果属性名以”_”开头,则抛出访问被拒绝的错误;否则,返回目标对象的属性值。如果属性是函数,则将其绑定到目标对象上返回,否则直接返回属性值。
  • set(target, prop, val):拦截属性写入操作。在这里,如果属性名以”_”开头,则抛出访问被拒绝的错误;否则,将属性值设置到目标对象中,并返回true表示操作成功。
  • deleteProperty(target, prop):拦截属性删除操作。在这里,如果属性名以”_”开头,则抛出访问被拒绝的错误;否则,删除目标对象中的属性,并返回true表示操作成功。
  • ownKeys(target):拦截读取属性列表操作。在这里,返回目标对象的属性列表,但排除以”_”开头的属性名。

另外,我们将下列的代码单拎出来分析:

get(target, prop) {
  // ...
  let value = target[prop];
  return (typeof value === 'function') ? value.bind(target) : value; // (*)
}

value.bind(target)是什么意思呢,为什么不是直接返回value?这里就涉及到this的指向了,如果不使用bind进行绑定,在调用的时候内部的函数就不能访问到_password私有属性了。

3.5 has 捕捉器

has 捕捉器可以按照要求检查是否满足某种条件,返回布尔值。如使用in操作符来检查一个数字是否在range范围内。

let range = {
  start1,
  end10
};

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

alert(5 in range); // true
alert(50 in range); // false

3.6 包装函数 apply

在js的函数中,装饰器模式可以对如何调用这个函数进行控制,如延迟执行、调用频次等。

如延迟几秒后调用某函数:

function delay(f, ms{
  // 返回一个包装器(wrapper),该包装器将在时间到了的时候将调用转发给函数 f
  return function(// (*)
    setTimeout(() => f.apply(thisarguments), ms);
  };
}

function sayHi(user{
  alert(`Hello, ${user}!`);
}

// 在进行这个包装后,sayHi 函数会被延迟 3 秒后被调用
sayHi = delay(sayHi, 3000);

sayHi("John"); // Hello, John! (after 3 seconds)

但是包装函数(*)不会转发属性读取/写入操作或者任何其他操作。进行包装后,就失去了对原始函数属性的访问,例如 name,length 和其他属性。使用proxy,我们可以这样实现代理上的所有操作都能被转发到原始函数:

function delay(f, ms{
  return new Proxy(f, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArg, args), ms);
    }
  });
}

function sayHi(user{
  alert(`Hello, ${user}!`);
}

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 1 (*) proxy 将“获取 length”的操作转发给目标对象

sayHi("max"); // Hello, max!(3 秒后)

3.7 定制化初始化 construct

construct 拦截器是 Proxy 对象的一个拦截器方法,用于在使用 new 运算符创建实例时进行拦截。当外部代码使用 new 运算符,给一个函数或类创建实例对象时,就会触发 construct 拦截器方法。

construct 方法必须返回一个对象,这个对象就是 new 运算符创建的实例对象,如果没有返回值,则实例对象为 undefined

function Person(name{
  this.name = name;
}

const PersonProxy = new Proxy(Person, {
  construct(target, args) {
    console.log(`I'm a proxy constructor.`);
    return new target(...args);
  }
})

const person = new PersonProxy('Tom');
console.log(person.name); // Tom

在上面的代码中,当我们创建一个新实例 person 时,首先会触发代理的 construct 方法,打印一条消息,然后再执行原函数 Person,创建一个新的实例对象,并且将实例对象的 name 属性设置为传入的参数 ‘Tom’。最后,我们输出了实例对象的 name 属性值,即 Tom。

4 Reflect

4.1 Reflect 常用方法

Reflect 是一个内置的 JavaScript 对象,提供了一系列与对象操作相关的静态方法。它的设计目标是将一些原本属于 Object 对象的方法移植到 Reflect 上,以统一和简化 JavaScript 的 API。Reflect 中的方法大多是与 JavaScript 对象的属性、原型链、方法调用等相关的操作。重要的一点是Reflect将Proxy中的内部方法,例如 [[Get]] 和 [[Set]] 等,都只是规范性的,不能直接

  • Reflect.get(target, propertyKey[, receiver]):返回指定对象的指定属性的值。类似于 target[propertyKey] 的写法。
  • Reflect.set(target, propertyKey, value[, receiver]):修改指定对象的指定属性的值为给定的新值。类似于 target[propertyKey] = value 的写法。
  • Reflect.has(target, propertyKey):判断指定对象是否具有指定属性。类似于 propertyKey in target 的写法。
  • Reflect.deleteProperty(target, propertyKey):删除指定对象的指定属性。类似于 delete target[propertyKey] 的写法。
  • Reflect.construct(target, argumentsList[, newTarget]):通过指定构造函数创建一个实例对象。
  • Reflect.apply(target, thisArg, argumentsList):调用目标函数,并传入指定的参数。
  • Reflect.getPrototypeOf(target):获取指定对象的原型对象。
  • Reflect.setPrototypeOf(target, prototype):设置指定对象的原型对象。
  • Reflect.ownKeys(target):返回指定对象的所有自身属性的键名,包括字符串和符号类型。

对于每个可被 Proxy 捕获的内部方法,在 Reflect 中都有一个对应的方法,其名称和参数与 Proxy 捕捉器相同。

所以,我们可以使用 Reflect 来将操作转发给原始对象。在下面这个示例中,捕捉器 get 和 set 均无形般,仅会多显示一条消息:

let user = {
  name"John",
};

user = new Proxy(user, {
  get(target, prop, receiver) {
    alert(`GET ${prop}`);
    return Reflect.get(target, prop, receiver); // (1)
  },
  set(target, prop, val, receiver) {
    alert(`SET ${prop}=${val}`);
    return Reflect.set(target, prop, val, receiver); // (2)
  }
});

let name = user.name; // 显示 "GET name"
user.name = "Pete"// 显示 "SET name=Pete"

4.2 Reflect 的优势

为了说明在Proxy中,使用Reflect比直接操作对象更安全、可靠,我们引入一个例子。

现在我们有一个代理如下:

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

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop]; // 直接访问属性
  }
});

alert(userProxy.name); // Guest


// 但此时如果有另一个对象
// admin 从 user 继承后,我们可以观察到错误的行为:
let admin = {
  __proto__: userProxy,
  _name"Admin"
};

// 期望输出:Admin
alert(admin.name); // 输出:Guest (?!?)

在上述代码中,user 对象定义了一个 name 属性的 getter 方法,用于返回 _name 属性的值。同时,使用 Proxy 对象创建了一个代理 userProxy,它的 get 钩子函数直接返回对象上对应属性的值。

随后,我们创建了一个新对象 admin,并使用 __proto__ 属性将其原型设置为 userProxy 对象。随后,我们给 admin 设置了一个 _name 属性,并期望能够通过它的 name 属性访问到这个新值。

然而,当我们调用 admin.name 时,却得到了输出 “Guest” 的结果。这是因为,在 JavaScript 中,函数的 this 值始终与该函数被调用时所处的对象有关。在我们的例子中,通过 __proto__ 属性将 admin 的原型设置为 userProxy,意味着当我们访问 admin 对象的 name 属性时,它会首先查找 admin 对象上是否存在 name 属性,如果不存在则会在原型链上查找。由于 userProxy 对象是 user 对象的代理,所以在查找 admin 对象的 name 属性时,实际上会先调用 userProxy 的 get 钩子函数,而该函数直接返回了 target[prop],即 user 对象上的 name 属性值 “Guest”。因此,最终输出 “Guest”。

为了解决这种情况,我们需要 get 捕捉器的第三个参数 receiver。它保证将正确的 this 传递给 getter。在我们的例子中是 admin。

如何把上下文传递给 getter?对于一个常规函数,我们可以使用 call/apply,但这是一个 getter,它不能“被调用”,只能被访问。

Reflect.get 可以做到。如果我们使用它,一切都会正常运行。

更正后,代码如下:

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

let userProxy = new Proxy(user, {
  get(target, prop, receiver) { // receiver = admin
    return Reflect.get(target, prop, receiver); // (*)
  }
});


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

alert(admin.name); // Admin

5. Proxy 的局限性

5.1 内建对象:内部插槽(Internal slot)

许多内建对象,例如 Map,Set,Date,Promise 等,都使用了所谓的“内部插槽”。

它们类似于属性,但仅限于内部使用,仅用于规范目的。例如,Map 将项目(item)存储在 [[MapData]] 中。内建方法可以直接访问它们,而不通过 [[Get]]/[[Set]] 内部方法。所以 Proxy 无法拦截它们。如:

let map = new Map();

let proxy = new Proxy(map, {});

proxy.set('test'1); // Error

下面的代码可以正常工作,因为 get 捕捉器将函数属性(例如 map.set)绑定到了目标对象(map)本身。

let map = new Map();

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);
alert(proxy.get('test')); // 1(工作了!)

5.2 私有字段

类的私有字段也会发生类似的情况。

例如,getName() 方法访问私有的 #name 属性,并在代理后中断:

class User {
  #name = "Guest";

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

let user = new User();

user = new Proxy(user, {});

alert(user.getName()); // Error

原因是私有字段是通过内部插槽实现的。JavaScript 在访问它们时不使用 [[Get]]/[[Set]]

在调用 getName() 时,this 的值是代理后的 user,它没有带有私有字段的插槽。

再次,带有 bind 方法的解决方案使它恢复正常:

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;
  }
});

alert(user.getName()); // Guest

如前所述,该解决方案也有缺点:它将原始对象暴露给该方法,可能使其进一步传递并破坏其他代理功能。

5.3 Proxy != target

代理和原始对象是不同的对象。这很理所应当,对吧?

所以,如果我们使用原始对象作为键,然后对其进行代理,之后却无法找到代理了:

let allUsers = new Set();

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

let user = new User("John");

alert(allUsers.has(user)); // true

user = new Proxy(user, {});

alert(allUsers.has(user)); // false


原文始发于微信公众号(anywareAI):JS进阶篇 Proxy

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/186581.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!