jWidget — объектно-ориентированный JavaScript MV* framework
Есть замечательный сайт http://todomvc.com/, на котором демонстрируется решение одной и той же задачи с помощью разных JavaScript MV* (Model-View-[Controller]) фреймворков. Сейчас там представлены десятки различных фреймворков, у каждого из которых есть свои преимущества и недостатки. Есть там и такие гиганты, как Angular, Ember и Backbone. Несмотря на высокую конкуренцию, я все равно хотел бы продемонстрировать свой MV* фреймворк — jWidget.Я быстро просмотрел все решения, представленные на сайте TodoMVC, и не нашел ни одного фреймворка, похожего на jWidget. Дело в том, что, помимо JavaScript, я много программирую на строго-типизированных языках программирования, таких как Java, C#, а в прошлом и на C++. Поэтому я большой фанат объектно-ориентированного программирования, SOLID принципов и паттернов объектно-ориентированного проектирования. Мне не нужен фреймворк, который стеснял бы меня в возможности применения стандартных объектно-ориентированных решений. То, что я увидел в существующих решениях TodoMVC, не внушает доверия в этом отношении. Как правило, они предоставляют некий декларативный синтаксис и мощный шаблонный движок, но объектно-ориентированная основа всего этого, даже если она существует, скрыта от наших глаз.
Документация на английском: http://enepomnyaschih.github.io/jwidget/index.html#!/guide/home
Документация на русском: http://enepomnyaschih.github.io/jwidget/index.html#!/guide/ruhome
Проект на GitHub: https://github.com/enepomnyaschih/jwidget
Twitter: @jwidgetproject
Реализация TodoMVC на jWidget: http://enepomnyaschih.github.io/todomvc/labs/architecture-examples/jwidget/release/
Ссылка Source там сейчас не работает, поскольку jWidget есть только в моем форке. Ниже правильная ссылка на исходный код.
Исходный код TodoMVC на jWidget: https://github.com/enepomnyaschih/todomvc/tree/gh-pages/labs/architecture-examples/jwidget/
Кратко перечислю основные характеристики jWidget:
1. Строгое соответствие принципам ООП. Полностью задокументированная на двух языках библиотека классов с примерами руководством.2. Скорость работы скрипта превыше всего. Отсюда явное объявление конструктора класса и минимальное использование замыканий при объявлении классов, т.к. в Google Chrome наследование через прототипы гораздо эффективнее наследования по паттерну «Модуль» (в Firefox наоборот, но там разница не так велика).3. Ни одна манипуляция в модели не требует полного перерендеринга представления. Каждый компонент рендерится только один раз, после чего он только обновляет свои отдельные элементы, за счет чего обеспечивается высокая скорость работы приложения.4. Фреймворк работает на базе jQuery.5. Имеет простейший шаблонный движок, не требующий препроцессинга перед отправлением в функцию https://api.jquery.com/jQuery.parseHTML/. Никакой магии в шаблонах, никакого inline-кода: весь Data binding осуществляется в JavaScript коде компонента. Благодаря этому, одни и те же техники Data binding’а можно применять как для связи представления с моделью, так и для связи объектов внутри модели или внутри представления, что часто оказывается полезным.6. Все объекты после использования полностью уничтожаются. Благодаря этому обеспечивается экономный расход ресурсов клиента и отсутствие непредвиденных ошибок в консоли от «мертвых» объектов, пытающихся обработать некоторое событие. Например, вы можете использовать одну и ту же модель на протяжении работы приложения, налету меняя ее представления. Любое представление слушает события модели, но после того, как представление удаляется из DOM, оно обязано отписаться от этих событий, чтобы не тратить процессорное время на обработку этих событий, и чтобы сборщик мусора мог очистить память. Предусмотрен легкий способ уничтожения объектов — т.н. механизм агрегации объектов. 7. Собственный сборщик приложения — jWidget SDK, — упрощает разработку приложения и выполняет оптимизацию кода перед релизом на продакшен. Планируется заменить его стеком плагинов к GruntJS. Просто когда я начал разработку jWidget, GruntJS или чего-то подобного еще не существовало.
Чтобы подтвердить, что jWidget работает очень быстро, я отмерил время добавления 500 записей в TodoMVC с ожиданием в 0 миллисекунд после добавления каждой записи, чтобы дать браузеру время перерисовать представление. Также, я примерно отмерил время операций Select all и Clear completed для 500 записей. Результаты таковы:
Angular JS — 16847 миллисекунд. Операции Select all и Clear completed выполняются мгновенно. Angular JS (performance optimized version) — 13287 миллисекунд. Операции Select all и Clear completed выполняются мгновенно. Ember JS — 13095 миллисекунд. Операции Select all и Clear completed выполняются примерно 3 секунды. Backbone — 9506 миллисекунд. Операции Select all и Clear completed выполняются примерно 3 секунды. jWidget — 9974 миллисекунд. Операции Select all и Clear completed выполняются мгновенно. YUI — больше минуты. Не дождался.Как видите, только Backbone незначительно превзошел jWidget по скорости добавления записей, но при этом сильно отстал по скорости Select all и Clear completed. При этом учтите, что отставание Angular и Ember в 3 секунды на самом деле является значительным, поскольку кучу времени во всех случаях просто скушал 500-кратный вызов setTimeout. В общем, из 3 наиболее популярных фреймворков ни один не справился до конца с большими объемами данных, тогда как jWidget показал себя на высоте.
Теперь расскажу о механизме работы jWidget. Фреймворк состоит из 5 слоев:
Классы и объекты. Наследование классов. Механизм агрегации объектов. События. Объявление событий. Подписка, отписка и генерация событий. Свойства и их хелперы. Создание новых свойств на базе существующих. Data binding на базе свойств. Коллекции и их синхронизаторы. Массив, словарь, множество. Создание новых коллекций на базе существующих. Data binding на базе коллекций. Компоненты. Шаблоны. Связь элементов шаблона с кодом компонента. Создание дочерних компонентов с помощью Data binding’а на базе свойств и коллекций. Ниже приведен пример объявления класса jWidget. Класс создается стандартным наследованием через прототип, разбавленным небольшим количеством синтаксического сахара.
// Объявляем конструктор. var Hand = function (side) { // Вызываем конструктор базового класса. Hand._super.call (this); // Присваиваем поле. this.side = side; // Присваиваем даже те поля, которые по умолчанию не установлены. // Опыт показал, что в некоторых браузерах это существенно ускоряет работу приложения. this.grabbedObject = null; };
// Наследуем Hand от JW.Class. JW.extend (Hand, JW.Class, { // Объявляем поля в комментарии, для нашего удобства. // String side; // Grabbable grabbedObject; // Объявляем метод. grab: function (obj) { this.grabbedObject = obj; }, // Перегружаем метод уничтожения объекта. destroy: function () { console.log («Destroying » + this.side + » hand»); // Тот же метод базового класса можно вызвать через _super. this._super (); } }); Одна из ключевых возможностей JW.Class — это механизм агрегации объектов, который служит для уничтожения объектов, которые находятся под контролем другого объекта. Эту идею я почерпнул из введения к книге Приёмы объектно-ориентированного проектирования. Паттерны проектирования от «банды четырех». Там рассказывается, что все указатели на объекты делятся на два типа: агрегирование и осведомленность. Осведомленность обозначает, что объект, владеющий указателем, не несет никакой ответственности за объект, на который он ссылается. Он просто имеет доступ к его публичным полям и методам, но время жизни этого объекта не под его контролем. Агрегирование же обозначает, что объект, владеющий ссылкой, несет ответственность за уничтожение объекта, на который он ссылается. Как правило, агрегируемый объект живет, пока жив объект-владелец, хотя бывают и более сложные случаи.
В jWidget агрегирование реализуется через метод own класса JW.Class. Передав объект B в метод own объекта A, вы сделали объект A владельцем объекта B. При уничтожении объекта A объект B будет уничтожен автоматически. Для удобства, метод own возвращает объект B. Ниже приведен пример кода, использующего эту возможность.
var Person = function () { Person._super.call (this); // Создаем две руки. Руки — неотъемлемая часть полноценного человека, // поэтому агрегируем их. this.leftHand = this.own (new Hand («left»)); this.rightHand = this.own (new Hand («right»)); };
JW.extend (Person, JW.Class, { // Hand leftHand; // Hand rightHand; destroy: function () { console.log («Destroying person»); this._super (); } }); Теперь мы можем создать человека и уничтожить его вызовом метода destroy.
var person = new Person (); person.destroy (); В результате чего мы увидим в консоли браузера следующие строки:
Destroying person Destroying right hand Destroying left hand Как видите, при уничтожении человека руки уничтожаются автоматически. Альтернативно, мы могли бы уничтожить руки явно в методе destroy класса Person вызовом их метода destroy. Но агрегация позволяет нам добиться этого меньшим количеством кода. Вообще, в реальном приложении метод destroy приходится перегружать очень редко. Например, в моей реализации TodoMVC этот метод не перегружается ни разу — все достигается одним механизмом агрегации объектов.
Агрегированные объекты уничтожаются в обратном порядке, что гарантирует целостность при наличии связей между ними.
События — неотъемлемая часть любого MV* фреймворка. Если представление имеет прямой доступ к модели, то модель ничего не знает о представлении. Обратная связь осуществляется никак не иначе, как через события. Здесь не имеются ввиду стандартные события пользовательского интерфейса, такие как click, mousedown или keypress, а события вроде «изменилось имя документа», «новый документ добавлен в папку», «документы в папке отсортированы по дате». Это не заложено в стандартные средства языка программирования, поэтому это задача фреймворка.
Как я писал в начале статьи, скорость работы скрипта в jWidget превыше всего. Поэтому стандартная схема подписки на события, которая предлагается, например, в jQuery, нам не подходит.
$(»#document»).bind («click», onClick); $(»#document»).unbind («click», onClick); Проблема здесь заключается в том, что алгоритм отписки от события имеет линейную вычислительную сложность (полный перебор). Мне же удалось реализовать схему, при которой время отписки от события равно времени удаления ключа из словаря, что существенно быстрее. Кроме того, схема событий jWidget реализована по всем принципам ООП и отлично сочетается с механизмом агрегации объектов.
var Document = function (title) { Document._super.call (this); this.title = title; // Создаем объект события. this.titleChangeEvent = this.own (new JW.Event ()); };
JW.extend (Document, JW.Class, { // String title; // JW.Event titleChangeEvent; setTitle: function (title) { if (this.title === title) { return; } this.title = title; // Выбрасываем событие. this.titleChangeEvent.trigger (new JW.ValueEventParams (this, title)); } });
var Client = function (document) { Client._super.call (this); this.document = document; // Подписываемся на событие. Благодаря агрегации, подписка на событие будет // уничтожена автоматически при уничтожении клиента. this.own (document.titleChangeEvent.bind (this._onTitleChange, this)); };
JW.extend (Client, JW.Class, { // Document document; _onTitleChange: function (params) { console.log («Changed title to » + params.value); } });
// Немного потестируем. var doc = new Document («apple»); var client = new Client (doc); doc.setTitle («banana»); // Вывод: Changed title to banana doc.setTitle («cherry»); // Вывод: Changed title to cherry
// Не забываем уничтожать все, что создаем. client.destroy (); doc.destroy (); Событие представляется классом JW.Event. Подписка на событие возвращается методом bind в виде экземпляра класса JW.EventAttachment. Уничтожение подписки равноценно отписке от события. Когда мы выбрасываем событие методом trigger, мы передаем туда экземпляр JW.EventParams для передачи его обработчикам событий в качестве аргумента.
Фреймворк не может называться полноценным MV* фреймворком, если он не предоставляет возможности Data binding’а. jWidget предоставляет эту возможность. Объекты следующих классов автоматически выбрасывают события о своем изменении, и, следовательно, могут быть использованы для Data binding’а:
О коллекциях (Array, Map, Set) расскажу в следующем параграфе, а сейчас я хотел бы объяснить, что такое свойство (JW.Property). Свойство — это «переменная», которая выбрасывает события об изменении своего значения. Отсюда простейший интерфейс этого класса:
Когда вы передаете значение x в метод set, свойство проверяет, не равно ли оно этому значению x. Если равно, ничего не происходит. Если не равно, свойство присваивает себя значению x и выбрасывает событие changeEvent.
Несмотря на то, что интерфейс класса прост до неузнаваемости, он предоставляет широкие возможности для Data binding’а, которые на порядок сокращают объем кода приложения. Во-первых, мы можем связать два свойства, копируя значение одного свойства в другое:
var source = new JW.Property («apple»); var target = new JW.Property (); new JW.Copier (source, {target: target}); assertEqual («apple», target.get ()); source.set («banana»); assertEqual («banana», target.get ()); Метод bindTo делает то же самое, что позволяет нам сделать код более понятным. Кроме того, обращаю ваше внимание на то, что свойство target в данном случае тоже выбрасывает события о своем изменении, так что можно связать сколько угодно свойств по цепочке:
var source = new JW.Property («apple»); var target1 = new JW.Property (); target1.bindTo (source); var target2 = new JW.Property (); target2.bindTo (target1); source.set («banana»); assertEqual («banana», target2.get ()); Копирование свойств налету — это только начало. Давайте попробуем создать новое свойство на базе двух существующих свойств по формуле text = value + » » + unit:
var value = new JW.Property (1000); var unit = new JW.Property («MW»); var functor = new JW.Functor ([ value, unit ], function (value, unit) { return value + » » + unit; }, this); var target = functor.target; assertEqual (»1000 MW», target.get ()); value.set (1500); assertEqual (»1500 MW», target.get ()); unit.set («МВт»); // включаем русскую локализацию assertEqual (»1500 МВт», target.get ()); Наконец, привяжем текст внутри какого-то элемента представления к построенному свойству:
new JW.UI.TextUpdater (»#capacity», target); Теперь при изменении value и unit у вас автоматически будет обновляться текст внутри элемента #capacity.
Полный список возможностей класса JW.Property смотрите в документации.
jWidget переносит привычный Data binding через HTML-шаблоны в JavaScript-код приложения. Это дает колоссальные возможности по оптимизации приложения и расширению его возможностей. Data binding не ограничен лишь прослойкой между моделью и представлением. Вы можете с легкостью связывать между собой свойства внутри модели и внутри представления. Алгоритм работы приложения совершенно прозрачен, и вы сами можете контролировать, что с чем связывать, исходя из конкретных сценариев использования вашего приложения. Появляется возможность повторного использования всех функций приложения. Фреймворк не выполняет никакой прекомпиляции HTML-шаблонов, чтобы вычленить оттуда формулы для Data binding’а, благодаря чему скорость работы приложения увеличивается.
Значение свойства можно заагрегировать методом ownValue.
jWidget вводит 3 собственных класса коллекций: JW.AbstractArray, JW.AbstractMap и JW.AbstractSet. Это не значит, что вам запрещено использовать нативные Array и Object — коллекции jWidget легко преобразуются в нативные и обратно. Каждая коллекция jWidget имеет две реализации — простую и оповещающую:
— JW.AbstractArray: JW.Array и JW.ObservableArray— JW.AbstractMap: JW.Map и JW.ObservableMap— JW.AbstractSet: JW.Set и JW.ObservableSet
Простые коллекции работают чуть-чуть быстрее оповещающих, зато оповещающие коллекции выбрасывают события о своем изменении, благодаря чему к ним свободно применяется Data binding. Также, классы простых коллеций имеют идентичный набор статических методов, которые предназначены для выполнения таких же операций с нативными Array и Object. В качестве примера приведу операцию создания массива объектов представления по массиву объектов модели:
// @param {JW.AbstractArray} documents function createDocumentViews (documents) { return documents.$map (function (document) { return new DocumentView (document); }, this); } При этом мы просто создали новый экземпляр JW.Array и заполнили его объектами представления. Никакой связи между массивами документов и их представлений не сохранилось, так что изменение массива documents не повлечет за собой изменение массива представлений. Чтобы связать их между собой, нужно настроить Data binding. В jWidget это делается путем создания синхронизатора. В данном случае, нужно создать Mapper:
function createDocumentViews (documents) { return documents.createMapper ({ createItem: function (document) { return new DocumentView (document); }, destroyItem: function (documentView) { documentView.destroy (); }, scope: this }).target; } Как видите, вместо одного коллбека мы теперь передаем два. Второй коллбек нужен для того, чтобы Mapper смог уничтожить представление документа, если его удалили из массива documents. Mapper формирует массив target и держит его в полном соответствии с исходным массивом. При уничтожении Mapper’а он уничтожит все оставшиеся в target представления… Кстати, мы забыли уничтожить Mapper. Воспользуемся агрегированием:
var DocumentList = function (documents) { DocumentList._super.call (this); this.documentViews = this.createDocumentViews (documents); };
JW.extend (DocumentList, JW.Class, { createDocumentViews: function (documents) { return this.own (documents.createMapper ({ createItem: function (document) { return new DocumentView (document); }, destroyItem: function (documentView) { documentView.destroy (); }, scope: this })).target; } }); Обратите внимание, как стоят круглые скобочки. Мы агрегируем именно Mapper, а возвращаем именно его target.
Метод createMapper работает как для JW.Array, так и для JW.ObservableArray. Только в первом случае он не сможет осуществлять постоянный Data binding, поскольку JW.Array не выбрасывает никаких событий. Зато вы можете разрабатывать абсолютно полиморфное решение с возможностью в любой момент при необходимости заменить JW.Array на JW.ObservableArray.
jWidget предоставляет широкий набор синхронизаторов. Полный список смотрите в документации.
Элементы коллекции можно заагрегировать методом ownItems.
Наконец, добрались до представления. jWidget предоставляет класс JW.UI.Component в качестве базового класса для всех компонентов представления. Каждый класс компонента имеет свой шаблон, который наследуется вместе с этим классом. Шаблон — это обычный HTML, в котором добавлены 2 новых атрибута: jwclass и jwid. Шаблон привязывается к классу компонента методом JW.UI.template.
var MyComponent = function (message, link) { MyComponent._super.call (this); this.message = message; this.link = link; };
JW.extend (MyComponent, JW.UI.Component, { // String message; // String link;
renderComponent: function () { this._super (); this.getElement («hello-message»).text (this.message); this.getElement («link»).attr («href», this.link); } });
JW.UI.template (MyComponent, { main: '
Чтобы отрендерить компонент в DOM, можно воспользоваться следующей инструкцией:
var component = new MyComponent («Hello, Wanderer!», «http://google.com»); component.renderTo («body»); В коде компонента видно, что с помощью метода getElement можно получить jQuery-обертку элемента по его jwid.
Метод renderComponent является методом жизненного цикла компонента. Перегрузив его, вы можете манипулировать элементами компонента и создавать дочерние компоненты.
Дочерние компоненты бывают трех типов:
Именованные дочерние компоненты Легко заменяемые дочерние компоненты Массивы дочерних компонентов Именованные дочерние компоненты полностью заменяют собой указанные элементы шаблона. Например, пусть приложение состоит из заголовка и содержимого. Оформим их именованными дочерними компонентами. Это делается путем добавления их в словарь children:
var Application = function () { Application._super.call (this); };
JW.extend (Application, JW.UI.Component, { renderComponent: function () { this._super (); this.children.set (this.own (new Header ()), «header»); this.children.set (this.own (new Content ()), «content»); } });
JW.UI.template (Application, { main: '
Легко заменяемые дочерние компоненты похожи на именованные, но работают на базе JW.Property. Они добавляются методом addReplaceable. Такие компоненты удобно рендерить с помощью JW.Mapper:
var Application = function (selectedDocument) { Application._super.call (this); this.selectedDocument = selectedDocument; };
JW.extend (Application, JW.UI.Component, { // JW.Property selectedDocument; renderComponent: function () { this._super (); var documentView = this.own (new JW.Mapper ([ this.selectedDocument ], { createValue: function (document) { return new DocumentView (document); }, destroyValue: function (documentView) { documentView.destroy (); }, scope: this })).target; this.addReplaceable (documentView, «document»); } });
JW.UI.template (Application, { main: '
Массивы дочерних компонентов работают на базе JW.AbstractArray. Они добавляются методом addArray. Если массив является JW.ObservableArray, то метод обеспечит непрерывную синхронизацию представления с этим массивом. Массивы дочерних компонентов удобно рендерить через метод createMapper:
var Application = function (documents) { Application._super.call (this); this.documents = documents; };
JW.extend (Application, JW.UI.Component, { // JW.AbstractArray documents; renderComponent: function () { this._super (); var documentViews = this.own (this.documents.createMapper ({ createItem: function (document) { return new DocumentView (document); }, destroyItem: function (documentView) { documentView.destroy (); }, scope: this })).target; this.addArray (documentViews, «documents»); } });
JW.UI.template (Application, { main: '
Для удобства jWidget позволяет определить метод renderChildId, где ChildId — это jwid элемента, записанный в CamelCase с большой буквы. Метод принимает на вход элемент шаблона. Ниже представлены разные возможности этого метода:
var Application = function (title, documents, selectedDocument) { Application._super.call (this); this.title = title; this.documents = documents; this.selectedDocument = selectedDocument; };
JW.extend (Application, JW.UI.Component, {
// JW.Property
JW.UI.template (Application, { main: '
Надеюсь, эта статья была вам интересна. Я совершенно уверен, что среди JavaScript программистов найдутся такие, которые так же, как и я, фанатеют от настоящего объектно-ориентированного программирования и ценят высокую скорость выполнения кода. Если это вы, попробуйте jWidget в вашей работе, и не останетесь разочарованными. Даже при наличии огромного количества MV* фреймворков я все равно предпочитаю jWidget. Я трачу много сил на поддержание документации, руководства для начинающих и плотного покрытия юнит-тестами. Если вы хотите, чтобы проект и дальше развивался и рос, не поленитесь поставить звездочку на GitHub и зафолловить меня в Twitter @jwidgetproject. Также, я ценю конструктивную критику и хорошие предложения. Спасибо.