Односторонний binding данных с ECMAScript-2015 Proxy



    Доброго времени суток, хабравчане. Сегодня мы будем создавать хранилище данных с функцией одностороннего связывания данных с использованием Proxy и некоторых других плюшек ECMAScript 2015.

    Что же такое Proxy?


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

    Что мы будем делать?


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

    Итак, поехали…

    За работу


    Подразумеваем, что наше хранилище является экземпляром некого класса-фабрики хранилищ.

    "use strict";
    class OS {
     	//тут будет наш код
    }
    window.ObserveStorage = new OS();

    Все происходящее в дальнейшем будет происходить внутри класса OS.

    Наше хранилище должно иметь следующую структуру данных:

    {
    	хранилище объектов: {
    	ключ объекта: прокси на объект
    }
    хранилище слушателей:{
    	ключ объекта:{
    	тип поля объекта:{
    id слушателя: функция
    }
    }
    }
    }

    Соответственно, для того, чтобы реализовать весь необходимый функционал, мы определим в конструкторе объекты для хранения данных:

    
    class OS {
       constructor() {
           this.storage = new Map(); //поле хранения объектов
           this.listeners = new Map(); //поле хранения слушателей
           this.serviceField = new Set([`on`, 'un']); //”сервисные” поля, т.е. поля объекта используемые библиотекой.
       }
    }

    Описание классов Map и Set я намеренно опущу. В случае, если вам захочется узнать о них подробно, то вам сюда и сюда.

    Поле serviceField необходимо для того, чтобы исключить возможность перезаписи или перебора служебных, но об этом позднее.

    Следующим шагом станет организация добавления нового объекта в наше хранилище.

    Имея объект:

    let object = {key1: ”data”, key2: 1} 

    реализуем следующий метод добавления объекта в хранилище:

    let wrapper = ObserveStorage.add(key, object); //return Proxy


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

    Заранее подразумеваем, что не всем пользователям будет интересен функционал получения объекта из хранилища, да и следить за всеми ключами не всегда удобно, поэтому валидной будет и данная запись:

    let wrapper = ObserveStorage.add(object);

    Так как у нас используются различные ключи и id, делаем простой метод для их генерации.

    static __getId() {
       return (`${Math.random().toFixed(10).toString().replace("0.", "")}${Date.now()}`)
    }

    Теперь, когда мы определили интерфейс и имеем инструмент для генерации различного рода идентификаторов, можно приступить к разработке метода.

    add(...arg) {
    //на входе мы имеем 1 или 2 параметра
       let key, object;
    
       if(arg.length == 1){
           [object] = arg;
            key = OS.__getId(); //метод генерирующий id
       }
       else
           [key, object] = arg;
         
    //данным костыльным решением мы определяем количество параметров и, в случае, если ключ не указан, генерируем его.
    
    //получив жизненно важные для нашей архитектуры аргументы, продолжаем перенос объекта в хранилище.
    
    //во 1) использование ключей подразумевает их уникальность:
       if (this.storage.has(key)) {
           throw new Error(`key ${key} is already in use`);
       }
    
    //во 2) нам необходимо преобразовать текущий (сохраняемый) объект, определив в нем служебные методы, ссылающиеся на методы класса (важно: для того, чтобы определить в классе, на какой объект мы подписываемся – просто замыкаем ключ объекта в функции):
       let self = this;
       object.on = (...arg)=> self.on(key, ...arg); //функция подписки
       object.un = (...arg)=> self.un(key, ...arg); //функция отписки 
      
    //для отслеживания изменения объекта мы генерируем для него storage
       const proxy = this.getProxy(key, object); //return Proxy
    
    //затем создаем для него Map слушателей
       this.listeners.set(key, new Map());
    
    //и, наконец, сохраняем его в хранилище
       this.storage.set(key, proxy);
    
    //для того, чтобы зря не дергать объект, сразу вернем пользователю обертку
       return proxy;
    }

    Нераскрытым остался метод getProxy, не знаю, как вы, а я тайны терпеть не могу. Поэтому, поехали:

    //метод getProxy принимает 2 параметра, 1 – это ключ, под которым сохранен объект, а 2 – это сам объект.
    getProxy(key, object){
       let self = this;
    // возвращает этот метод обертку, которая следит за изменением объекта
       return new Proxy(object, {
    //данная ловушка перехватывает попытку получения полей объекта
                   get(target, field) {
                       return target[field];
                   },
    	//данная ловушка перехватывает попытку записи полей объекта
                   set(target, field, value) {
     //не забываем, что запись в служебные поля недопустима
                       if(self.serviceField.has(field))
                           throw new Error(`Field ${field} is blocked for DB object`);
    
                       const oldValue = target[field];
                       target[field] = value;
    //формируем событие и отправляем его через метод fire класса OS.
                       self.fire(key, {
                           type:       oldValue ? "change" : "add",
                           property:   field,
                           oldValue:   oldValue,
                           value:      value,
                           object:     self.get(key)
                       });
    // на деле событие могло бы быть любым, но, так как в ходе написания этого кода я вдохновлялся почившим O.o, то и событие чем-то напоминает его.
                       return true
                   },
    // перехватчик события удаления полей
                   deleteProperty(target, field) {
    //удаление отсутствующих или служебных полей считаем недопустимым
                       if (!field in target || self.serviceField.has(field)) {
                           return false;
                       }
    
                       const oldValue = target[field];
    
                       delete target[field];
    
                       self.fire(key, {
                           type:       "delete",
                           property:   field,
                           oldValue:   oldValue,
                           value:      undefined,
                           object:     self.get(key)
                       });
    
                       return true;
                   },
    // перехватчик Object.getOwnPropertyNames() функции, отслеживание его в текущей задаче необходимо только для “вычленения” служебных полей из интегрируемого объекта
                   ownKeys(target) {
                       let props = Object.keys(target)
                                       .filter(function (prop) {
                                           return !(self.serviceField.has(prop));
                                       });
                       return props;
                   }
               }
       );
    }

    Важно отметить, что при генерации события

    self.fire(key, {
                           type:       oldValue ? "change" : "add",
                           property:   field,
                           oldValue:   oldValue,
                           value:      value,
                           object:     self.get(key)
                       });

    В качестве объекта передается не target, а обертка. Это необходимо для того, чтобы пользователь, изменяя объект в callback, не наделал неотслеживаемых изменений. Изначально я передавал туда копию объекта, что, на самом деле, тоже не особо хорошо. В блоке выше засветились такие методы, как .get и .fite, так что, следуя по порядку, поговорим о них.

    Метод .get всего-навсего проверяет наличие объекта в хранилище и возвращает его.

    
    get(key) {
       if(this.storage.has(key))
           return this.storage.get(key);
       else{
           console.warn(`Element ${key} is not exist`);
           return undefined;
       }
    }
    

    Перед тем как говорить о методе .fire, стоит упомянуть о подписке на события. Для подписки используются следующий интерфейс:

    wrapper.on(callback, property = "*"); 

    где

    
    property = "*" 

    является значением по умолчанию и обозначает подписку на все поля данного объекта.

    Для примера:

    
    wrapper.on(event => console.log(JSON.stringify(event)), "value");
    wrapper.data = "test"; // События нет
    wrapper.value = 2; // Object{"type":"change","property":"value","oldValue":4,"value":2,"object":{"data":"test","value":2}}
    
    wrapper.on(event => console.log(JSON.stringify(event)), "*");
    wrapper.data = "test"; // Object{"type":"change","property":"data","oldValue":”text”,"value":”test”,"object":{"data":"test","value":1}}
    

    В объект мы интегрируем данный метод на момент записи объекта в хранилище (см. выше). Сам метод является следующей функцией:

    
    on(key, callback, property = "*") {
    //Отсутствие ключа или callback считаем недопустимым
       if (!key || !callback) {
           throw new Error("Key or callback is empty or not exist");
       }
    
    //получаем Map слушателей для данного объекта
       const listeners      = this.listeners.get(key),
    //и генерируем id для нового слушателя
             subscriptionId = OS.__getId();
    //если для поля, на которое пытается произойти подписка, еще не существует слушателей, то генерируем для них новый Map
       !listeners.has(property) && listeners.set(property, new Map());
    //Затем слушатель записывается в хранилище под выданным ему id
       listeners
           .get(property)
           .set(subscriptionId, callback);
    // этот id является результатом выполнения подписки
       return subscriptionId;
    }

    Особое внимание уделяем первому параметру метода .on. Внимательные заметили, что параметров передается 1 или 2, но метод ожидает 3, один из которых – ключ.

    А особо внимательные помнят, что мы замкнули ключ в метод в момент инициализации объекта в хранилище, а именно в строке:

    object.on = (...arg)=> self.on(key, ...arg);

    Для отписки необходимо использовать полученный в ходе подписки subscription Id.

    wrapper.un(subscriptionId);

    Описание функции:

    un(key, subscriptionId) {
    // не отписываемся от того, чего нет
       if (!key) {
           throw new Error("Key is empty or not exist");
       }
    //получаем список слушателей для полей
       const listeners = this.listeners.get(key);
       if (listeners)
    //получаем список слушателей для всех полей Map
           for (let listener of listeners.values()) {
    //и в случае удаления искомого слушателя заканчиваем поиск
               if (listener.delete(subscriptionId))
                   return true;
           }
       return false;
    }

    Мне нравится использование id для различного рода операций, так как это позволяет четко идентифицировать действия пользователя в достаточно прозрачной форме.

    И вот, мы-таки добрались до вызова метода .fire, который и дергает все callback навешанные на обертку:

    
    fire(key, event) {
    //получаем всех подписчиков
       let listeners = this.listeners.get(key)
           ,property = event.property;
    //вызываем всех слушателей параметра
       listeners.has(property) && this.fireListeners(event, listeners.get(property));
    //вызываем всех слушателей объекта
    listeners.has("*")  && this.fireListeners(event, listeners.get("*"));
    }

    Метод fireListeners прозрачен и не нуждается в объяснении:

    
    fireListeners(event, listeners) {
           listeners.forEach((listener)=> {
               setTimeout(()=> listener(event), 0);
           })
       }

    Подводя итоги


    Таким образом, мы написали свое хранилище данных всего за каких-то 150 строк кода, при этом получая возможность подписываться на изменения объектов. Следуя наследию O.o в текущий момент мы не оборачиваем вложенные объекты и не обрабатываем массивы, но все это можно реализовать при должном желании.

    Полный код можно найти здесь.

    С вами был, sinires.
    Добра вам, земляне.
    ГК «РТЛ Сервис»
    53,00
    Разработчик системы локального позиционирования
    Поделиться публикацией

    Комментарии 13

      +2
         let self = this;
         object.on = (...arg)=> self.on(key, ...arg); //функция подписки
         object.un = (...arg)=> self.un(key, ...arg); //функция отписки 
      


      для стрелочных функций не нужно делать let self = this, можно просто вызывать this, у них контекст того, где они были созданы
        0
        Да, вы на 100% правы, недоглядел.
        Спасибо
          0
          не за что, Вам спасибо за статью, не успел разобрать полностью еще, но обязательно до-разберу
        0

        А почему не WeakMap и WeakSet?

          0
          Вызов множества cb происходит через forEach. WeakMap не позволяет его использовать.
            0

            Вы говорите про это:


            this.listeners.set(key, new Map());

            Я про это:
            В конструкторе OS


            constructor() {
            this.storage = new Map(); //поле хранения объектов
            this.listeners = new Map(); //поле хранения слушателей
            this.serviceField = new Set([on, 'un']); //”сервисные” поля, т.е. поля объекта используемые библиотекой.
            }
              0
              Ок, понял =). Профита большого не увидел, ссылка на github есть, буду рад любым предложениям =)
          0
          Да, крутую штуку сделал. И за хорошие пояснения отдельное спасибо.
            0
            Спасибо Вам, что осилили данную статью.
            –2
            https://github.com/millermedeiros/js-signals уже не катит?
              0
              Я не открывал Америку. Мне было интересно сделать то, что сделано через Proxy. Дань памяти почившему O.o
              0
              Вы применяете где-то данный функционал? Просто интересно где это может пригодиться. А за статью спасибо!
                0
                Как и O.o для отслеживания событий изменения объектов. У нас много ассинхрона в проекте.
                Как хранилище задумывалось для того, чтобы не хранить объекты в глобальной области, а подключать к модулям в случае необходимости.
                Однако в данный момент мы по большей части отошли от этого в сторону использования локальной шины данных реализованной на клиенте.
                Спасибо Вам, за интересные вопросы.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое