进大厂必会的 Proxy 代理

975 阅读8分钟

代理对象封装另一个对象并拦截操作,如读取/写入属性和其他操作,可以选择自己处理它们,或透明地允许对象处理它们。

很多库和一些浏览器框架都使用代理。在本文中,我们将看到许多实际应用程序。

Proxy

语法如下:

let proxy = new Proxy(target, handler)
  • target - 是一个要包装的对象,可以是任何东西,包括函数。

  • handler - 代理配置:一个带有“陷阱”的对象,拦截操作的方法。-例如,读取target属性时设置trap,写入target属性时设置trap,等等。

对于代理上的操作,如果handler中有相应的陷阱,那么它就会运行,并且代理有机会处理它,否则操作就会在目标上执行。

作为一个开始的例子,让我们创建一个没有任何陷阱的代理:

let target = {};
let proxy = new Proxy(target, {}); // empty handler

proxy.test = 5; // writing to proxy (1)
alert(target.test); // 5, the property appeared in target!

alert(proxy.test); // 5, we can read it from proxy too (2)

for(let key in proxy) alert(key); // test, iteration works (3)

由于没有陷阱,代理上的所有操作都被转发到目标。

  • 写操作 proxy.test=target上的值。

  • 读取操作 proxy.test 从 target 返回值。

  • 迭代代理返回目标值。

正如我们所见,没有任何陷阱,proxy是一个透明的目标包装器。

Proxy是一种特殊的“外来对象”。它没有自己的属性。使用空处理程序,它透明地将操作转发给target。

为了激活更多的功能,让我们添加陷阱。

我们能用他们拦截什么?

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

代理陷阱拦截这些方法的调用。它们在代理规范和下表中列出。

对于每个内部方法,在该表中都有一个陷阱:我们可以添加到新代理的handler参数的方法名来拦截操作:

使用 get 方式获取默认值

最常见的陷阱是用于读/写属性的。

为了拦截读取,处理程序应该有一个方法get(目标、属性、接收器)。

当一个属性被读取时,它会触发,参数如下:

  • target—是目标对象,作为第一个参数传递给新代理,

  • property -属性名称,

  • receiver——如果目标属性是一个getter,那么receiver就是将在其调用中使用的对象。通常这是代理对象本身(或者从它继承的对象,如果我们从代理继承的话)。现在我们不需要这个论证,所以后面会更详细地解释。

让我们使用get来实现对象的默认值。

我们将创建一个数字数组,对于不存在的值返回0。

通常,当一个人试图获取一个不存在的数组项时,他们得到的是未定义的,但是我们将把一个常规的数组包装到代理中,以捕获读取,如果没有这样的属性则返回0:

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // default value
    }
  }
});

alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (no such item)

正如我们所见,诱捕陷阱很容易做到。

我们可以使用代理来实现“默认”值的任何逻辑。

想象一下我们有一本词典,里面有一些短语和它们的翻译:

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome'] ); // undefined

现在,如果没有短语,从字典中读取将返回undefined。但在实践中,不翻译一个短语通常比不定义要好。我们让它返回一个未翻译的短语,而不是undefined。

为了实现这一点,我们将把dictionary封装在一个拦截读取操作的代理中:

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

dictionary = new Proxy(dictionary, {
  get(target, phrase) { // intercept reading a property from dictionary
    if (phrase in target) { // if we have it in the dictionary
      return target[phrase]; // return the translation
    } else {
      // otherwise, return the non-translated phrase
      return phrase;
    }
  }
});

// Look up arbitrary phrases in the dictionary!
// At worst, they're not translated.
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome to Proxy']); // Welcome to Proxy (no translation)

使用 set 验证

假设我们想要一个专门用于数字的数组。如果添加了另一种类型的值,应该会出现错误。

set trap在写入属性时触发。

set(target, property, value, receiver)
  • target—是目标对象,作为第一个参数传递给新代理,

  • property -属性名称,

  • value -属性值,

  • receiver——与get trap类似,只对setter属性有效。

如果设置成功,set trap应该返回true,否则返回false(触发TypeError)。

让我们使用它来验证新值:

let numbers = [];

numbers = new Proxy(numbers, { // (*)
  set(target, prop, val) { // to intercept property writing
    if (typeof val == 'number') {
      target[prop] = val;
      return true;
    } else {
      return false;
    }
  }
});

numbers.push(1); // added successfully
numbers.push(2); // added successfully
alert("Length is: " + numbers.length); // 2

numbers.push("test"); // TypeError ('set' on proxy returned false)

alert("This line is never reached (error in the line above)");

请注意:数组的内置功能仍然有效!值是通过push添加的。当添加值时,length属性自动增加。我们的代理不会破坏任何东西。

我们不必重写添加值的数组方法(如push和unshift等)来添加检查,因为它们在内部使用由代理拦截的[[Set]]操作。

因此,代码是干净和简洁的。

使用 ownKeys, getOwnPropertyDescriptor 进行迭代

Object.keys, for...in 和迭代对象属性的大多数其他方法使用[[OwnPropertyKeys]]内部方法(被ownKeys陷阱截获)来获得属性列表。

这些方法在细节上有所不同:

  • Object.getOwnPropertyNames(obj) 返回非符号键。

  • Object.getOwnPropertySymbols(obj) 返回符号键。

  • Object.keys/values()返回带有可枚举标志的非符号键/值(属性标志在“属性标志和描述符”一文中解释过)。

  • for..in 循环遍历带有enumerable标志的非符号键和原型键。

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

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

// "ownKeys" filters out _password
for(let key in user) alert(key); // name, then: age

// same effect on these methods:
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]]来获取它的描述符。这里,因为没有属性,它的描述符是空的,没有可枚举标志,所以它被跳过。

为对象。要返回一个属性,我们需要它存在于对象中,并带有enumerable标志,或者可以拦截对[[GetOwnProperty]]的调用(陷阱getOwnPropertyDescriptor做了这个工作),并返回一个带有enumerable: true的描述符。

let user = { };

user = new Proxy(user, {
  ownKeys(target) { // called once to get a list of properties
    return ['a', 'b', 'c'];
  },

  getOwnPropertyDescriptor(target, prop) { // called for every property
    return {
      enumerable: true,
      configurable: true
      /* ...other flags, probable "value:..." */
    };
  }

});

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

使用 deleteProperty 保护属性

有一个广泛的约定,即以下划线为前缀的属性和方法是内部的。它们不应该从对象外部访问。

从技术上讲,这是可能的:

let user = {
  name: "John",
  _password: "secret"
};

alert(user._password); // secret

让我们使用代理来防止任何以_开头的属性的访问。

我们需要陷阱:

  • 读取这样的属性时抛出错误,

  • 设置为写入时抛出错误,

  • 删除时抛出错误,

  • ownKeys排除以_开头的属性for..in和方法,如Object.keys

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

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) { // to intercept property writing
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      target[prop] = val;
      return true;
    }
  },
  deleteProperty(target, prop) { // to intercept property deletion
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      delete target[prop];
      return true;
    }
  },
  ownKeys(target) { // to intercept property list
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "get" doesn't allow to read _password
try {
  alert(user._password); // Error: Access denied
} catch(e) { alert(e.message); }

// "set" doesn't allow to write _password
try {
  user._password = "test"; // Error: Access denied
} catch(e) { alert(e.message); }

// "deleteProperty" doesn't allow to delete _password
try {
  delete user._password; // Error: Access denied
} catch(e) { alert(e.message); }

// "ownKeys" filters out _password
for(let key in user) alert(key); // name

请注意get陷阱的重要细节,在(*)行:

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

为什么我们需要一个函数来调用`value.bind(target)``?

原因是对象方法,如user.checkPassword(),必须能够访问_password:

  // ...
  checkPassword(value) {
    // object method must be able to read _password
    return value === this._password;
  }
}

使用 has in range

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

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

has陷阱在调用中拦截。

has(target, property)

  • target — 是目标对象,作为第一个参数传递给新代理,

  • property -属性名称

演示:

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

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

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

包装函数:apply

我们也可以用代理来封装函数。

apply(target, thisArg, args)陷阱将调用代理作为函数:

  • target是目标对象(functionJavaScript中的对象),

  • thisArgthis的值。

  • args是一个参数列表。

function delay(f, ms) {
  // return a wrapper that passes the call to f after the timeout
  return function() { // (*)
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

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

// after this wrapping, calls to sayHi will be delayed for 3 seconds
sayHi = delay(sayHi, 3000);

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

正如我们已经看到的,这基本上是可行的。包装器函数(*)在超时后执行调用。

但是包装器函数不转发属性读/写操作或其他任何操作。包装后,对原始函数的属性的访问将丢失,例如名称、长度等:

function delay(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

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

alert(sayHi.length); // 1 (function length is the arguments count in its declaration)

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 0 (in the wrapper declaration, there are zero arguments)

理要强大得多,因为它将所有内容转发给目标对象。

让我们使用代理代替包装函数:

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 forwards "get length" operation to the target

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