Работа с моделями данных в javascript

Здравствуйте, Хабралюди.

Мал по-малу из моего опыта и наших проектов родилась небольшая библиотека для работы с моделями в джаваскрипте. Она так и называется — Model.js.

Я расскажу вам вкратце об этой библиотеке и этим постом запрашиваю обратную связь у тех, кто создавая сложные джаваскрипт-приложения, уже решает эту проблему каким-то определенным образом без фреймворков. Интересно также и мнение тех, кто только подыскивает подходящий инструмент для своих нужд: какой инструмент вам нужен и насколько вам подходит Model.js?

Зачем?


Чтобы упростить работу со слоем данных, не используя фреймворков. Текущая первая версия библиотеки — v0.1 весом около 12К — призвана помочь в первую очередь с валидацией данных и управлением событиями, в частности c событиями при изменении данных.

Что особенного?


Сахар. Обыкновенный приятный глазу синтаксический сахар.

Модель описыватся проще некуда.
var Note = new Model('Note', function () {
  this.attr('id!', 'number');
  this.attr('title', 'string', 'nonempty');
  this.attr('text', 'string');
});

Затем создаем сущности, как обычные объекты.
var note = new Note({ id: 123, title: "Hello World" });


Публичные свойства сущности


Геттеры значений атрибутов. В нашем примере это:
note.data.id
note.data.title
note.data.text

Сеттеры. Кроме изменения значений также «выстреливают» событие change.
note.data.id =
note.data.title =
note.data.text =
note.data = {…}

Геттер note.get(attrName[, attrName, …]) возвратит объект со значениями запрашиваемых атрибутов.

note.get() возвратит копию всех данных.

note.data() — то же самое, что и note.get().

Сеттеры note.set(attrName, value) и note.set({…}) не “выстреливают” событие change.

note.hasChanged говорит, изменены ли данные сущности с момента их последнего сохранения.

note.isNew говорит, сохранены ли данные сущности вообще хоть раз.

note.isPersisted говорит, сохранены ли последние изменения.

note.bind(eventName, handler) «вешает» обработчик. Кстати говоря, повесить обработчик любого события можно не только на отдельную сущность, но и на все сущности класса (Note.bind).

note.isValid говорит, валидны ли текущие данные модели.

note.errors собственно, возвращает ошибки данных, если они есть.

note.revert() откатывает несохраненные изменения и «выстреливает» событие revert.

Это все.

Вы спросите, где же методы сохранения и прочее-прочее? — Отвечу: использование библиотеки подразумевает, что разработчик должен реализовать эти методы самостоятельно как того требует логика его приложения.

Нужно отметить, что с сохранением есть один нюанс: если данные сохранились успешно, нужно об этом сообщить сущности при помощи приватного метода note._persist(), который также «выстреливает» событие persist.

Объяснение примером хорошо. Допустим, наше приложение работает в браузерном окружении и метод note.save() должен сохранить данные при помощи аджакса.

Note.prototype.save = function () {
  var note = this;
  return $.ajax({
    type: 'PUT',
    url: '/notes/'+note.data.id,
    data: note.data(),
    dataType: 'json'
  }).done(function (json) {
    note._persist();
  });
}

note.bind('persist', function () {
  $('h1', 'div#note'+this.data.id).html(this.data.title);
});

note.data = { id: 123, title: "abc", text: "" }
if (note.hasChanged && note.isValid) { // оппа Django-style
  note.save();
}


Проверка данных


При создании модели обязательно описывается каждый ее атрибут.
this.attr('title', 'string', 'nonempty')
Сперва указываем название атрибута, затем его валидаторы. Валидаторы, когда придет время, в объявленном порядке будут проверять значение атрибута.

Вообще, валидаторы — это обычные функции, которые принимают значения и возвращают ошибки, когда эти значения невалидны.
function validateMinLength(value, minLength) {
  if (value.length < minLength) return 'tooshort';
}

Если у вас есть необходимость использовать валидатор несколько раз — резонно его зарегистрировать, чтобы подключать по имени.
Model.registerValidator('array', function (value) {
  if (Object.prototype.toString.call(v) === '[object Array]') return 'wrongtype';
});
Model.registerValidator('minLength', validateMinLength);

Model.js имеет несколько базовых (уже зарегистрированных) валидаторов: number, string, boolean, nonnull, nonempty и in. Их, совсем как в нашем примере, можно подключать к атрибутам по названию.

Чтобы передать параметр валидатору при описании атрибута, нужно записать его, как в примере ниже, в виде массива:
this.attr('title', 'string', 'nonempty', [ 'minLength', 6 ]);

Валидаторы можно подключать и обычным дедовским методом, не регистрируя их.
this.attr('title', 'string', 'nonempty', [ 'minLength', 6 ], function (title) {
  if (title[0] !== title[0].toUpperCase()) return 'downcase';
});

Все это нужно, чтобы note.isValid мог сказать true или false, а note.errors мог вернуть объект с ошибками, если они есть.
note.data = { id: 'abc', title: '', text: 3 }; // полностью неверные данные
note.isValid // false
note.errors // { id: 'wrongtype', title: 'empty', text: 'wrongtype' }


События


Есть четыре события: initialize, change, persist и revert. Как говорилось выше, можно повесить обработчик на конкретную сущность (note.bind), а можно и на класс (Note.bind), так что обработчик станет общим для всех сущностей.

initialize «выстреливается» единожды при создании сущности: var note = new Note({…}). Так что вешать обработчик initialize имеет смысл только на класс.

change срабатывает каждый раз, когда меняется значение атрибута. Есть, правда, сеттеры, которые не дергают change (об этом было рассказано выше).

perist — когда сущность получает сигнал о том, что изменения успешно сохранены.

revert — когда логика приложения приказала откатить еще несохраненные изменения (при помощи метода note.revert()).

var ALLOWED_LANGUAGES = ['en', 'ua', 'ru'];
var Note = new Model('Note', function () {
  this.attr('id!', 'number');
  this.attr('title', 'string', 'nonempty');
  this.attr('lang', 'string', 'nonempty', [ 'in', ALLOWED_LANGUAGES]);
  this.attr('text', 'string');
});

Note.bind('initialize', function () {
  if (!this.data.lang) {
    this.set('lang', 'en'); // change не выстрелит — специальный такой сеттер!
  }
});

note.bind('change', function (changes) {
  if (changes.title) $('h1', 'div#note'+this.data.id).html(changes.title);
});


В завершение


Сейчас Model.js — это чистый state of the art, это промежуточный, но уверенно работающий результат. Если вы заинтересовались библиотекой, если у вас возникло желание попробовать ее применить, я буду рад ответить на ваши вопросы. Больше информации о том, как что работает можно найти в документации на гитхабе и в тестах.

Ну а пока пожелайте малютке доброго пути, ведь чтобы ей стать «взрослой» библиотекой, нужно проделать еще немало работы.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 36

    +1
    Сделайте байндер к rivetsjs, будет вообще отлично.
      0
      Спасибо за наводку — интересная вещь!
        +1
        Ага. С вашими моделями получается миленький микро-фреймворк:)
      +1
      … создавая сложные джаваскрипт-приложения, уже решает эту проблему каким-то определенным образом без фреймворков

      Вопрос зачем? Зачем нужно создавать сложные js-приложения, не используя сторонние фреймворки? Поймите правильно, я против того, чтобы в каждое мало-мальское web-app пихали все подряд библиотеки и все это превращалось в что-то монструозное, но если приложение действительно достаточно сложное, то просто моделью вы все равно не обойдетесь, а следовательно лучше сразу использовать какую-нибудь хорошо известную связку.
        +2
        Текущая ситуация с JS мне начинает напоминать ситуацию с PHP. Сделал свой велосипед — зачем? Да, я тоже не рад использованию везде и вся миллионов фреймворков. Особенно бесит, что сейчас уже «программируют на jQuery», а не на JS. Но ответ на Ваш вопрос «зачем?» — чтобы учиться. Хороший велосипед — перенять чужой опыт. Плохой — тоже перенять чужой опыт и не повторять ошибки.
          0
          Ответ «чтобы учиться» в этом случае самый правильный, ничего против не имею, только за.
          0
          Данное решение выглядит вполне хорошо, так почему этому не быть микрофреймворком. Лично мне данный проект внешне очень понравился, хочу попробовать в связке с KnockoutJS. Мне не хватает чего-то удобного для работы с моделью
            0
            Я соглашусь с вами, Сергей: без фреймворка действительно не обойтись. Миниатюрные же библиотеки в моих глазах хороши тем, что из них можно скомпоновать «свой» фреймворк, разработать свой способ, не принимая навязываемые другими фреймворками конвенции и ограничения. Если иметь ряд инструментов, каждый из которых исключительно хорош в своем узком деле, можно, построив из них систему, выйти за рамки и создать более качественный продукт. Как пример вспоминаются юниксы.

            Я также очень настороженно отношусь к новым библиотекам и любой новый способ/подход, равно как и инструмент, конечно, должен пройти проверку боем и проверку временем. Model.js здесь не исключение.
            +18
            Какой-то у вас сахар несладкий

            var Note = new Model('Note', function () {
              this.attr('id!', 'number');
              this.attr('title', 'string', 'nonempty');
              this.attr('text', 'string');
            });
            


            Почему не так:

            var Note = new Model({
                'id!' : 'number',
                'title': {
                    type: 'string' // или String
                    validator: 'non-empty'
                },
                'text': 'string'
            });
            


            note.bind(eventName, handler) «вешает» обработчик. Кстати говоря, повесить обработчик любого события можно не только на отдельную сущность, но и на все сущности класса (Note.bind).
            


            Вообще круто. developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind Переопределять стандартный метод вместо того чтобы использовать общепринятые on или addEventListener

            this.attr('title', 'string', 'nonempty', [ 'minLength', 6 ]);
            


            Это разве синтаксический сахар?
              +1
              Спасибо, вы задали интересный вопрос. Вообще, сахар-не-сахар — это все субъективно, но…

              Смотрите, конфигурационную функцию конструктор моделей получает не просто так. Сейчас в ее контексте есть только метод this.attr(attrName, validator[, validator, …]), но в будущем появятся и другие методы, при помощи которых будет описываться модель. Например, this.validates(function () {…}), которая должна бы валидировать модель вцелом после проверки значений атрибутов. Есть и другие задумки.

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

              1. Тип нам нужен только чтобы его проверить, и поэтому ничего страшного нет в том, чтобы просто записать валидатор с названием типа, не делая особый синтаксический акцент на том, что это именно тип. Получается, вы немного усложнили, а не упростили.

              2. Валидатор — не один — их может быть любое количество, как того может требовать логика нашего приложения. Каждый из валидаторов, когда проверяется значение, если возвращает ошибку, останавливает всю цепочку за ним.

              this.attr('title', 'nonnull', 'string', 'nonempty', [ 'minLength', 6 ]);
              

              В этом примере мы сперва проверяем не null ли, затем строка ли, затем не пуста ли и затем не меньше ли 6 символов в длину. Каждый их этих валидаторов может вернуть нам код своей ошибки и остановить на этом проверку конкретного значения. Код ошибки, который он возвратит, может что-то означать в нашем приложении. Даже то, что nonempty и [ 'minLength', 6 ] стоят рядом может иметь в нашем приложении специальный смысл (я имею ввиду, что в принципе nonempty можно и не указывать рядом с [ 'minLength', 6 ]).

              Стоит также помнить и о том, что валидаторы могут принимать атрибуты, как в случае с minLength или in.

              Если учесть изложенные нюансы и записать в форме объекта это описание, все становится очень похожим на тот пример, который вы критикуете.
              var Note = new Model({
                'id!' : 'number',
                'title': [ 'nonnull', 'string', 'non-empty', [ 'minLength', 6 ] ],
                'text': 'string'
              });
              

              Но нам нужна эта конфигурационная функция,— выше я объяснил почему,— и поэтому форма объявления атрибута при помощи this.attr все же предпочтительней.

              По поводу метода bind, то он стал стандартным только в JS 1.8.5 (в современных браузерах с марта 2011 года, начиная с ФФ4), в то время, как Model.js нацелена на JS 1.5 (c ноября 2000 года в абсолютном большинство современных браузеров). Но вы правы, в будущем этому нюансу стоит уделить больше внимания. А пока, в ближайших версиях, возможно, и стоит создать алиас на слово on, которое сделали популярным последние версии jQuery и Backbone.
                0
                С точки зрения «сахарности» можно иногда и усложнить, в угоду читаемости. Например:
                var Note = new Model({
                    'text':  {
                        'name' : {
                            type: 'string',
                            validators: []
                        }
                    }
                });
                


                Несмотря на то, что тип это просто валидатор, можно для него сделать отдельное поле (естественно, как опцию, а не жесткое правило). Для валидаторов можно сделать два поля — validator и validators. Вот это и есть сахар. Причем обратывать эти поля можно одним и тем же кодом, то есть использовать их как синонимы (ну точне брать валидаторы из всех и объединять в массив).

                Для аргументов валидаторов вы нашли плохое решение, потому что элементы массива равноправны, а названия валидатора и аргументы — нет. Понятно, что они нужны, но в итоге совершенно нечитаемо.

                Насчет конфигурационной функции — не понятно, почему то же самое нельзя делать снаружи:
                var Note = new Model('Note', function () {
                }).attr('id!', 'number').validates(...)
                


                По поводу метода bind, то он стал стандартным только в JS 1.8.5 (в современных браузерах с марта 2011 года, начиная с ФФ4), в то время, как Model.js нацелена на JS 1.5


                Если вас так волнует поддержка старых браузеров, то весьма странно видеть использование __proto__,__defineGetter__ и __defineSetter__, которые вообще не являются стандартными.
                  0
                  Спасибо вам за мнение. Объективно, если усложнять, то это уже не «сахар». Я также не согласен с вами в том, что многоэтажные объекты легче читаются. Но, вообще, конечно, удобство — категория субъективная. Думаю, вы согласитесь, что тратить время ну пустые споры не имеет смысла.

                  Есть другой момент. Из ряда __proto__, __defineGetter__ и __defineSetter__ нестандартным в JS 1.5 есть только __proto__. Присмотритесь к коду. Использование __proto__ в 2 местах — это всего лишь перестраховка от кое-каких особенностей современных браузеров: если вообще удалить __proto__, смысл не пострадает, но вот в последних хромах (напр. версии 24.0.1312.70) прототипная цепочка почему-то не установится (слетит ровно 75 тестов).

                  Буду признателен, если вы сможете объяснить почему так происходит.
                    0
                    Для начала расскажите, чего вы хотите добиться такими конструкциями:
                        cls.prototype = cls.__proto__ = private.Class.prototype;
                    


                    Если cls это не функция, то установка prototype ничего не делает. Если cls это функция, то устанавливая __proto__ вы отрубаете Function.prototype. Отличный пост habrahabr.ru/post/140810/, еще dailyjs.com/2012/11/26/js101-proto/.

                    Если вы хотите, чтобы у конструктора были те же методы, что и у порождаемых им объектах (т. е. их прототипа), нужно скопировать их из прототипа в конструктор.
                      0
                      Я признателен. Спасибо вам.
                      0
                      __defineGetter__ и __defineSetter__ тоже не относятся к стандарту (пруф и пруф), хотя и поддерживаются многими браузерами. Лучше определить shim для defineProperty (-ies) и использовать его.
                        0
                        Да, вы правы. Они присутствуют не во всех реализациях JS 1.5, а только в сишных.
                        JavaScript writers can now add getters and setters to their objects. This feature is available only in the C implementation of JavaScript.
                        developer.mozilla.org/en-US/docs/JavaScript/New_in_JavaScript/1.5
                          0
                          Я про то что этих методов нет в стандарте ECMAScript. Там определены методы defineProperty и defineProperties. __defineGetter__ и __defineSetter__ — это проделки Мозиллы.
                +1
                ведь чтобы ей стать «взрослой» библиотекой, нужно проделать еще немало работы
                Что бы ей в конце-концов стать backbone.js надо действительно проделать немало работы
                  +1
                  Смотрели component.model?
                    0
                    Спасибо, изучу.
                    +1
                    Никого не слушайте (имею ввиду критику без аргументов). Вы сделали несомненно очень крутую и полезную библиотеку. Это естественно, что у нее есть какие-то «детские болячки», и я надеюсь, что в скором времени вы ее вылечите и вырастите в легковесный и удобный рабочий инструмент. Удачи.
                      0
                      Спасибо на добром слове. Вы правильно поняли взятый вектор. Всегда приятно получить такой отзыв.
                      +3
                      В качестве типа атрибута можно использовать другую модель?
                        0
                        Да, можно. Нужно создать валидатор, который проверит есть ли значение аттрибута сущностью другой модели. Сейчас нет какого-то красивого метода сущности для этой цели, поэтому нужно извернуться вот так:
                        var Author = new Model('Author', function () {…});
                        var Comment = new Model('Comment', function () {
                          this.attr('author', function (author) {
                            if (author.constructor.className != 'Author') return 'wrongtype'; 
                          });
                          this.attr('text', 'string');
                        });
                        


                        Вообще, спасибо вам за идею — этот момент доработаем в ближайшей версии.

                        На ум пока пришла такая вот форма:
                        var Author = new Model('Author', function () {…});
                        var Comment = new Model('Comment', function () {
                          this.attr('author', 'Author');
                          this.attr('text', 'string');
                        });
                        
                        0
                        А наследовать модели друг от друга нельзя?
                          0
                          К сожалению, пока что нельзя.
                            +1
                            Я не уверен, что это действительно нужно с вашей библиотекой, но c Backbone я часто наследуюсь, и это удобно.
                          0
                          В целом все довольно просто, но вешать глобальный обработчик на класс, что бы изменять инстанс по дефолту крайне витиеватое решение. Не очень приятно наследование через callback. Что бы добавить свойств, хотелось бы иметь возможность их явно указать, например, их можно передавать объектом.
                          В Backbone прекрасная механика наследования, за счет этого, что бы задать дефолтные значения, достаточно передать свойство default. Тут же появляется возможность переопределять конструктор и функцию инициализации. Расширить валидацию свойств не проблема.
                          Так же для остлеживания создания моделей на глобальном уровне, достаточно создать коллекцию, что даёт более обширные возможности с точки зрения выборок. Но обучение дело золотое, не спорю.
                            0
                            Спасибо. Вы знаете, в будущих версиях синтаксис объявления атрибутов наверняка будет доработан. Вы ведь правы: сейчас нужно много движений, чтобы установить значение по умолчанию, да и функция инициализации и вправду не помешала бы.

                            По поводу выборок также есть задумки. В текущей же версии вопрос регистра сущностей и методов класса моделей, которые как-то бы с этим регистром работали, умышленно обойден. Но не могу не согласиться, это и вправду нужная функциональность.
                            +4
                            Пожалуйста, не пишите так:
                            var note = this;
                              return $.ajax({
                                type: 'PUT',
                                url: '/notes/'+this.data.id,
                                data: this.data(),
                                dataType: 'json'
                              }).done(function (json) {
                                note._persist();
                              });
                            

                            у jq есть замечательный параметр context
                            return $.ajax({
                                type: 'PUT',
                                url: '/notes/'+note.data.id,
                                data: note.data(),
                                dataType: 'json',
                                context: this,
                              }).done(function (json) {
                                this._persist();
                              });
                            


                            Биндить скоупы нужно всегда, для этого в jq есть метод $.proxy(fn,scope,args). хорошая статья про скоупы
                              0
                              А чем не угодил нативный fn.apply( scope, args )?
                                0
                                _не во всех_ браузерах есть, а any_lib.bind предоставит фаллбек в случает IE<9 и других устаревших браузеров
                              0
                              На мой вкус не хватает коллекций и сериализации в json. А так достаточно неплохо получилось.
                                0
                                Спасибо. Коллекций, правда, пока нет. Сериализация же подразумевается при помощи внешних средств, ведь JSON.stringify пока не везде есть (только начиная с JS 1.7), чтобы его широко использовать.
                                +1
                                Нитпик: «State of the art» — это высшее достижение ремесла на текущий момент. А не «незаконченный набросок», в каком качестве эта фраза, по всей видимости, используется в этом тексте. В остальном — кул!
                                  0
                                  Спасибо! Вы, правда, не правы. Не «незаконченный набросок», а все же «промежуточный, но уверенно работающий результат», что не мешает ему быть высшим достижением ремесла на текущий момент.

                                Only users with full accounts can post comments. Log in, please.