Collection – фреймворк для управления данными

в 13:23, , рубрики: javascript, javascript framework, Песочница, СУБД, шаблонизация, метки: , ,

Введение

При написании современного веб-приложения все чаще используется подход, когда сервер присылает не конечный HTML код, а набор некоторых данных (как правило, JSON), которые затем собираются на клиенте. Это действительно очень удобно и здорово, тем более что сейчас существует огромное количество замечательных шаблонизаторов для клиента.

Агрегирование данных

Для манипуляций с данными в нативном JS есть интерфесы для массивов, такие как:

  • итеративные методы, которые позволяют изменять исходный массив или создавать новый;
  • методы сортировки данных;
  • методы добавления и удаления элементов.

С простыми объектами все хуже, т.к. у нас лишь инструменты для добавления и удаления данных. Конечно, часть функционала мы можем добавить, используя шаблон заимствования (для Array.prototype), однако все равно придется реализовывать дополнительный интерфейс, т.к. использование в явном виде может быть неудобно и трудно поддерживаемо. Именно по этой причине несколько лет назад я начал писать небольшую библиотеку, которая, по сути, была прокси-объектом для массивов и хеш-таблиц, чтобы я мог не думать о том с какой структурой я работаю, и какие методы я могу использовать.

var data = new Collection({
  name: 'Koba',
  age: 22,
  skils: ['js', 'css', 'html']
});

data.remove('name');

var data2 = new Collection([1, 2, 3, 4]);
data2.remove(0);

Для массивов, как правило, методы просто перенаправлялись на нативные реализации, а для объектов в большинстве случаев приходилось писать реализацию самому. Такой подход оказался невероятно удобным, т.к. я получил одинаковый интерфейс для управления данными вне зависимости от их структуры. Затем я подумал, что было бы здорово иметь под рукой инструмент для полноценного управления данными, я захотел написать СУБД.

Запросы

В общем виде всего существует два вида запросов на выборку данных: по ссылке и по условию.

var a = [1, 2, [1, [1 ,2]]];

// запросим элемент по ссылке
a[2][1][0];

var b = [1, 2, 3, 4, 5, 6];
// запросим элементы по условие (кратность двум)
a.filter(function (el) {
  return el % 2 === 0;
});

Как видите, запрос по ссылке — это просто выбор определенного элемента, а запрос по условию это некоторое множество.

Запрос по ссылке

В своей реализации таких запросов я поддался влиянию CSS селекторов и вместо квадратных скобок для указания родительских отношения я использовал знак больше (>), а также добавил возможность обращения не только по имени ключа, но и по порядку (индексу) во множестве.

var db = new Collection({
  next: [{a: 1}, {a: 1, b: 2}]
};

// запросим элемент по ссыле ['next'][1]['b']
// eq делает запро по порядку, если индекс отрицательный, то отсчет идет с конца
db.get('next > eq(-1) > eq(-1)');

Как видите, что логика довольна прозрачна.

Запрос по условию

Как и в нативных методов массивов JavaScript, реализация условных методов Collection — это простой итеративный обход коллекции и вызов callback функции на каждом элементе. Функция callback (далее фильтр) возвращает логическое значение, которое и определяет, соответствует ли элемент запросу. Первые 3 входных параметра фильтра соответствуют нативным реализациям:

  • el — активный элемент коллекции;
  • key — ключ элемента (для массивов это индекс);
  • data — ссылка на исходные данные (т.е. el = data[key]).

Далее идут дополнительные параметры Collection:

  • i — номер итерации;
  • length — специальная функция, которая возвращает длину коллекции (вызов функции кешируется);
  • cObj — ссылка на экземпляр Collection;
  • id — ИД коллекции.

Указатель this в фильтре ссылается на сам фильтр (это удобно для передаче внешних данных функции как ее свойство, т.к. в ECMAScript 5 убрали arguments.callee).

var db = new Collection([1, 2, 3, 4, 5]);
// выберем все элементы не кратные двум и больше 2-х
db.get(function (el) {
  return el % 2 !== 0 && el > 2;
});

Как видите, что в Collection многие методы полиморфны, т.е. в зависимости от входных параметров выполняют те или иные действия.

Строчное сокращение фильтра

В большинстве случаев условия фильтров настолько простые, что всю логику можно записать после return, однако все равно приходится каждый раз писать объявления функции, перечислять нужные входные параметры и т.д. Для решения проблемы «лишнего кода» в Collection реализованы механизмы строчных выражений (сокращений), например:

// выберем все элементы не кратные двум и больше 2-х
db.get(':el % 2 !== 0 && el > 2');

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

db.get('(:el % 2 !== 0) && (:el > 2)');

Как видите, мы использовали 2 строчных сокращения фильтра в одном условие (обратите, что мы взяли условия в скобки — это нужно для того, чтобы показать, что это 2 разных условия). В результате у нас получилось 2 атомарных фильтра, которые участвуют в отборке данных. Вложенность скобок не ограниченна и вы также можете использовать логические операторы: !, &&, ||. Где такой подход нужен на практике, я расскажу чуть позже.

Условие в рамках контекста

По умолчанию глобальным контекстом данных являются сам исходный объект, т.е.

var db = new Collection({
  humans: [1, 2, 3, 4, 5],
  animals: [1, 2, 3, 4, 5]
});

// попробуем получить все четные элементы внутри humans
db.get(':i % 2 === 0');

Данный запрос вернет совершенно не то, что мы хотели видеть, т.к. он действует в рамках глобального контекста и обходит сам объект вместо его свойства humans. Для точного указания контекста мы можем использовать дополнительный механизм строчных сокращений:

// явно укажем контекст (для указания контекста используется синтаксис запроса по ссылке)
// символы >> являются разделителем контекста и фильтра
db.get('humans >> :i % 2 === 0');

А как же указать контекст в случае использования явного объявления функции? Разумеется, такая возможность есть, однако для этого придется явно указать глобальный контекст обхода (как это сделать будет рассказано чуть позже).

Дополнительные параметры условных методов

Помимо задачи условия на выборку у условных методов Collection существует большое количество дополнительных параметров:

  • id — ИД коллекции (по умолчанию берётся активная)
  • mult — если указать false, то цикл автоматически прервётся при первом успешном выполнении (по умолчанию: true);
  • count — максимальное количество успешных выполнений цикла (по умолчанию: весь объект);
  • from — количество пропускаемых успешных выполнений циклов (по умолчанию: 0);
  • indexOf — начальная точка для цикла (по умолчанию: 0);
  • lastIndexOf — конечная точка для цикла (по умолчанию: длина коллекции);
  • rev — если указать true, то коллекция обходится с конца (т.е. отчёт идёт не с нулевого элемента, а с конца).

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

Стек

Для инкапсуляции данных внутри экземпляра Collection реализован специальный механизм хранения объектов, названный стеком, а также специальное АПИ для управления им. Рассмотрим на примере фильтров.

// вместо того чтобы каждый раз описывать условия выборки
// можно заранее добавить их в стек фильтров экземпляра

// добавим 3 фильтра в стек, причем третий будет состоять из первых двух
db.pushFilter({
  filter1: function (el) {
    return el % 2 === 0;  
  },
  filter2: function (el) {
    return el > 5;
  },
  filter3: 'filter1 && filter2 && (:el < 15)'
});

Как видите, что использование стека в фильтрах позволяет делать более гибкие запросы, однако это ещё не всё. Для вызова фильтра в любом условном методе достаточно написать его ИД, однако даже когда ИД не указан и выполняется условная операция, то на все элементы коллекции всё равно накладывается условие (по умолчанию условие всегда возвращает true). Дело в том, что помимо стековых параметров в Collection существуют так называемые «активные», т.е. которые установлены по умолчанию. Для фильтров это означает, что помимо указанного условия все элементы коллекции должны соответствовать активному условию тоже.

// сделаем filter2 активным
db.setFilter('filter2');

Теперь, все элементы вне зависимости от условия также должны соответствовать активному фильтру. Если активный параметр хранится в стеке, то к нему всегда можно обратиться по его ИД, однако для активных параметров также существует указатель active. Такой указатель нужен, когда вы точно не знаете, какой параметр является активным или, когда параметр не имеет места в стеке.
Помимо фильтров место в стеке имеет многие параметры, в том числе сами коллекции.

// добавим 2 коллекции в стек
db.pushCollection({
  table1: [1, 2, 3, 4, 5, 6],
  table2: [[1, 2, 3, 4]]
});

// сделаем выборку из первой коллекции
db.get(':el % 2 === 0', 'table1');

// перенесём данные по условию из первой коллекции в первый элемент второй
db.move(':el > 2', 'table1>>+table2:0');

Как видите, в поле указания ИД можно также использовать строковые выражения, синтаксис записи здесь следующий: ИД источника + >> или >>> (в случае указания этого разделителя, результирующая коллекция будет назначена активной) + [+] (знак плюса, в случае указания результирующая коллекция будет модифицирована, а не переписана) +: + дополнительный контекст для результирующей коллекции.

// сохраним результат выборки в новую коллекцию и сделаем её активной
db.get(':el % 2 === 0', 'table1>>>table3');

Контекст

Очень часто коллекции могут иметь сложную иерархическую структуру, например:

var db = new Collection({
  games: {
    action: [
      {
        name: 'HalfLife',
        hero: 'Gordon Freeman'
      },
      {
        name: 'Halo',
        hero: 'Master Chief'
      }
    ],
    rpg: [
      {
        name: 'Mass Effect',
        hero: 'Shepard'
      },
      {
        name: 'Summoner',
        hero: 'Joseph'
      }
    ]
  }
});

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

// назначим новый активнй контекст
db.newContext('games > action');

// отсортируем коллекцию по полю name
db.sort('name');

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

// поднимемся на один уровень вверх по контексту (новый  контекст равен games)
db.parent();

Подробнее про стек: ссылка, ссылка.

Хранение данных

В Collection реализован интерфейс для хранения данных на компьютере клиенте используя DOM Storage (само хранилище не эмулируется для старых браузеров, т.к. для этого существует множество других библиотек). Реализованы возможности полного дампа экземпляра или отдельно нескольких коллекций, а также присутствует возможность указания «времени жизни» данных.

// сохраним все данные
db.saveAll();

// загрузим все данные в экземпляр
db.loadAll();

Подробнее про хранения данных.

Шаблонизация данных

Для визуального представления данных в браузере клиента в Collection существует специальный механизм шаблонов. Шаблон — это специальная функция callback (стековый параметр), которая для каждого элемента коллекции возвращает строку представления, затем результирующая строка вставляется в DOM или возвращается в качестве ответа метода. Шаблон можно создавать явно, указывая необходимую функцию.

db.newTemplate(function (el, key, data, i, length, cObj, id) {
  return '<p><b>' + el.name  + '</b> sex:' + el.sex + '; age:' + el.age + '</p>';
});

Как видите, что шаблон по логике своей работы схож с фильтром. Однако такой вид указания шаблона может быть не удобным при использовании на практики, т.к. мешается логика с представлением. Для решения этой проблемы в Collection реализован механизм DOM шаблонов, перепишем наш пример.

<script type="text/ctpl" id="myTemplate">
  <p>
    <?js echo el.name; ?> sex: <?js echo el.sex; ?>; age: <?js echo el.age; ?>
  </p>
</script>

Такой подход написания шаблонов позволит разделять сущности вашего приложения (тип операторных скобок и сам оператор вывода можно легко поменять через специальные константы).
Для отображения шаблона существует специальный метод print, а также есть специальный механизм, для автоматического создания интерфейсов навигации. Подробнее про всё.

Заключение

Рассмотренные выше возможности являются малой частью очень мощного фреймворка Collection, разработка которого ведётся мною уже почти 3 года. Пример использования: карта путей. Сейчас я работаю над одним стартапом, где пробую свою разработку в качестве основной серверной СУБД.

Подробнее об основных возможностях Collection вы можете узнать на официальном сайте проекта.
Проект на github.
Обсуждение на javascript.ru.

Автор: kobezzza


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js