Привет! Меня зовут Валерий Зайцев, я клиентсайд-разработчик проекта Таргет Mail.ru. В нашем проекте мы используем небезызвестную библиотеку Backbone.js, и, конечно же, нам стало чего-то не хватать. Поразмыслив над возможными решениями наших проблем, я решил написать свое дополнение к Backbone.js, как говорится с блэкджеком и… О нем я и хочу рассказать в этой статье.
Ribs.js — библиотека, расширяющая возможности Backbone. И прелесть в том, что именно расширяет, а не изменяет. Вы можете использовать ваш любимый Backbone, как и прежде, но по необходимости задействовать новые возможности:
- вложенные атрибуты: работа с атрибутами модели любой вложенности;
- вычисляемые атрибуты: добавление в модель атрибутов, которые автоматически пересчитываются при изменении зависимостей (других атрибутов модели);
- биндинги: динамическая связь между атрибутами модели и DOM-элементами.
Рассмотрим эти возможности подробнее.
Вложенные атрибуты
Начнем с самого простого и очевидного. Если вы много пишете на Backbone, то наверняка сталкивались с проблемой, когда нужно внести изменения в модель, атрибуты которой далеко не плоские.
var Simpsons = Backbone.Ribs.Model.extend({
defaults: {
homer: {
age: 40,
weight: 90,
job: 'Safety Inspector'
},
bart: {
age: 10,
weight: 30,
job: '4th grade student'
}
}
});
var family = new Simpsons();
Предположим, что Гомер плотно пообедал и набрал пару килограммов:
Backbone:
var homer = _.clone(family.get('homer'));
homer.weight = 92;
family.set('homer', homer);
Для того, чтобы не нарушать get/set подход, нам необходимо:
- забрать объект из модели;
- создать копию этого объекта;
- внести необходимые изменения;
- положить обратно.
Согласитесь, это крайне неудобно. А если учесть тот факт, что объекты могут быть огромными, то это еще и очень затратно. Куда проще изменить именно тот атрибут, который нужно:
Backbone + Ribs:
family.set('homer.weight', 92);
В результате этого set-a будет сгенерировано событие 'change:homer.weight'
. Не исключена ситуация, когда вам нужно, чтобы события были сгенерированы по всей цепочке вложенности. Для этого методу set необходимо передать {propagation: true}
.
family.set('homer.weight', 92, {propagation: true});
В этом случае будут сгенерированы события 'change:homer.weight'
и 'change:homer'
.
Вычисляемые атрибуты
Сразу оговорюсь, что я привык называть их вычисляемыми полями, поэтому прошу меня извинить за двойную терминологию. Итак, приступим. Очень часто возникает ситуация, когда атрибуты модели нужно преобразовать в определенную форму (назовем ее «результат»), а потом этот результат использовать, да еще и не в одном месте. И хорошо бы, чтобы при изменении атрибутов, результат обновлялся, и всё, что на него завязано, тоже бы обновлялось. В результате получается достаточно громоздкая вереница из дополнительных методов и подписок, которую в будущем будет достаточно проблематично поддерживать.
К примеру, Профессор Фринк задумал некое безумное исследование, в котором ему очень важно контролировать общий вес Гомера и Барта. Давайте сравним реализации на чистом Backbone и на Backbone + Ribs.
Backbone:
var Simpsons = Backbone.Model.extend({
defaults: {
homer: {
age: 40,
weight: 90,
job: 'Safety Inspector'
},
bart: {
age: 10,
weight: 30,
job: '4th grade student'
}
}
});
var family = new Simpsons(),
doSmth = function (model, value) {
console.log(value);
};
family.on('change:bart', function (model, bart) {
var prev = family.previous('bart').weight;
if (bart.weight !== prev) {
doSmth(family, bart.weight + family.get('homer').weight);
}
});
family.on('change:homer', function (model, homer) {
var prev = family.previous('homer').weight;
if (homer.weight !== prev) {
doSmth(family, homer.weight + family.get('bart').weight);
}
});
var bart = _.clone(family.get('bart'));
bart.weight = 32;
family.set('bart', bart);//В консоль будет выведено: 122
var homer = _.clone(family.get('homer'));
homer.weight = 91;
family.set('homer', homer);//В консоль будет выведено: 123
Можно было написать немного по-другому, но это не сильно спасет ситуацию. Разберем, что мы здесь понаписали. Определили функцию, которая будет что-то делать с искомым суммарным весом. Подписались на обработку 'change:homer'
и 'change:bart'
. В обработчиках проверяем, изменилось ли значение веса, и в этом случае вызываем нашу рабочую функцию. Согласитесь, достаточно много писанины для достаточно простой и распространенной ситуации. Теперь то же самое, но короче, нагляднее и проще.
Backbone + Ribs:
var Simpsons = Backbone.Ribs.Model.extend({
defaults: {
homer: {
age: 40,
weight: 90,
job: 'Safety Inspector'
},
bart: {
age: 10,
weight: 30,
job: '4th grade student'
}
},
computeds: {
totalWeight: {
deps: ['homer.weight', 'bart.weight'],
get: function (h, b) {
return h + b;
}
}
}
});
var family = new Simpsons(),
doSmth = function (model, value) {
console.log(value);
};
family.on('change:totalWeight', doSmth);
family.set('bart.weight', 32); //В консоль будет выведено: 122
family.set('homer.weight', 91); //В консоль будет выведено: 123
Что же здесь происходит?! Мы добавили вычисляемое поле, которое зависит от двух атрибутов. При изменении какого-либо из атрибутов, вычисляемое поле пересчитается автоматически. Вычисляемый атрибут можно воспринимать, как обычный атрибут.
Вы можете прочитать его значение:
family.get('totalWeight'); // 120
Можете подписаться на его изменение:
family.on('change:totalWeight', function () {});
В случае необходимости, можно описать метод set для вычисляемого поля, и сетить его без зазрения совести. Стоит отметить, что вычисляемые поля можно использовать в зависимостях других вычисляемых полей. Также, вычисляемые поля очень удобны в биндингах!
Биндинги
Биндинг — это связь между моделью и DOM-элементом. Проще тут и не скажешь. Веб-разработчику изо дня в день приходится выводить всякие данные в интерфейс. Следить за их изменениями. Обновлять. Снова выводить… А тут уже и рабочий день закончился. Вернемся к нашим желтым друзьям. Допустим, захотелось нам выводить суммарный вес в какой-нибудь span
.
Backbone:
var Simpsons = Backbone.Model.extend({
defaults: {
homer: {
age: 40,
weight: 90,
job: 'Safety Inspector'
},
bart: {
age: 10,
weight: 30,
job: '4th grade student'
}
}
});
var Table = Backbone.View.extend({
initialize: function (family) {
this.family = family;
family.on('change:bart', function (model, bart) {
var prev = this.family.previous('bart').weight;
if (bart.weight !== prev) {
this.onchangeTotalWeight(bart.weight + family.get('homer').weight);
}
}, this);
family.on('change:homer', function (model, homer) {
var prev = family.previous('homer').weight;
if (homer.weight !== prev) {
this.onchangeTotalWeight(homer.weight + family.get('bart').weight);
}
}, this);
},
onchangeTotalWeight: function (totalWeight) {
this.$('span').text(totalWeight);
}
});
var family = new Simpsons(),
table = new Table(family);
Backbone + Ribs:
var Simpsons = Backbone.Ribs.Model.extend({
defaults: {
homer: {
age: 40,
weight: 90,
job: 'Safety Inspector'
},
bart: {
age: 10,
weight: 30,
job: '4th grade student'
}
},
computeds: {
totalWeight: {
deps: ['homer.weight', 'bart.weight'],
get: function (h, b) {
return h + b;
}
}
}
});
var Table = Backbone.Ribs.View.extend({
bindings: {
'span': {text: 'family.totalWeight'}
},
initialize: function (family) {
this.family = family;
}
});
var family = new Simpsons(),
table = new Table(family);
Теперь, при любых изменениях веса Гомера или Барта, span
будет обновлен. Помимо текста, вы можете создавать и другие связи между параметрами DOM-элементов и атрибутами моделей:
- двусторонняя связь с input-ами различных типов (text, checkbox, radio);
- css-атрибута;
- css-классы;
- модификаторы;
- и другое.
Помимо обычных биндингов в Ribs.js можно создать биндинг коллекции. Описание этого механизма заслуживает отдельной статьи, поэтому в рамках данной статьи расскажу в двух словах. Биндинг коллекции связывает коллекцию моделей, Backbone.View и некий DOM-элемент. Для каждой модели из коллекции создается свой экземпляр View и кладется в DOM-элемент. Причем при любых изменениях коллекции (добавление/удаление моделей, сортировка) интерфейс обновляется без вашего вмешательства. Тем самым вы получаете динамическое представление для всей коллекции. Область применения очевидна — разнообразные списки и структуры с однотипными данными.
Почему именно Ribs.js, а не что-то другое?
На просторах интернета имеется ряд библиотек, которые добавляют возможность работать со вложенными атрибутами. Есть библиотеки, которые реализуют биндинги. Но это разные библиотеки, и заставить их работать вместе — задача очень непростая, а скорее всего нереализуемая.
Три составляющие Ribs.js (вложенные атрибуты, вычисляемые поля и биндинги) могут работать независимо друг от друга. Но вся мощь раскрывается, когда вы используете их вместе (последний пример это наглядно иллюстрирует).
Ближайший известный мне конкурент — Epoxy.js. Это библиотека со схожими возможностями, но:
- она не умеет работать с вложенными атрибутами, а это, как мы уже убедились, очень полезная вещь;
- одну коллекцию можно использовать только в одном биндинге (в Ribs.js вы можете на базе одной коллекции создавать сколько угодно разнообразных представлений);
- в тесте с биндингом коллекции из 10000 моделей Epoxy.js уступает Ribs.js почти в 2 раза. Исходники теста лежат здесь;
- есть еще ряд придирок к реализации и удобству использования. В сложных задачах из-за этого приходится выдумывать обходные пути и вставлять костыли.
Используя Ribs.js, вы можете сосредоточиться на бизнес-логике, не отвлекаясь на реализацию простейших механизмов. Код становится нагляднее и компактнее, а это самым положительным образом сказывается как на самой разработке, так и на последующей поддержке. К тому же, работа над Ribs.js будет продолжена. Многие идеи, реализованные в Ribs.js родились в ходе работы над реальными боевыми задачами. Эти идеи будут появляться дальше, и лучшие из них будут попадать в следующие версии библиотеки.
Автор: ZaValera