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


  1. Imbecile
    10.10.2021 17:02
    -4

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

    enum Color {
      Red,
      Green,
      Blue
    }
    class Colors {
      [key: string]: Color;
    }
    
    const colors = new Colors();
    colors['red'] = Color.Red;


    1. Alexandroppolus
      10.10.2021 17:06
      +7

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


    1. PaulIsh Автор
      10.10.2021 17:12

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

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



  1. 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).


    1. PaulIsh Автор
      10.10.2021 17:18

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


      1. kahi4
        10.10.2021 17:30

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

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

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


  1. GCU
    10.10.2021 23:33

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


  1. bBars
    11.10.2021 04:22

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


  1. 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


    1. PaulIsh Автор
      11.10.2021 07:14

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


    1. MKMatriX
      11.10.2021 07:55

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


    1. 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 { ... }


  1. bruian
    11.10.2021 12:22
    -1

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


    1. PaulIsh Автор
      11.10.2021 12:47

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


  1. kicum
    11.10.2021 14:19

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


    1. PaulIsh Автор
      11.10.2021 14:35

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