Индексаторы (Indexers) в JavaScript

В некоторых современных объектно-ориентированных языках есть понятие идексаторов – свойств, позволяющих работать с экземпляром класса как с массивом, используя [] нотацию. В этой статье я хочу продемонстрировать как это сделать на современном 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.

@PaulIsh
10.10.2021 18:53 UTC
Первоисточник

Комментарии

@Imbecile
10.10.2021 14:02 UTC
-4

Ещё одна причина посмотреть в сторону typescript. Там это из коробки.

enum Color {
  Red,
  Green,
  Blue
}
class Colors {
  [key: string]: Color;
}

const colors = new Colors();
colors['red'] = Color.Red;
@Alexandroppolus
10.10.2021 14:06 UTC
+7

Доступ на чтение/запись через квадратные скобки испокон веку есть и в JS. Статья о том, как этот доступ обвесить кастомной логикой.

@PaulIsh
10.10.2021 14:12 UTC
0

К сожалению нет. Методы getColor и setColor, выполняющие роль геттера и сеттера могут иметь сложную логику. В вашем примере, такого нет.

Возможно я сделал недостаточно выразительные примеры в статье.

@jMas
10.10.2021 14:11 UTC
-1
@kahi4
10.10.2021 14:12 UTC
+2

В свою очередь, мне было бы интересно узнать, есть ли еще способы реализовать [] нотацию без использования Proxy.

const foo = {}

foo["color"] = "red";

foo["color"] // > "red"
"color" in foo // > true 

Это я к тому что это просто стандартное поведение js. Даже прокси не нужны.

Сложности появляются если вам при этом нужно сделать дополнительные действия при этом, например, перерисовать все зависимые элементы (mobx, короче). Тут задача разбивается на две категории: список всех известных полей заранее известен и не известен. В первом случае можно через defineProperty выкрутиться, ещё вроде был метод watch, но либо был очень давно либо мне приснился.

Во втором случае, когда список свойств, в которые будут записывать или считывать, не известен, уже без прокси не обойтись (либо нужно менять структуру на такую, при которой он известен или пользоваться getItem/setItem).

@PaulIsh
10.10.2021 14:18 UTC
0

Я налажал с примером пытясь его упростить. Да, речь про сложную логику в геттере/сеттере.

10.10.2021 14:30 UTC
0

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

Звучит как безумие, но тот же redux не то чтобы далеко ушёл от этой идеи.

Во втором+ ангуларе используются «зоны» (zone.js) для этого, но не уверен как они работают, так что ничего утверждать не буду.

@GCU
10.10.2021 20:33 UTC
0

Можно динамически добавлять и удалять геттеры и сеттеры на объект через Object.defineProperty, однако полноценной перегрузки операторов в JavaScript пока нету.

@bBars
11.10.2021 01:22 UTC
0

Такие вот геттеры-сеттеры уровня объекта — это, конечно, хорошо и удобно. Но позволяют писать такой монструозный код, что обфускаторы утратят актуальность

@
11.10.2021 03:06 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
@PaulIsh
11.10.2021 04:14 UTC
0

Спасибо. Этот метод мне действительно кажется удобней, чем я описал в статье.

@MKMatriX
11.10.2021 04:55 UTC
0

Спасибо, очень крутой пример.

@
11.10.2021 07:04 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
@bruian
11.10.2021 09:22 UTC
-1

Базовый аспект языка, действительно повод для написания статьи?

@PaulIsh
11.10.2021 09:47 UTC
0

Вы внимательно прочитали содержание?

@kicum
11.10.2021 11:19 UTC
0

Не очень понял, для чего это делать? Реализовывать сложную логику в гетерах/сетерах? Они не предназначены для этого. Когда я вызываю color.get("#colorName"), я не ожидаю, что произойдет запуск ракеты. Когда я пытаюсь прочитать color["#colorName"], то вообще не ожидаю исполнения какой-либо логики. Зачем?

@PaulIsh
11.10.2021 11:35 UTC
0

Речь не про запуск ракеты, а про любые действия производимые при вызове свойства. Например, при установке цвета чего-то (colors['toolbar'] = 'red') у вас этот элемент должен автоматически применить цвет.