Как стать автором
Обновить
0
RealTrac Technologies
Разработчик системы локального позиционирования

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

Время на прочтение 7 мин
Количество просмотров 8.1K


Доброго времени суток, хабравчане. Сегодня мы будем создавать хранилище данных с функцией одностороннего связывания данных с использованием 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.
Добра вам, земляне.
Теги:
Хабы:
+6
Комментарии 13
Комментарии Комментарии 13

Публикации

Информация

Сайт
rtlservice.com
Дата регистрации
Дата основания
2009
Численность
31–50 человек
Местоположение
Россия

Истории