Познай прокси-объект JavaScript как самого себя

Первым делом ознакомимся с официальным определением Proxy на сайте веб-документации MDN, которое гласит: 

“Объект Proxy позволяет создавать прокси для другого объекта, обладая способностью перехватывать и переопределять основные операции для данного объекта”. 

Прежде чем приступить к подробному разбору темы, для лучшего понимания сути Proxy обратимся к аналогиям из жизни. В одной из статей мне встретилось следующее объяснение:

Каждый день мы занимаемся разными делами: тут e-mail надо прочитать, там посылку получить и т. д. Периодически приходится отвлекаться на решение лишних и ненужных задач, например тратить время и силы на удаление спам-рассылки. Что касается полученной посылки, то в ней вполне может находиться бомба, заложенная террористами и угрожающая безопасности людей (все возможно). 

Именно в таких ситуациях нам нужен верный помощник, который оградит от подобных угроз и возьмет на себя часть обязанностей, встав на защиту нашего дела. 

Вернемся к нашей главной теме  —  JavaScript. Как известно, его можно расширить для применения функциональностей, предоставляемых парадигмой ООП, такие как инкапсуляция, абстракция, классы, объекты и т. д. Каждый разработчик JS использует объекты, в которых обычно сохраняется информация. 

Но в результате подобных действий снижается степень защищенности кода, поскольку объекты в JS всегда выполняются “оголенными”, оказываясь доступными для любых внешних воздействий. 

Для решения этой проблемы в ECMAScript 2015, широко известного как ES6, была представлена новая функциональность  —  Proxy, с которой мы обретаем верного помощника для объекта и средство для расширения его исходных функций. 

Понятие прокси-объекта JavaScript 

Прокси в JavaScript  —  это объект, который создает обертку для другого объекта (целевого) и перехватывает основные операции с ним. 

Если вернуться к нашей аналогии, то выполнение каких-то действий, например чтение e-mail, получение посылки и т. д., может взять на себя помощник. В отношении объекта такими действиями являются поиск свойств, присваивание, перечисление, вызов функции и т. д., которые способен расширить прокси-объект. 

Простое представление прокси-объекта 

Создание прокси-объекта 

Самый простой синтаксис для создания Proxy выглядит следующим образом: 

let proxy = new Proxy(target, handler);

где:

  • target —  целевой объект для обертывания; 
  • handler —  объект-обработчик, содержащий методы, называемые ловушками, для управления поведением target.

Proxy создает скрытый заслон вокруг целевого объекта, перенаправляющий все операции к обработчику. Если мы передаем пустой handler, то прокси-объект оказывается лишь пустой оберткой вокруг исходного объекта. 

Простой пример прокси-объекта 

Для начала определим новый объект user

const user = { 
    firstName: ‘John’, 
    lastName: ‘Doe’, 
    email: ‘[email protected]’, 
}

Затем то же самое сделаем для объекта handler:

В обработчике перечисляются действия, выполнение которых мы намерены поручить прокси-объекту. Например, если в процессе получения свойства объекта требуется вывести выражение в консоль, пишем следующим образом: 

const handler = {     
    get(item, property, itemProxy) {         
        console.log(`Property ${property} has been read.`); 
        return target[property];     
    } 
}

Функция get принимает 3 аргумента: 

  • item : сам объект;
  • property : имя считываемого свойства;
  • itemProxy : только что созданный объект-помощник. 

Легко и просто создаем объект proxy вот таким способом: 

const proxyUser = new Proxy(user, handler);

Объект proxyUser использует объект user для хранения данных и может обращаться ко всем его свойствам. 

Наглядное отображение рассмотренного примера кода 

Теперь давайте обратимся к свойствам firstName и lastName объекта user через объект proxyUser:

console.log(proxyUser.firstName);
console.log(proxyUser.lastName);

Вывод будет выглядеть так: 

Property firstName has been read. 
John 
Property lastName has been read. 
Doe

В данном примере возвращаемое значение функции get является результатом считывания этого свойства. Поскольку мы не намерены пока ничего менять, просто возвращаем значение свойства исходного объекта. 

При необходимости мы также можем изменить результат. Например, сделаем так:  

let obj = {a: 1, b:2}

let handler = {
  get: function(item, property, itemProxy){
    console.log(`You are getting the value of '${property}'     property`)
    return item[property] * 2
  }
}

let objProxy = new Proxy(obj, handler)

console.log(objProxy.a)
console.log(objProxy.b)

На выводе получаем:

You are getting the value of 'a' property 
2
You are getting the value of 'b' property 
4

Ловушки Proxy 

get()

Ловушка get() запускается при обращении к свойству объекта target через прокси-объект. 

В предыдущем примере сообщение выводится, когда proxyUser обращается к свойству user.

set()

Перехватывать можно не только операции чтения, но также изменения свойств. Например, следующим образом: 

let obj = {a: 1, b:2}

let handler = {
  set: function(item, property, value, itemProxy){
    console.log(`You are setting '${value}' to '${property}' property`)
    item[property] = value
  }
}

let objProxy = new Proxy(obj, handler)

При попытке обновить значение свойства  —  увидим такой вывод: 

Поскольку нам нужно передать дополнительное значение при установке значения свойства, функция set в отличие от функции get принимает еще один аргумент .

Помимо операций чтения и изменения свойств, прокси-объект способен перехватывать в целом 13 операций/ловушек:

  • get(item, propKey, itemProxy)перехватывает операцию чтения свойств объектов, таких как obj.a и ojb['b'].
  • set(item, propKey, value, itemProxy)перехватывает операцию установки свойств объектов, таких как obj.a = 1.
  • has(item, propKey)перехватываетоперацию propKey in objProxy и возвращает значение логического типа. 
  • deleteProperty(item, propKey)перехватывает операцию delete proxy[propKey] и возвращает значение логического типа. 
  • ownKeys(item)перехватывает такие операции, как Object.getOwnPropertyNames(proxy),Object.getOwnPropertySymbols(proxy),Object.keys(proxy),for...in и возвращает массив. Метод возвращает имена всех собственных свойств целевого объекта, в то время как результат, возвращаемый Object.keys(), включает только собственные перечисляемые свойства этого объекта.
  • getOwnPropertyDescriptor(item, propKey)перехватывает операцию Object.getOwnPropertyDescriptor(proxy, propKey) и возвращает дескриптор свойства. 
  • defineProperty(item, propKey, propDesc)перехватываеттакие операции, как Object.defineProperty(proxy, propKey, propDesc),Object.defineProperties(proxy, propDescs) и возвращает значение логического типа. 
  • preventExtensions(item)перехватывает операцию Object.preventExtensions(proxy) и возвращает значение логического типа. 
  • getPrototypeOf(item)перехватывает операцию Object.getPrototypeOf(proxy) и возвращает объект. 
  • isExtensible(item)перехватывает операцию Object.isExtensible(proxy) и возвращает значение логического типа. 
  • setPrototypeOf(item, proto)перехватывает операцию Object.setPrototypeOf(proxy, proto) и возвращает значение логического типа. 

Если целевой объект является функцией, существуют 2 дополнительные операции для перехвата.  

  • apply(item, object, args)перехватывает операции вызова функции, такие как proxy(...args),proxy.call(object, ...args),proxy.apply(...).
  • construct(item, args)перехватывает операцию, запускаемую экземпляром прокси-объекта в качестве конструктора, а именно new proxy(...args)

Теперь разберем некоторые случаи использования и посмотрим, в чем состоит реальная польза Proxy

Реализация отрицательного индекса массива 

Ряд языков программирования, например Python, поддерживают отрицательные индексы массивов. 

Отрицательный индекс подразумевает обратный отсчет, начиная с последней позиции массива. Например:

  • arr[-1] — последний элемент массива; 
  • arr[-4] — четвертый элемент массива с конца. 

Эта функциональная возможность, несомненно, полезна и эффективна. К сожалению, на данный момент отрицательные индексы массивов в JavaScript не поддерживаются. 

При попытке их применить вы получите undefined, как показано в следующем примере: 

Если же нам действительно необходимы отрицательные индексы в коде, то на помощь приходит прокси-объект. 

Массив можно обернуть как прокси-объект. При попытке пользователя обратиться к отрицательному индексу, мы перехватываем эту операцию посредством прокси-метода get. Затем в соответствии с ранее описанными правилами отрицательный индекс преобразуется в положительный, и доступ осуществляется. 

Теперь рассмотрим, как именно этого добиться с помощью прокси-объекта. 

Итак, у нас есть пользователь, стремящийся получить доступ к свойству, которое является индексом массива, при чем отрицательным. В этом случае перехватываем его и поступаем с ним соответствующим образом. Если же свойство не является индексом, или он положительный, то не предпринимаем никаких действий. 

Прежде всего, прокси-метод get перехватит обращение ко всем свойствам массива, в том числе и к его индексу. Операция обращения к элементу массива происходит только в том случае, когда имя свойства можно преобразовать в целое число. В действительности нам необходимо перехватить эту операцию, чтобы обратиться к элементам массива. 

Чтобы определить, является ли свойство массива индексом, проверим, можно ли преобразовать его в целое число.  

Number(propKey) != NaN && Number.isInteger(Number(propKey))

Полный вариант кода: 

function negativeArray(array) {
  return new Proxy(array, {
    get: function(target, propKey){
      if (Number(propKey) != NaN && Number.isInteger(Number(propKey)) && Number(propKey) < 0) {
        propKey = String(target.length + Number(propKey));
      }
      return target[propKey]
    }
  })
}

Рассмотрим пример в Chrome DevTools:

Проверка данных Data Validation

Как известно, JS  —  слабо типизированный язык. Как правило, после своего создания объект выполняется “оголенным”, в связи с чем любой волен его изменить. 

Но в большинстве случаев значение свойства объекта должно удовлетворять определенным условиям. Например, объект, записывающий информацию пользователя, в поле age должен иметь целое число больше 0 и, как правило, меньше 150.  

let person1 = {
  name: 'Jon',
  age: 23
}

Однако по умолчанию JavaScript не предоставляет механизм защиты, в связи с чем вы можете менять это значение по своему усмотрению. 

person1.age = 9999
person1.age = 'hello world'

Чтобы обезопасить код, можно обернуть объект с помощью прокси. С этой целью перехватим операцию set и проверим, соответствует ли условиям новое значение поля age. 

Следующий код показывает, как именно это сделать: 

let ageValidate = {
  set (item, property, value) {
    if (property === 'age') {
      if (!Number.isInteger(value) || value < 0 || value > 150) {
        throw new TypeError('age should be an integer between 0 and 150');
      }
    }
    item[property] = value
  }
}

Теперь попытаемся изменить значение этого свойства. Как видим, установленный механизм защиты исправно работает.  

Ассоциативное свойство 

Довольно часто свойства объекта взаимосвязаны. Например, для объекта, хранящего информацию пользователя, такими свойствами являются почтовый индекс и местоположение. При определении почтового индекса пользователя устанавливается и его местоположение. 

Смоделируем пример, в котором местоположение и почтовый индекс связаны следующим образом: 

JavaScript Street  --  232200

Python Street -- 234422

Golang Street -- 231142

В коде эта взаимосвязь выражается так: 

const location2postcode = {
  'JavaScript Street': 232200,
  'Python Street': 234422,
  'Golang Street': 231142
}

const postcode2location = {
  '232200': 'JavaScript Street',
  '234422': 'Python Street',
  '231142': 'Golang Street'
}

Еще один пример: 

let person = {
  name: 'Jon'
}

person.postcode = 232200

Нам нужно, чтобы установка person.postcode=232200 автоматически запускала person.location='JavaScript Street'

Решение:

let postcodeValidate = {
  set(item, property, value) {
    if(property === 'location') {
      item.postcode = location2postcode[value]
      
    }
    if(property === 'postcode'){
      item.location = postcode2location[value]
    }
  }
}

Таким образом мы связали postcode и location

Закрытое свойство 

Ни для кого не секрет, что закрытые свойства никогда не поддерживались в JS, вследствие чего у нас отсутствует возможность грамотно управлять правами доступа при написании кода. 

Для решения этой проблемы представители JS-сообщества условились считать поля, начинающиеся со знака _, закрытыми свойствами. 

var obj = {
  a: 1,
  _value: 22
}

Вышепредставленное свойство _value считается закрытым. Однако важно помнить, что речь идет лишь о соглашении, а не о закрепленном в языке правиле. 

Располагая прокси-объектом, мы можем сымитировать закрытое свойство. 

По сравнению с обычными свойствами закрытые обладают следующими особенностями: 

  • Значение свойства нельзя прочитать. 
  • Если пользователь попытается получить доступ к ключу объекта, это свойство окажется скрытым. 

Рассмотрим 13 ранее упомянутых операций прокси-объекта и убедимся, что 3 из них необходимо перехватить.

function setPrivateField(obj, prefix = "_"){
  return new Proxy(obj, {
    // Перехват операции `propKey in objProxy`
    has: (obj, prop) => {},       

 // Перехват таких операций, как `Object.keys(proxy)`
    ownKeys: obj => {},    

//Перехват операций считывания свойств объектов 
    get: (obj, prop, rec) => {})
    });
}

После этого добавим в шаблон условное выражение: если обнаружена попытка пользователя обратиться к полю, начинающемуся с _, в доступе будет отказано. 

function setPrivateField(obj, prefix = "_"){
  return new Proxy(obj, {
    has: (obj, prop) => {
      if(typeof prop === "string" && prop.startsWith(prefix)){
        return false
      }
      return prop in obj
    },
    ownKeys: obj => {
      return Reflect.ownKeys(obj).filter(
        prop => typeof prop !== "string" || !prop.startsWith(prefix)
      )
    },
    get: (obj, prop) => {
      if(typeof prop === "string" && prop.startsWith(prefix)){
        return undefined
      }

      return obj[prop]
    }
  });
}

Перед вами окончательный вариант кода для изучаемого примера:

Заключение 

Итак, вы узнали не только, что из себя представляет Proxy в JavaScript и в каких случаях он используется, но и как с его помощью отслеживать объекты. Теперь вы сможете добавлять к ним поведение посредством методов-ловушек в обработчике. Хочется верить, что в вас пробудился интерес к дальнейшему изучению связанных с Proxy возможностей в JS. 

Благодарю за внимание! Надеюсь, материал статьи был вам полезен. 

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Gourav Kajal: Everything You Should Know About JavaScript Proxy

Предыдущая статьяВремя управлять версиями проектов МО по-новому
Следующая статьяПишем фронтенд-компоненты на ванильном JS