В некоторых современных объектно-ориентированных языках есть понятие идексаторов – свойств, позволяющих работать с экземпляром класса как с массивом, используя [] нотацию. В этой статье я хочу продемонстрировать как это сделать на современном JavaScript.
Приведу пример на C#:
class Colors {
private Dictionary<string, Color> inner = new Dictionary<string, Color>();
// реализуем индексатор
public Color this[string name] {
get => inner[name];
set => inner[name] = value;
}
}
class Program {
static void Main(string[] args) {
Colors c = new Colors();
// обращаемся к свойству используя [] нотацию
c["red"] = Color.Red;
c["yellow"] = Color.Yellow;
}
}
К сожалению, пока такой возможности современный ES6 напрямую не предлагает. Тем не менее, в ES6 есть механизмы позволяющие добиться подобного поведения, хоть это будет выглядеть и работать не так изящно, как в том же C#.
В ES6 есть особый класс Proxy, позволяющий перехватывать обращения к базовому классу. Таким образом, появляется возможность особым образом обрабатывать обращения к полям класса.
Давайте повторим наш C# пример на JS без каких-либо хитростей.
class Colors {
#colors = new Map();
getColor(key) {
// тут может быть сложная логика
return this.#colors.get(key);
}
setColor(key, value) {
// тут может быть сложная логика
this.#colors.set(key, value);
}
hasColor(key) {
return this.#colors.has(key);
}
}
const colors = new Colors();
colors.setColor('red', ...);
colors.setColor('yellow', ...);
colors.hasColor(‘red’); => true
Чтобы добавить цвет в набор мы вместо нотации [] используем метод setColor(). Исправим это дело добавив магию Proxy. Для этого объявим в классе Colors конструктор и будем вместо экземпляра Colors возвращать его Proxy обертку с обработчиками get и set.
constructor() {
return new Proxy(this, {
// перекрывает получение Colors.name
get(target, name) {
if (name in target) {
const result = target[name];
return typeof result === 'function' ? result.bind(target) : result;
}
return target.getColor(name);
},
// перекрывает установку Colors.name
set(target, name, value) {
if (name in target)
target[name] = value;
else
target.setColor(name, value);
return true;
}
});
}
Тут необходимо дополнительно пояснить некоторый код обработчика get:
if (name in target) {…}
Этот фрагмент нужен чтобы дать доступ к свойствам и методам класса (например, hasColor), и только если такого свойства или метода в классе нет, то вызов пойдет в getColor().
return typeof result === 'function' ? result.bind(target) : result;
Тут функции биндятся к target (т.е. к экземпляру Color), так как иначе внутри они получат this = Proxy.
После создания такого конструктора нам становится доступен требуемый синтаксис:
const colors = new Colors();
colors['red'] = …;
colors['yellow'] = …;
colors.has('red'); // true
console.log(colors['yellow']);
Несмотря на то, что мы добились требуемого результата, код на JS выглядит скорее как костыль и использовать его я бы рекомендовал только если вы переносите существующий код с другого языка (C#, Delphi), где такие свойства активно использовались.
В свою очередь, мне было бы интересно узнать, есть ли еще способы реализовать [] нотацию для вызова геттера/сеттера без использования Proxy.
Комментарии (17)
jMas
10.10.2021 17:11-1Why proxy? Probably Symbol.iterator is better? https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Symbol/iterator
kahi4
10.10.2021 17:12+2В свою очередь, мне было бы интересно узнать, есть ли еще способы реализовать [] нотацию без использования Proxy.
const foo = {} foo["color"] = "red"; foo["color"] // > "red" "color" in foo // > true
Это я к тому что это просто стандартное поведение js. Даже прокси не нужны.
Сложности появляются если вам при этом нужно сделать дополнительные действия при этом, например, перерисовать все зависимые элементы (mobx, короче). Тут задача разбивается на две категории: список всех известных полей заранее известен и не известен. В первом случае можно через defineProperty выкрутиться, ещё вроде был метод watch, но либо был очень давно либо мне приснился.
Во втором случае, когда список свойств, в которые будут записывать или считывать, не известен, уже без прокси не обойтись (либо нужно менять структуру на такую, при которой он известен или пользоваться getItem/setItem).
PaulIsh Автор
10.10.2021 17:18Я налажал с примером пытясь его упростить. Да, речь про сложную логику в геттере/сеттере.
kahi4
10.10.2021 17:30Есть, к слову, странный способ обойтись без прокси, но требует, мягко говоря, значительных телодвижений, вплоть до целой архитектуры, заточенной под эти изменения. В целом я говорю про первый ангулар, он же нынче angularjs, в котором есть специфичный digest цикл. Суть в том, что все изменения над объектом должны проводиться только внутри специальной функции (в случае с ангуларом все хендлеры и методы были автоматически обёрнуты в этот цикл), которая после завершения всех операций, сравнивает предыдущее состояние объекта и новое и, таким образом, определяет какие поля поменялись. Если вы поменяете поле два раза и более внутри одного цикла, обработчик сработает только раз, и вообще не сработает если вы поменяете его обратно на значение с которого начинали.
Звучит как безумие, но тот же redux не то чтобы далеко ушёл от этой идеи.
Во втором+ ангуларе используются «зоны» (zone.js) для этого, но не уверен как они работают, так что ничего утверждать не буду.
GCU
10.10.2021 23:33Можно динамически добавлять и удалять геттеры и сеттеры на объект через Object.defineProperty, однако полноценной перегрузки операторов в JavaScript пока нету.
bBars
11.10.2021 04:22Такие вот геттеры-сеттеры уровня объекта — это, конечно, хорошо и удобно. Но позволяют писать такой монструозный код, что обфускаторы утратят актуальность
dynamicult
11.10.2021 06:06+4Совершенно необязательно оборачивать каждый инстанс конкретных классов в прокси-обертку. Достаточно иметь один единственный экземпляр Proxy-объекта на всю систему, который будет базовым прототипом в цепочке наследования любой иерархии классов.
Сам Proxy-объект может не реализовывать никакой логики, он просто перехватывает сообщение
#doesNotUnderstand
с определенным типом, и пересылает его обратно ресиверу, дергая[protected]
метод конкретного класса через, например символ.В итоге вся "громоздкость" сведется к простому и единственном определению
const doesNotUnderstand = new Proxy({}, { get(target, name, receiver) { return receiver[Symbol.for('get')](name) }, set(target, name, value, receiver) { return receiver[Symbol.for('set')](name, value) } })
После чего можно использовать данный объект в каких угодно классах и их цепочках, без необходимости его дублирования, и со всеми плюшками наследования, переопределяя в дочерних классах только то, что нужно.
class Colors { #colors = new Map(); [Symbol.for('get')](key) { // тут может быть сложная логика return this.#colors.get(key); } [Symbol.for('set')](key, value) { // тут может быть сложная логика this.#colors.set(key, value); } // метод же has можно реализовать любым удобным из множества способов // начиная от тривиального прямого has() как члена конкретного класса // а можно прибегнуть все к тому же метапрограммированию [Symbol.hasInstance](key) { // тут может быть сложная логика return this.#colors.has(key); } } Object.setPrototypeOf(Colors.prototype, doesNotUnderstand) const colors = new Colors(); colors['red'] = …; colors['yellow'] = …; 'red' instanceof colors; // true let green = new Color('green') colors[green] = ...; // где green это полноценный объект, а не примитив green instanceof colors // true
PaulIsh Автор
11.10.2021 07:14Спасибо. Этот метод мне действительно кажется удобней, чем я описал в статье.
vvadzim
11.10.2021 10:04Класс! Спасибо!))
Можно ещё небольшой клас чтобы избавиться от непривычного setPrototypeOf в каждом класе и заменить его на привычный extends:/* indexable.js */ const doesNotUnderstand = new Proxy({}, { ... } export class Indexable { // страховка от рекурсии если забыли реализовать [Symbol.for('get')](key) { throw new Error('Unimplemented') } [Symbol.for('set')](key, value) { throw new Error('Unimplemented') } } Object.setPrototypeOf(Indexable.prototype, doesNotUnderstand) /* colors.js */ import { Indexable } from 'indexable.js' class Colors extends Indexable { ... }
kicum
11.10.2021 14:19Не очень понял, для чего это делать? Реализовывать сложную логику в гетерах/сетерах? Они не предназначены для этого. Когда я вызываю color.get("#colorName"), я не ожидаю, что произойдет запуск ракеты. Когда я пытаюсь прочитать color["#colorName"], то вообще не ожидаю исполнения какой-либо логики. Зачем?
PaulIsh Автор
11.10.2021 14:35Речь не про запуск ракеты, а про любые действия производимые при вызове свойства. Например, при установке цвета чего-то (colors['toolbar'] = 'red') у вас этот элемент должен автоматически применить цвет.
Imbecile
Ещё одна причина посмотреть в сторону typescript. Там это из коробки.
Alexandroppolus
Доступ на чтение/запись через квадратные скобки испокон веку есть и в JS. Статья о том, как этот доступ обвесить кастомной логикой.
PaulIsh Автор
К сожалению нет. Методы getColor и setColor, выполняющие роль геттера и сеттера могут иметь сложную логику. В вашем примере, такого нет.
Возможно я сделал недостаточно выразительные примеры в статье.