1 July 2014

Ribs.js — вложенные атрибуты, вычисляемые поля и биндинги для Backbone.js

Mail.ru Group corporate blogWebsite developmentJavaScript


Привет! Меня зовут Валерий Зайцев, я клиентсайд-разработчик проекта Таргет 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 подход, нам необходимо:
  1. забрать объект из модели;
  2. создать копию этого объекта;
  3. внести необходимые изменения;
  4. положить обратно.

Согласитесь, это крайне неудобно. А если учесть тот факт, что объекты могут быть огромными, то это еще и очень затратно. Куда проще изменить именно тот атрибут, который нужно:

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 родились в ходе работы над реальными боевыми задачами. Эти идеи будут появляться дальше, и лучшие из них будут попадать в следующие версии библиотеки.
Tags:javascriptbackbone.jsbackboneribsribs.js
Hubs: Mail.ru Group corporate blog Website development JavaScript
+50
11.3k 122
Comments 42