Введение в JavaScript Proxy

Источник: «A primer on JavaScript Proxies»
Сегодня рассмотрим JavaScript Proxy: что это такое, как они работают и для чего они могут понадобиться.

Что такое Proxy

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

Для создания нового объекта Proxy можно использовать конструктор new Proxy(). В качестве аргумента передайте массив ([]) или объект ({}), из которого необходимо создать Proxy, а также объект обработчик, определяющий способ обработки взаимодействий (подробнее об этом вкратце).

В этом примере из объекта wizards создаётся Proxy, а в качестве обработчика передаётся пустой объект.

let wizard = {
name: 'Merlin',
tool: 'Wand'
};

// Создание Proxy объекта
let wizardProxy = new Proxy(wizard, {});

Объект обработчик и ловушки

Объект обработчик указывает Proxy, как реагировать на взаимодействие с объектом или свойствами массива. Можно назначить несколько функций, называемых ловушками, которые запускаются как обратные вызовы для различных типов взаимодействий.

Существует более десятка различных методов ловушек, но три наиболее распространённых — это методы get(), set() и deleteProperty(). Они выполняются каждый раз, когда кто-то получает, устанавливает или удаляет свойство массива или объекта, соответственно.

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

// Создание Proxy объекта
let wizardProxy = new Proxy(wizard, {

/**
* Выполняется при получении значения свойства
* @param {Object|Array} obj Объект или массив, который обрабатывает Proxy
* @param {String|Integer} key Ключ или индекс свойства
*/

get (obj, key) {
console.log('get', obj, key, obj[key]);
return obj[key];
},

/**
* Выполняется при определении или обновлении свойства
* @param {Object|Array} obj Объект или массив, который обрабатывает Proxy
* @param {String|Integer} key Ключ или индекс свойства
* @param {*} value Значение, присваиваемое свойству
*/

set (obj, key, value) {

console.log('set', obj, key, value);

// Обновление свойства
obj[key] = value;

// Индикация успеха
return true;

},

/**
* Выполняется при удалении свойства
* @param {Object|Array} obj Объект или массив, который обрабатывает Proxy
* @param {String|Integer} key Ключ или индекс свойства
*/

deleteProperty (obj, key) {

console.log('delete', obj, key, obj[key]);

// Удаление свойства
delete obj[key];

// Индикация успеха
return true;

}

});

Теперь можно изменять объект Proxy так же, как и обычный объект, а методы ловушки обработчика Proxy в ответ будут выполнять код.

// Запускает ловушку get() и выводит в лог...
// "get" {name: 'Merlin', tool: 'Wand'} "name" "Merlin"
let name = wizardProxy.name;

// Запускает ловушку deleteProperty() и выводит в лог...
// "set" {name: 'Merlin', tool: 'Wand'} "age" 172
wizardProxy.age = 172;

// Запускает ловушку get() и выводит в лог...
// "delete" {name: 'Merlin', tool: 'Wand'} "tool" "Wand"
delete wizardProxy.tool;

Полный пример кода

Proxy и вложенность

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

В данном случае есть объект wizard с вложенным массивом spells. Также есть объект handler с методами get() и set(). Они оба выводят сообщение в консоль, а в остальном сохраняют поведение по умолчанию.

Создадим объект Proxy с объектами wizard и handler и присвоим его переменной wizardProxy.

// Объект с вложенным массивом
let wizard = {
name: 'Merlin',
tool: 'wand',
spells: ['Abbracadabra', 'Disappear']
};

// Объект handler
let handler = {
get (obj, key) {
console.log('get', key);
return obj[key];
},
set (obj, key, value) {
console.log('set', key);
obj[key] = value;
return true;
}
};

// Создаём Proxy
let wizardProxy = new Proxy(wizard, handler);

Если в wizardProxy добавить свойство или получить значение свойства из него, в консоль будет выведено сообщение, как и ожидалось.

// Выводит "get" "name" и "set" "age" соответственно
let name = wizardProxy.name;
wizardProxy.age = 172;

Но если получить свойство из массива wizardProxy.spells, то метод handler.get() выполнится при получении массива spells, но не конкретных свойств из него.

Но если использовать метод Array.prototype.push() для добавления свойства в массив wizardProxy.spells, то метод handler.get() выполняется, при получении массива spells, но метод handler.set() никогда не выполняется.

// Обновление вложенного массива
// выводит "get" "spells"
wizardProxy.spells.push('Heal');

Как обрабатывать вложенные массивы и объекты в объекте Proxy

Чтобы обнаружить вложенные массивы и объекты внутри объекта Proxy, сначала нужно переместить объект handler в функцию, возвращающую объект.

function handler () {
return {
get (obj, key) {
console.log('get', key);
return obj[key];
},
set (obj, key, value) {
console.log('set', key);
obj[key] = value;
return true;
}
};
}

// Создаём Proxy
let wizardProxy = new Proxy(wizard, handler());

Внутри метода handler.get() необходимо проверить, является ли значение свойства (obj[key]) массивом или объектом.

Если да, то мы передаём его в конструктор new Proxy() и возвращаем обратно, рекурсивно передавая функцию handler(). Если нет, то возвращаем его как есть.

Оператор typeof возвращает object для всех видов элементов, не являющихся простыми объектами ({}), поэтому используем другой подход, чтобы выяснить это. Можно использовать метод call() метода Object.prototype.toString() и передать в него элемент, который хотим проверить. Он вернёт имя прототипа.

// возвращает [object Array]
Object.prototype.toString.call([]);

// [object Object]
Object.prototype.toString.call({});

Создадим массив с [object Object] и [object Array], затем используем метод Array.prototype.includes(), чтобы проверить, является ли строка, возвращённая Object.prototype.toString.call(obj[key]), одним из этих значений.

Если да, то вернём new Proxy(), передав в качестве аргументов obj[key] и handler().

function handler () {
return {
get (obj, key) {
console.log('get', key);

// Если элемент является объектом или массивом, возвращается proxy
let nested = ['[object Object]', '[object Array]'];
let type = Object.prototype.toString.call(obj[key]);
if (nested.includes(type)) {
return new Proxy(obj[key], handler());
}

return obj[key];
},
set (obj, key, value) {
console.log('set', key);
obj[key] = value;
return true;
}
};
}

Теперь, когда Array.prototype.push() элемент в массив wizardProxy.spells, метод handler.set() действительно выполняется.

wizardProxy.spells.push('Heal');

Как избежать создания Proxy из Proxy

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

В текущем коде можно создать Proxy из Proxy, в результате чего функция handler() будет выполняться для одного и того же массива или объекта несколько раз. Если это произойдёт несколько раз, браузер может зависнуть или даже упасть.

let data = new Proxy({
wizards: {
list: ['Gandalf', 'Radagast', 'Merlin']
},
witches: {
list: ['Ursula', 'Wicked Witch Of The West', 'Malificent']
}
}, handler());

/**
* Реверс witches и wizards
* После нескольких десятков замен браузер начнёт тормозить или упадёт.
*/

function swapMagic () {
let tempCache = data.wizards.list;
data.wizards.list = data.witches.list;
data.witches.list = tempCache;
}

Хотя в браузере нет способа проверить, является ли массив или объект Proxy, его можно добавить с помощью объекта handler.

В методе handler.get() сначала проверим, является ли извлекаемый ключ _isProxy. Если да, то возвращаем true.

Это не реальное свойство объекта. Это внутреннее фиктивное свойство, возвращающее true только в том случае, если выполняется метод handler.get(). Если это происходит, мы знаем, что свойство уже является объектом Proxy.

// Объект handler
function handler () {
return {
get (obj, key) {

// Если ключ "_isProxy", возвращаем true
// Это произойдёт только в случае, если свойство уже является Proxy
if (key === '_isProxy') return true;

// ...

},
// ...
};
}

Если свойство является массивом или объектом, мы проверяем, возвращает ли свойство _isProxy значение true.

Если это так, то массив или объект уже управляется объектом handler и уже стал Proxy, поэтому можно вернуть его как есть. Если нет, то это обычный массив или объект, и можно смело возвращать new Proxy().

// Объект handler
function handler () {
return {
get (obj, key) {

// Если ключ "_isProxy", возвращаем true
// Это произойдёт только в случае, если свойство уже является Proxy
if (key === '_isProxy') return true;

// Если элемент является объектом или массивом и ещё не является Proxy, возвращаем new Proxy
let nested = ['[object Object]', '[object Array]'];
let type = Object.prototype.toString.call(obj[key]);
if (nested.includes(type) && !obj[key]._isProxy) {
return new Proxy(obj[key], handler());
}

// Иначе возвращаем свойство
return obj[key];

},
// ...
};
}

Эти два небольших дополнения позволяют избежать вложенности массивов и объектов в несколько обработчиков Proxy, а также связанных с этим проблем с производительностью.

Дополнительные материалы

Предыдущая Статья

Как прослушивать несколько событий в веб-компоненте

Следующая Статья

Создание ванильного JavaScript signal() с Proxy