Объект Proxy
«оборачивается» вокруг другого объекта и может перехватывать (и, при желании, самостоятельно обрабатывать) разные действия с ним, например чтение/запись свойств и другие. Далее мы будем называть такие объекты «прокси».
Прокси используются во многих библиотеках и некоторых браузерных фреймворках. В этой главе мы увидим много случаев применения прокси в решении реальных задач.
Синтаксис:
let proxy = new Proxy(target, handler);
target
– это объект, для которого нужно сделать прокси, может быть чем угодно, включая функции.handler
– конфигурация прокси: объект с «ловушками» («traps»): методами, которые перехватывают разные операции, например, ловушкаget
– для чтения свойства изtarget
, ловушкаset
– для записи свойства вtarget
и так далее.
При операциях над proxy
, если в handler
имеется соответствующая «ловушка», то она срабатывает, и прокси имеет возможность по-своему обработать её, иначе операция будет совершена над оригинальным объектом target
.
В качестве начального примера создадим прокси без всяких ловушек:
let target = {};
let proxy = new Proxy(target, {}); // пустой handler
proxy.test = 5; // записываем в прокси (1)
alert(target.test); // 5, свойство появилось в target!
alert(proxy.test); // 5, мы также можем прочитать его из прокси (2)
for(let key in proxy) alert(key); // test, итерация работает (3)
Так как нет ловушек, то все операции на proxy
применяются к оригинальному объекту target
.
- Запись свойства
proxy.test=
устанавливает значение наtarget
. - Чтение свойства
proxy.test
возвращает значение изtarget
. - Итерация по
proxy
возвращает значения изtarget
.
Как мы видим, без ловушек proxy
является прозрачной обёрткой над target
.
Proxy
– это особый, «экзотический», объект, у него нет собственных свойств. С пустым handler
он просто перенаправляет все операции на target
.
Чтобы активировать другие его возможности, добавим ловушки.
Что именно мы можем ими перехватить?
Для большинства действий с объектами в спецификации JavaScript есть так называемый «внутренний метод», который на самом низком уровне описывает, как его выполнять. Например, [[Get]]
– внутренний метод для чтения свойства, [[Set]]
– для записи свойства, и так далее. Эти методы используются только в спецификации, мы не можем обратиться напрямую к ним по имени.
Ловушки как раз перехватывают вызовы этих внутренних методов. Полный список методов, которые можно перехватывать, перечислен в спецификации Proxy, а также в таблице ниже.
Для каждого внутреннего метода в этой таблице указана ловушка, то есть имя метода, который мы можем добавить в параметр handler
при создании new Proxy
, чтобы перехватывать данную операцию:
Внутренний метод | Ловушка | Что вызывает |
---|---|---|
[[Get]] |
get |
чтение свойства |
[[Set]] |
set |
запись свойства |
[[HasProperty]] |
has |
оператор in |
[[Delete]] |
deleteProperty |
оператор delete |
[[Call]] |
apply |
вызов функции |
[[Construct]] |
construct |
оператор new |
[[GetPrototypeOf]] |
getPrototypeOf |
Object.getPrototypeOf |
[[SetPrototypeOf]] |
setPrototypeOf |
Object.setPrototypeOf |
[[IsExtensible]] |
isExtensible |
Object.isExtensible |
[[PreventExtensions]] |
preventExtensions |
Object.preventExtensions |
[[DefineOwnProperty]] |
defineProperty |
Object.defineProperty, Object.defineProperties |
[[GetOwnProperty]] |
getOwnPropertyDescriptor |
Object.getOwnPropertyDescriptor, for..in , Object.keys/values/entries |
[[OwnPropertyKeys]] |
ownKeys |
Object.getOwnPropertyNames, Object.getOwnPropertySymbols, for..in , Object.keys/values/entries |
JavaScript налагает некоторые условия – инварианты на реализацию внутренних методов и ловушек.
Большинство из них касаются возвращаемых значений:
- Метод
[[Set]]
должен возвращатьtrue
, если значение было успешно записано, иначеfalse
. - Метод
[[Delete]]
должен возвращатьtrue
, если значение было успешно удалено, иначеfalse
. - …и так далее, мы увидим больше в примерах ниже.
Есть и другие инварианты, например:
- Метод
[[GetPrototypeOf]]
, применённый к прокси, должен возвращать то же значение, что и метод[[GetPrototypeOf]]
, применённый к оригинальному объекту. Другими словами, чтение прототипа объекта прокси всегда должно возвращать прототип оригинального объекта.
Ловушки могут перехватывать вызовы этих методов, но должны выполнять указанные условия.
Инварианты гарантируют корректное и последовательное поведение конструкций и методов языка. Полный список инвариантов можно найти в спецификации, хотя скорее всего вы не нарушите эти условия, если только не соберётесь делать что-то совсем уж странное.
Теперь давайте посмотрим, как это всё работает, на реальных примерах.
Значение по умолчанию с ловушкой «get»
Чаще всего используются ловушки на чтение/запись свойств.
Чтобы перехватить операцию чтения, handler
должен иметь метод get(target, property, receiver)
.
Он срабатывает при попытке прочитать свойство объекта, с аргументами:
target
– это оригинальный объект, который передавался первым аргументом в конструкторnew Proxy
,property
– имя свойства,receiver
– если свойство объекта является геттером, тоreceiver
– это объект, который будет использован какthis
при его вызове. Обычно это сам объект прокси (или наследующий от него объект). Прямо сейчас нам не понадобится этот аргумент, подробнее разберём его позже.
Давайте применим ловушку get
, чтобы реализовать «значения по умолчанию» для свойств объекта.
Например, сделаем числовой массив, так чтобы при чтении из него несуществующего элемента возвращался 0
.
Обычно при чтении из массива несуществующего свойства возвращается undefined
, но мы обернём обычный массив в прокси, который перехватывает операцию чтения свойства из массива и возвращает 0
, если такого элемента нет:
let numbers = [0, 1, 2];
numbers = new Proxy(numbers, {
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return 0; // значение по умолчанию
}
}
});
alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (нет такого элемента)
Как видно, это очень легко сделать при помощи ловушки get
.
Мы можем использовать Proxy
для реализации любой логики возврата значений по умолчанию.
Представим, что у нас есть объект-словарь с фразами на английском и их переводом на испанский:
let dictionary = {
'Hello': 'Hola',
'Bye': 'Adiós'
};
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome'] ); // undefined
Сейчас, если фразы в dictionary
нет, при чтении возвращается undefined
. Но на практике оставлять фразы непереведёнными лучше, чем использовать undefined
. Поэтому давайте сделаем так, чтобы при отсутствии перевода возвращалась оригинальная фраза на английском вместо undefined
.
Чтобы достичь этого, обернём dictionary
в прокси, перехватывающий операцию чтения:
let dictionary = {
'Hello': 'Hola',
'Bye': 'Adiós'
};
dictionary = new Proxy(dictionary, {
get(target, phrase) { // перехватываем чтение свойства в dictionary
if (phrase in target) { // если перевод для фразы есть в словаре
return target[phrase]; // возвращаем его
} else {
// иначе возвращаем непереведённую фразу
return phrase;
}
}
});
// Запросим перевод произвольного выражения в словаре!
// В худшем случае оно не будет переведено
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome to Proxy']); // Welcome to Proxy (нет перевода)
target
Пожалуйста, обратите внимание: прокси перезаписывает переменную:
dictionary = new Proxy(dictionary, ...);
Прокси должен заменить собой оригинальный объект повсюду. Никто не должен ссылаться на оригинальный объект после того, как он был проксирован. Иначе очень легко запутаться.
Валидация с ловушкой «set»
Допустим, мы хотим сделать массив исключительно для чисел. Если в него добавляется значение иного типа, то это должно приводить к ошибке.
Ловушка set
срабатывает, когда происходит запись свойства.
set(target, property, value, receiver)
:
target
– это оригинальный объект, который передавался первым аргументом в конструкторnew Proxy
,property
– имя свойства,value
– значение свойства,receiver
– аналогично ловушкеget
, этот аргумент имеет значение, только если свойство – сеттер.
Ловушка set
должна вернуть true
, если запись прошла успешно, и false
в противном случае (будет сгенерирована ошибка TypeError
).
Давайте применим её для проверки новых значений:
let numbers = [];
numbers = new Proxy(numbers, { // (*)
set(target, prop, val) { // для перехвата записи свойства
if (typeof val == 'number') {
target[prop] = val;
return true;
} else {
return false;
}
}
});
numbers.push(1); // добавилось успешно
numbers.push(2); // добавилось успешно
alert("Длина: " + numbers.length); // 2
numbers.push("тест"); // TypeError (ловушка set на прокси вернула false)
alert("Интерпретатор никогда не доходит до этой строки (из-за ошибки в строке выше)");
Обратите внимание, что встроенная функциональность массива по-прежнему работает! Значения добавляются методом push
. Свойство length
при этом увеличивается. Наш прокси ничего не ломает.
Нам не нужно переопределять методы массива push
и unshift
и другие, чтобы добавлять туда проверку на тип, так как внутри себя они используют операцию [[Set]]
, которая перехватывается прокси.
Таким образом, код остаётся чистым и прозрачным.
true
Как сказано ранее, нужно соблюдать инварианты.
Для set
реализация ловушки должна возвращать true
в случае успешной записи свойства.
Если забыть это сделать или возвратить любое ложное значение, это приведёт к ошибке TypeError
.
Перебор при помощи «ownKeys» и «getOwnPropertyDescriptor»
Object.keys
, цикл for..in
и большинство других методов, которые работают со списком свойств объекта, используют внутренний метод [[OwnPropertyKeys]]
(перехватываемый ловушкой ownKeys
) для их получения.
Такие методы различаются в деталях:
Object.getOwnPropertyNames(obj)
возвращает не-символьные ключи.Object.getOwnPropertySymbols(obj)
возвращает символьные ключи.Object.keys/values()
возвращает не-символьные ключи/значения с флагомenumerable
(подробнее про флаги свойств было в главе Флаги и дескрипторы свойств).for..in
перебирает не-символьные ключи с флагомenumerable
, а также ключи прототипов.
…Но все они начинают с этого списка.
В примере ниже мы используем ловушку ownKeys
, чтобы цикл for..in
по объекту, равно как Object.keys
и Object.values
пропускали свойства, начинающиеся с подчёркивания _
:
let user = {
name: "Вася",
age: 30,
_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) ); // Вася,30
Как видно, работает.
Впрочем, если мы попробуем возвратить ключ, которого в объекте на самом деле нет, то Object.keys
его не выдаст:
let user = { };
user = new Proxy(user, {
ownKeys(target) {
return ['a', 'b', 'c'];
}
});
alert( Object.keys(user) ); // <пусто>
Почему? Причина проста: Object.keys
возвращает только свойства с флагом enumerable
. Для того, чтобы определить, есть ли этот флаг, он для каждого свойства вызывает внутренний метод [[GetOwnProperty]]
, который получает его дескриптор. А в данном случае свойство отсутствует, его дескриптор пуст, флага enumerable
нет, поэтому оно пропускается.
Чтобы Object.keys
возвращал свойство, нужно либо чтобы свойство в объекте физически было, причём с флагом enumerable
, либо перехватить вызовы [[GetOwnProperty]]
(это делает ловушка getOwnPropertyDescriptor
), и там вернуть дескриптор с enumerable: true
.
Вот так будет работать:
let user = { };
user = new Proxy(user, {
ownKeys(target) { // вызывается 1 раз для получения списка свойств
return ['a', 'b', 'c'];
},
getOwnPropertyDescriptor(target, prop) { // вызывается для каждого свойства
return {
enumerable: true,
configurable: true
/* ...другие флаги, возможно, "value: ..." */
};
}
});
alert( Object.keys(user) ); // a, b, c
Ещё раз заметим, что получение дескриптора нужно перехватывать только если свойство отсутствует в самом объекте.
Защищённые свойства с ловушкой «deleteProperty» и другими
Существует широко распространённое соглашение о том, что свойства и методы, название которых начинается с символа подчёркивания _
, следует считать внутренними. К ним не следует обращаться снаружи объекта.
Однако технически это всё равно возможно:
let user = {
name: "Вася",
_password: "secret"
};
alert(user._password); // secret
Давайте применим прокси, чтобы защитить свойства, начинающиеся на _
, от доступа извне.
Нам будут нужны следующие ловушки:
get
– для того, чтобы сгенерировать ошибку при чтении такого свойства,set
– для того, чтобы сгенерировать ошибку при записи,deleteProperty
– для того, чтобы сгенерировать ошибку при удалении,ownKeys
– для того, чтобы исключить такие свойства изfor..in
и методов типаObject.keys
.
Вот соответствующий код:
let user = {
name: "Вася",
_password: "***"
};
user = new Proxy(user, {
get(target, prop) {
if (prop.startsWith('_')) {
throw new Error("Отказано в доступе");
} else {
let value = target[prop];
return (typeof value === 'function') ? value.bind(target) : value; // (*)
}
},
set(target, prop, val) { // перехватываем запись свойства
if (prop.startsWith('_')) {
throw new Error("Отказано в доступе");
} else {
target[prop] = val;
return true;
}
},
deleteProperty(target, prop) { // перехватываем удаление свойства
if (prop.startsWith('_')) {
throw new Error("Отказано в доступе");
} else {
delete target[prop];
return true;
}
},
ownKeys(target) { // перехватываем попытку итерации
return Object.keys(target).filter(key => !key.startsWith('_'));
}
});
// "get" не позволяет прочитать _password
try {
alert(user._password); // Error: Отказано в доступе
} catch(e) { alert(e.message); }
// "set" не позволяет записать _password
try {
user._password = "test"; // Error: Отказано в доступе
} catch(e) { alert(e.message); }
// "deleteProperty" не позволяет удалить _password
try {
delete user._password; // Error: Отказано в доступе
} catch(e) { alert(e.message); }
// "ownKeys" исключает _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
:
user = {
// ...
checkPassword(value) {
// метод объекта должен иметь доступ на чтение _password
return value === this._password;
}
}
Вызов user.checkPassword()
получает проксированный объект user
в качестве this
(объект перед точкой становится this
), так что когда такой вызов обращается к this._password
, ловушка get
вступает в действие (она срабатывает при любом чтении свойства), и выбрасывается ошибка.
Поэтому мы привязываем контекст к методам объекта – оригинальный объект target
в строке (*)
. Тогда их дальнейшие вызовы будут использовать target
в качестве this
, без всяких ловушек.
Такое решение обычно работает, но не является идеальным, поскольку метод может передать оригинальный объект куда-то ещё, и возможна путаница: где изначальный объект, а где – проксированный.
К тому же, объект может проксироваться несколько раз (для добавления различных возможностей), и если передавать методу исходный, то могут быть неожиданности.
Так что везде использовать такой прокси не стоит.
Современные интерпретаторы JavaScript поддерживают приватные свойства в классах. Названия таких свойств должны начинаться с символа #
. Они подробно описаны в главе Приватные и защищённые методы и свойства. Для них не нужны подобные прокси.
Впрочем, приватные свойства имеют свои недостатки. В частности, они не наследуются.
«В диапазоне» с ловушкой «has»
Давайте посмотрим ещё примеры.
Предположим, у нас есть объект range
, описывающий диапазон:
let range = {
start: 1,
end: 10
};
Мы бы хотели использовать оператор in
, чтобы проверить, что некоторое число находится в указанном диапазоне.
Ловушка has
перехватывает вызовы in
.
has(target, property)
target
– это оригинальный объект, который передавался первым аргументом в конструкторnew Proxy
,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
– это оригинальный объект (как мы помним, функция – это объект в языке JavaScript),thisArg
– это контекстthis
.args
– список аргументов.
Например, давайте вспомним декоратор delay(f, ms)
, созданный нами в главе Декораторы и переадресация вызова, call/apply.
Тогда мы обошлись без создания прокси. Вызов delay(f, ms)
возвращал функцию, которая передавала вызовы f
после ms
миллисекунд.
Вот предыдущая реализация, на основе функции:
function delay(f, ms) {
// возвращает обёртку, которая вызывает функцию f через таймаут
return function() { // (*)
setTimeout(() => f.apply(this, arguments), ms);
};
}
function sayHi(user) {
alert(`Привет, ${user}!`);
}
// после обёртки вызовы sayHi будут срабатывать с задержкой в 3 секунды
sayHi = delay(sayHi, 3000);
sayHi("Вася"); // Привет, Вася! (через 3 секунды)
Как мы уже видели, это в целом работает. Функция-обёртка в строке (*)
вызывает нужную функцию с указанной задержкой.
Но наша функция-обёртка не перенаправляет операции чтения/записи свойства и другие. После обёртывания доступ к свойствам оригинальной функции, таким как name
, length
, и другим, будет потерян.
function delay(f, ms) {
return function() {
setTimeout(() => f.apply(this, arguments), ms);
};
}
function sayHi(user) {
alert(`Привет, ${user}!`);
}
alert(sayHi.length); // 1 (в функции length - это число аргументов в её объявлении)
sayHi = delay(sayHi, 3000);
alert(sayHi.length); // 0 (в объявлении функции-обёртки ноль аргументов)
Прокси куда более мощные в этом смысле, поскольку они перенаправляют всё к оригинальному объекту.
Давайте используем прокси вместо функции-обёртки:
function delay(f, ms) {
return new Proxy(f, {
apply(target, thisArg, args) {
setTimeout(() => target.apply(thisArg, args), ms);
}
});
}
function sayHi(user) {
alert(`Привет, ${user}!`);
}
sayHi = delay(sayHi, 3000);
alert(sayHi.length); // 1 (*) прокси перенаправляет чтение свойства length на исходную функцию
sayHi("Вася"); // Привет, Вася! (через 3 секунды)
Результат такой же, но сейчас не только вызовы, но и другие операции на прокси перенаправляются к оригинальной функции. Таким образом, операция чтения свойства sayHi.length
возвращает корректное значение в строке (*)
после проксирования.
Мы получили лучшую обёртку.
Существуют и другие ловушки: полный список есть в начале этой главы. Использовать их можно по аналогии с вышеописанными.
Reflect
Reflect
– встроенный объект, упрощающий создание прокси.
Ранее мы говорили о том, что внутренние методы, такие как [[Get]]
, [[Set]]
и другие, существуют только в спецификации, что к ним нельзя обратиться напрямую.
Объект Reflect
делает это возможным. Его методы – минимальные обёртки вокруг внутренних методов.
Вот примеры операций и вызовы Reflect
, которые делают то же самое:
Операция | Вызов 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', 'Вася');
alert(user.name); // Вася
В частности, Reflect
позволяет вызвать операторы (new
, delete
…) как функции (Reflect.construct
, Reflect.deleteProperty
, …). Это интересная возможность, но здесь нам важно другое.
Для каждого внутреннего метода, перехватываемого Proxy
, есть соответствующий метод в Reflect
, который имеет такое же имя и те же аргументы, что и у ловушки Proxy
.
Поэтому мы можем использовать Reflect
, чтобы перенаправить операцию на исходный объект.
В этом примере обе ловушки get
и set
прозрачно (как будто их нет) перенаправляют операции чтения и записи на объект, при этом выводя сообщение:
let user = {
name: "Вася",
};
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 = "Петя"; // выводит "SET name=Петя"
Здесь:
Reflect.get
читает свойство объекта.Reflect.set
записывает свойство и возвращаетtrue
при успехе, иначеfalse
.
То есть, всё очень просто – если ловушка хочет перенаправить вызов на объект, то достаточно вызвать Reflect.<метод>
с теми же аргументами.
В большинстве случаев мы можем сделать всё то же самое и без Reflect
, например, чтение свойства Reflect.get(target, prop, receiver)
можно заменить на target[prop]
. Но некоторые нюансы легко упустить.
Прокси для геттера
Рассмотрим конкретный пример, демонстрирующий, чем лучше Reflect.get
, и заодно разберёмся, зачем в get/set
нужен третий аргумент receiver
, мы его ранее не использовали.
Допустим, у нас есть объект user
со свойством _name
и геттером для него.
Сделаем вокруг user
прокси:
let user = {
_name: "Гость",
get name() {
return this._name;
}
};
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return target[prop];
}
});
alert(userProxy.name); // Гость
Ловушка get
здесь «прозрачная», она возвращает свойство исходного объекта и больше ничего не делает. Для нашего примера этого вполне достаточно.
Казалось бы, всё в порядке. Но давайте немного усложним пример.
Если мы унаследуем от проксированного user
объект admin
, то мы увидим, что он ведёт себя некорректно:
let user = {
_name: "Гость",
get name() {
return this._name;
}
};
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return target[prop]; // (*) target = user
}
});
let admin = {
__proto__: userProxy,
_name: "Админ"
};
// Ожидается: Админ
alert(admin.name); // выводится Гость (?!?)
Обращение к свойству admin.name
должно возвращать строку "Админ"
, а выводит "Гость"
!
В чём дело? Может быть, мы делаем что-то не так с наследованием?
Но если убрать прокси, то всё будет работать как ожидается.
На самом деле, проблема в прокси, в строке (*)
.
-
При чтении
admin.name
, так как в объектеadmin
нет свойстваname
, оно ищется в прототипе. -
Прототипом является прокси
userProxy
. -
При чтении из прокси свойства
name
срабатывает ловушкаget
и возвращает его из исходного объекта какtarget[prop]
в строке(*)
.Вызов
target[prop]
, еслиprop
– это геттер, запускает его код в контекстеthis=target
. Поэтому результатом являетсяthis._name
из исходного объектаtarget
, то есть изuser
.
Именно для исправления таких ситуаций нужен receiver
, третий аргумент ловушки get
. В нём хранится ссылка на правильный контекст this
, который нужно передать геттеру. В данном случае это admin
.
Как передать геттеру контекст? Для обычной функции мы могли бы использовать call/apply
, но это же геттер, его не вызывают, просто читают значение.
Это может сделать Reflect.get
. Всё будет работать верно, если использовать его.
Вот исправленный вариант:
let user = {
_name: "Гость",
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: "Админ"
};
alert(admin.name); // Админ
Сейчас receiver
, содержащий ссылку на корректный this
(то есть на admin
), передаётся геттеру посредством Reflect.get
в строке (*)
.
Можно переписать ловушку и короче:
get(target, prop, receiver) {
return Reflect.get(...arguments);
}
Методы в Reflect
имеют те же названия, что и соответствующие ловушки, и принимают такие же аргументы. Это было специально задумано при разработке спецификации JavaScript.
Так что return Reflect...
даёт простую и безопасную возможность перенаправить операцию на оригинальный объект и при этом предохраняет нас от возможных ошибок, связанных с этим действием.
Ограничения прокси
Прокси – уникальное средство для настройки поведения объектов на самом низком уровне. Но они не идеальны, есть некоторые ограничения.
Встроенные объекты: внутренние слоты
Многие встроенные объекты, например Map
, Set
, Date
, Promise
и другие используют так называемые «внутренние слоты».
Это как свойства, но только для внутреннего использования в самой спецификациии. Например, Map
хранит элементы во внутреннем слоте [[MapData]]
. Встроенные методы обращаются к слотам напрямую, не через [[Get]]/[[Set]]
. Таким образом, прокси не может перехватить их.
Почему это имеет значение? Они же всё равно внутренние!
Есть один нюанс. Если встроенный объект проксируется, то в прокси не будет этих «внутренних слотов», так что попытка вызвать на таком прокси встроенный метод приведёт к ошибке.
Пример:
let map = new Map();
let proxy = new Proxy(map, {});
proxy.set('test', 1); // будет ошибка
Внутри себя объект типа Map
хранит все данные во внутреннем слоте [[MapData]]
. Прокси не имеет такого слота. Встроенный метод Map.prototype.set
пытается получить доступ к своему внутреннему свойству this.[[MapData]]
, но так как this=proxy
, то не может его найти и завершается с ошибкой.
К счастью, есть способ исправить это:
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 (работает!)
Сейчас всё сработало, потому что get
привязывает свойства-функции, такие как map.set
, к оригинальному объекту map
. Таким образом, когда реализация метода set
попытается получить доступ к внутреннему слоту this.[[MapData]]
, то всё пройдёт благополучно.
Array
не использует внутренние слотыВажным исключением является встроенный объект Array
: он не использует внутренние слоты. Так сложилось исторически, ведь массивы были добавлены в язык очень давно.
То есть описанная выше проблема не возникает при проксировании массивов.
Приватные поля
Нечто похожее происходит и с приватными полями классов.
Например, метод getName()
осуществляет доступ к приватному полю #name
, после проксирования он перестаёт работать:
class User {
#name = "Гость";
getName() {
return this.#name;
}
}
let user = new User();
user = new Proxy(user, {});
alert(user.getName()); // Ошибка
Причина всё та же: приватные поля реализованы с использованием внутренних слотов. JavaScript не использует [[Get]]/[[Set]]
при доступе к ним.
В вызове getName()
значением this
является проксированный user
, в котором нет внутреннего слота с приватными полями.
Решением, как и в предыдущем случае, является привязка контекста к методу:
class User {
#name = "Гость";
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()); // Гость
Однако, такое решение имеет ряд недостатков, о которых уже говорилось: методу передаётся оригинальный объект, который может быть передан куда-то ещё, и это может поломать всю функциональность проксирования.
Прокси != оригинальный объект
Прокси и объект, который проксируется, являются двумя разными объектами. Это естественно, не правда ли?
Если мы используем оригинальный объект как ключ, а затем проксируем его, то прокси не будет найден:
let allUsers = new Set();
class User {
constructor(name) {
this.name = name;
allUsers.add(this);
}
}
let user = new User("Вася");
alert(allUsers.has(user)); // true
user = new Proxy(user, {});
alert(allUsers.has(user)); // false
Как мы видим, после проксирования не получается найти объект user
внутри множества allUsers
, потому что прокси – это другой объект.
===
Прокси способны перехватывать много операторов, например new
(ловушка construct
), in
(ловушка has
), delete
(ловушка deleteProperty
) и так далее.
Но нет способа перехватить проверку на строгое равенство. Объект строго равен только самому себе, и никаким другим значениям.
Так что все операции и встроенные классы, которые используют строгую проверку объектов на равенство, отличат прокси от изначального объекта. Прозрачной замены в данном случае не произойдёт.
Отключаемые прокси
Отключаемый (revocable) прокси – это прокси, который может быть отключён вызовом специальной функции.
Допустим, у нас есть какой-то ресурс, и мы бы хотели иметь возможность закрыть к нему доступ в любой момент.
Для того, чтобы решить поставленную задачу, мы можем использовать отключаемый прокси, без ловушек. Такой прокси будет передавать все операции на проксируемый объект, и у нас будет возможность в любой момент отключить это.
Синтаксис:
let {proxy, revoke} = Proxy.revocable(target, handler)
Вызов возвращает объект с proxy
и функцией revoke
, которая отключает его.
Вот пример:
let object = {
data: "Важные данные"
};
let {proxy, revoke} = Proxy.revocable(object, {});
// передаём прокси куда-нибудь вместо оригинального объекта...
alert(proxy.data); // Важные данные
// позже в коде
revoke();
// прокси больше не работает (отключён)
alert(proxy.data); // Ошибка
Вызов revoke()
удаляет все внутренние ссылки на оригинальный объект из прокси, так что между ними больше нет связи, и оригинальный объект теперь может быть очищен сборщиком мусора.
Мы можем хранить функцию revoke
в WeakMap
, чтобы легко найти её по объекту прокси:
let revokes = new WeakMap();
let object = {
data: "Важные данные"
};
let {proxy, revoke} = Proxy.revocable(object, {});
revokes.set(proxy, revoke);
// ..позже в коде..
revoke = revokes.get(proxy);
revoke();
alert(proxy.data); // Ошибка (прокси отключён)
Преимущество такого подхода в том, что мы не должны таскать функцию revoke
повсюду. Мы получаем её при необходимости из revokes
по объекту прокси.
Мы использовали WeakMap
вместо Map
, чтобы не блокировать сборку мусора. Если прокси объект становится недостижимым (то есть на него больше нет ссылок), то WeakMap
позволяет сборщику мусора удалить его из памяти вместе с соответствующей функцией revoke
, которая в этом случае больше не нужна.
Ссылки
Итого
Прокси – это обёртка вокруг объекта, которая «по умолчанию» перенаправляет операции над ней на объект, но имеет возможность перехватывать их.
Проксировать можно любой объект, включая классы и функции.
Синтаксис:
let proxy = new Proxy(target, {
/* ловушки */
});
…Затем обычно используют прокси везде вместо оригинального объекта target
. Прокси не имеет собственных свойств или методов. Он просто перехватывает операцию, если имеется соответствующая ловушка, а иначе перенаправляет её сразу на объект target
.
Мы можем перехватывать:
- Чтение (
get
), запись (set
), удаление (deleteProperty
) свойства (даже несуществующего). - Вызов функции (
apply
). - Оператор
new
(ловушкаconstruct
). - И многие другие операции (полный список приведён в начале статьи, а также в документации).
Это позволяет нам создавать «виртуальные» свойства и методы, реализовывать значения по умолчанию, наблюдаемые объекты, функции-декораторы и многое другое.
Мы также можем оборачивать один и тот же объект много раз в разные прокси, добавляя ему различные аспекты функциональности.
Reflect API создано как дополнение к Proxy. Для любой ловушки из Proxy
существует метод в Reflect
с теми же аргументами. Нам следует использовать его, если нужно перенаправить вызов на оригинальный объект.
Прокси имеют некоторые ограничения:
- Встроенные объекты используют так называемые «внутренние слоты», доступ к которым нельзя проксировать. Однако, ранее в этой главе был показан один способ, как обойти это ограничение.
- То же самое можно сказать и о приватных полях классов, так как они реализованы на основе слотов. То есть вызовы проксированных методов должны иметь оригинальный объект в качестве
this
, чтобы получить к ним доступ. - Проверка объектов на строгое равенство
===
не может быть перехвачена. - Производительность: конкретные показатели зависят от интерпретатора, но в целом получение свойства с помощью простейшего прокси занимает в несколько раз больше времени. В реальности это имеет значение только для некоторых «особо нагруженных» объектов.