Доброго времени суток, хабравчане. Сегодня мы будем создавать хранилище данных с функцией одностороннего связывания данных с использованием Proxy и некоторых других плюшек ECMAScript 2015.
Что же такое 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.
Добра вам, земляне.
Комментарии (13)
gearbox
15.09.2016 15:08А почему не WeakMap и WeakSet?
sinires
15.09.2016 15:41Вызов множества cb происходит через forEach. WeakMap не позволяет его использовать.
gearbox
15.09.2016 16:54Вы говорите про это:
this.listeners.set(key, new Map());
Я про это:
В конструкторе OS
constructor() {
this.storage = new Map(); //поле хранения объектов
this.listeners = new Map(); //поле хранения слушателей
this.serviceField = new Set([on
, 'un']); //”сервисные” поля, т.е. поля объекта используемые библиотекой.
}sinires
15.09.2016 17:26Ок, понял =). Профита большого не увидел, ссылка на github есть, буду рад любым предложениям =)
keslo
16.09.2016 08:34Вы применяете где-то данный функционал? Просто интересно где это может пригодиться. А за статью спасибо!
sinires
16.09.2016 09:38Как и O.o для отслеживания событий изменения объектов. У нас много ассинхрона в проекте.
Как хранилище задумывалось для того, чтобы не хранить объекты в глобальной области, а подключать к модулям в случае необходимости.
Однако в данный момент мы по большей части отошли от этого в сторону использования локальной шины данных реализованной на клиенте.
Спасибо Вам, за интересные вопросы.
Igor-Maf
для стрелочных функций не нужно делать let self = this, можно просто вызывать this, у них контекст того, где они были созданы
sinires
Да, вы на 100% правы, недоглядел.
Спасибо
Igor-Maf
не за что, Вам спасибо за статью, не успел разобрать полностью еще, но обязательно до-разберу