Благодарю всех кто прочитал, отозвался и оценил первый пост!
Для демонстрации как работает идеология Jiant — реализовал ToDoMVC, проект, созданный для оценки различных MVC фреймворков. Jiant не MVC фреймворк, а скорее подход к разработке с набором вспомогательных инструментов. На разработку у меня ушло порядка 8 часов с учетом чтения и понимания спецификации, изучения референтной реализации, localStorage, с которым не имел дела (очень простая штука) ну и всего прочего. Не знаю много это или мало, но вот столько. Результаты лежат по адресу: github.com/vecnas/todomvc-jiant. В Chrome и Firefox работает прямо с файловой системы, в IE — с сервера.
Спецификация
github.com/addyosmani/todomvc/wiki/App-Specification — спецификация на английском. Кратко изложу ключевые пункты здесь.
Нужно разработать инструмент для управления задачами (ToDo), следующие пункты должны быть реализованы:
- Хранение. Все задачи и их состояние должны сохраняться в локальном хранилище (HTML5 localStorage) и восстанавливаться при например перезапуске браузера.
- Навигация. Приложение должно поддерживать хэш-навигацию в браузере, более детально см. оригинал. Там задан формат навигации, но у Jiant-а немного отличный от этого формат, связано это с возможность задавать навигационные узлы и затем возвращаться к ним. Управляется кнопками на панели навигации. Есть три состояния: показать все, показать активные и показать завершенные
- Новые задачи. Вводятся заполнением поля ввода наверху страницы и нажатием клавиши Enter
- Массовые манипуляции. Специальная кнопка отмечает все задачи как выполненные или не выполненные. Состояние кнопки также синхронизируется с текущим состоянием всех задач. То есть если мы добавим новую когда все уже выполнены — индикатор перестанет показывать что все сделано
- Задача. Представляет три интерактивных элемента — переключатель сделано-не сделано работает по щелчку, кнопку удаления задачи, и по двойному щелчку на названии — дает возможность редактировать текст задачи
- Редактирование. Открывается поле ввода, остальные элементы прячутся, по нажатию Enter или потере фокуса — сохраняет текст, по нажатию Escape — отменяет все изменения, если при сохранении текст пустой — удаляет задачу
- Счетчик активных задач. Показывает количество текущих активных задач, синхронизируется на любые изменения, текст должен быть грамотным («1 задача», но «2 задачи»)
- Очистить завершенные. Показывает количество завершенных задач, удаляет их по щелчку и показывается только если есть хотя бы одна завершенная задача
- Панель со счетчиками и кнопками навигации показывается только если есть хотя бы одна задача
На входе выдан шаблон проекта, содержащий html код и некоторые интерактивные скрипты, а также все стили.
Структура Jiant проекта
На текущий момент идеальная структура выглядит следующим образом:
- Файл определения приложения — объявление json переменной, описывающей API приложения
- «Зажигание» — это вызов bindUi где-либо где удобно пользователю
- Набор «плагинов» — логики приложения, каждая из которых инкапсулирована в своей песочнице и работает через API приложения
Начальное проектирование
Первая стадия в подходе Jiant это максимально абстрактное проектирование. Самое важное и возможно непривычное (по-крайней мере для меня самого) — это полностью абстрагироваться от того как все будет реализовываться. Создаю первую версию описания приложения, глядя на описание заказчика. Здесь я излагаю первые соображения, после чтения спецификации, а не подготовленные по результатам проекта идеальные рассуждения. То есть все жизненно, как оно происходит.
Состояния
Раз нужна хэш навигация, значит у приложения будут состояния. Перечисляем их, прямо берем список из спецификации, не надо думать:
states: {
"": {
go: function () {},
start: function(cb) {},
end: function(cb) {}
},
active: {
go: function () {},
start: function(cb) {},
end: function(cb) {}
},
completed: {
go: function () {},
start: function(cb) {},
end: function(cb) {}
}
}
Пояснение: согласно формату, если переменная описания приложения содержит секцию states — Jiant загружает перечисленные в ней состояния и реализует для каждого три метода: go, start, end. go служит для перехода в состояние, start и end для реакции на начало или конец состояния. Наиболее краткая запись выглядит так:
states: {
"": {},
active: {},
completed: {}
}
Но в этом случае у нас не будет автозавершения в IDE, так что для собственного удобства и лучшего документирования я использую первую нотацию. Пустое состояние соответствует «неопределенным» ситуациям — например, когда просто загружено окно браузера без любых хэшей.
Все, это все что нужно для работы хэш-навигации. Весь функционал будет добавлен при инициализации приложения Jiant'ом.
Чтобы полностью закрыть тему проектирования состояний — можно сделать одно состояние с двумя параметрами, например:
states: {
"": {
go: function(showActive, showCompleted) {}
}
}
И это тоже будет работать. Но, так как у нас по контролу на состояние, то из чисто утилитарных соображений — завести три состояния кажется удобней. Опять же это первое интуитивное решение. Кстати, в итоговой версии так и остались эти состояния.
События
Теперь определяем события уровня приложения, снова абстрактно. Интуитивно кажется что следующий список правилен:
events: {
todoAdded: {
fire: function(todo) {},
on: function(cb) {}
},
todoRemoved: {
fire: function(todo) {},
on: function(cb) {}
},
todoStateChanged: {
fire: function(todo) {},
on: function(cb) {}
}
}
Единственное размышление вызвало последнее событие — надо ли разбить его на два — на завершение или ре-активацию todo. Снова весь код поддержки событий работает внутри Jiant, пользователю нужно только определить абстрактный список событий и воспользоваться им. Каждое событие имеет два метода — достаточно очевидных. Функция cb (callback, параметр метода on) принимает в точности те же аргументы что вызов fire.
Интерфейс
Теперь нужно определить визуальную часть приложения. Список элементов как обычно просто копируем из описания заказчика, как удобно. После того как написал так:
views: {
main: {
batchToggleStateCtl: ctl,
newTodoTitleInput: input,
todoList: container
},
controls: {
activeCountLabel: label,
clearCompletedCtl: ctl,
completedCountLabel: label,
showAllCtl: ctl,
showActiveCtl: ctl,
showCompletedCtl: ctl
}
},
— полез в html и обнаружил что идентификаторы на элементы уже прописаны, структура задана, поэтому логичней будет просто ее применить. Итог:
views: {
header: {
newTodoTitleInput: input
},
main: {
batchToggleStateCtl: ctl,
todoList: container
},
footer: {
activeCountLabel: label,
clearCompletedCtl: ctl,
completedCountLabel: label,
showAllCtl: ctl,
showActiveCtl: ctl,
showCompletedCtl: ctl
}
}
и эта структура, созданная на 10й минуте проектирования, уже не менялась до конца проекта.
Шаблоны
Исходя из спецификации видим один крайне динамичный элемент — визуальное представление задачи. Количество их разное, они добавляются и убираются, значит для этого согласно идеологии Jiant просто необходимо использовать шаблон, определим его:
templates: {
tmTodo: {
deleteCtl: ctl,
editCtl: ctl,
stateMarker: label,
toggleStateCtl: ctl,
titleInput: input,
hiddenInEditMode: collection,
titleLabel: label
}
}
Снова дабы не утруждать лишний раз
Здесь следует кое-что сказать о шаблонах. Первое, можно увидеть что для шаблона определены поля — они во-первых проверяются на валидность при запуске приложения, во-вторых привязываются после создания элемента из шаблона. Каждый шаблон имеет два метода: parseTemplate, parseTemplate2Text, принимающие параметры для подстановки. Шаблон не содержит никакой логики, только подстановку значений, и это намеренно — место логики в javascript коде. Позднее в приложении появился еще один шаблон, введенный больше для того чтобы показать как подставляются значения:
templates: {
....
itemsLeft: {},
itemsLeft1: {}
}
реализация:
<div id="itemsLeft1" style="display: none;">
<strong>!!count!!</strong> item left
</div>
<div id="itemsLeft" style="display: none;">
<strong>!!count!!</strong> items left
</div>
и использование:
tm.parseTemplate2Text({count: count})
Модель данных пока не проектируем, об этом ниже. Хочется поскорей запустить и увидеть что ничего не ломается, для этого нужен стартер.
Стартер
Исходя из идеальной структуры, стартер приложения поместим в app.js файл (имеющийся в базовом шаблоне «от заказчика») и его код следующий:
jQuery(function() {
jiant.bindUi("", todomvcJiant, true);
});
Так как код html уже задан заказчиком (и менять не хочется, чтобы не менять css), то префикс здесь используется пустой (первый параметр), переменную todomvcJiant определяем в файле определения приложения (она содержит views, templates, states, events) и включаем режим разработки.
Реализация в HTML
Ну все, у нас есть только файл определения и стартер, никакой логики, но уж больно хочется запустить приложение. Запускаю html в Chrome и вижу:
- Алерт с сообщением от Jiant, в котором написано «No history plugin and states configured. Don't use states or add $.History plugin»
- Еще один алерт с сообщением о нереализованных элементах интерфейса
- И повтор текста второго алерта в консоли: non existing object referred by class under object id, non existing object referred by id, check stack trace for details, expected obj id:
Добавляю history — идет в комплекте. Теперь остается реализовать абстрактное определение на уровне html, например так:
<section id="main">
<input id="toggle-all" class="batchToggleStateCtl" type="checkbox" style="display: none;">
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list" class="todoList">
</ul>
</section>
— идентификаторы уже расставлены, остается добавить классы на нужные элементы. Реализация шаблона:
<div id="tmTodo" style="display: none;">
<li class="stateMarker">
<div class="view hiddenInEditMode">
<input class="toggle toggleStateCtl" type="checkbox">
<label class="editCtl titleLabel"></label>
<button class="destroy deleteCtl"></button>
</div>
<input class="edit titleInput" value="">
</li>
</div>
Следует заметить что в отличие от общей практики помещать шаблоны в <script type=«someUnreadableTypeToFoolBrowser»>, Jiant использует правильную html структуру, это связано с тем что внутри script тэга нет DOM модели и невозможно провести валидацию привязки полей шаблона к реализации.
Наконец, Jiant перестал сообщать о несвязанных элементах, абстрактная модель приложения привязана к реализации и готова к использованию.
Плагины
Следуя идеологии Jiant — любая логика добавляется плагинами. Если появляется новая вьюшка или состояние-событие, мы добавляем ее/его к файлу описания приложения и реализацию к html. То есть процесс расширения приложения всегда постоянный и контролируемый, что крайне важно для крупных разработок, начинающихся с малого.
Модель
Встает вопрос — нужна ли данному приложению централизованная модель данных? Теоретически каждый плагин может содержать свою модель и синхронизировать ее на основе происходящих в приложении событий. На практике я для сравнения написал такой вариант и как и ожидалось — получилось много ненужного и повторяющегося кода. Поэтому реализуем модель (практически можно было бы поместить реализацию прямо в json файл описания приложения, но тогда его станет сложнее читать, поэтому вынесем отдельным плагином, следуя идеологии), model.js:
todomvcJiant.model.todo = (function($, app) {
var todos = [];
return {
add: function(title, completed) {
var todo = {title: title, completed: completed ? true : false};
todos.push(todo);
app.events.todoAdded.fire(todo);
return todo;
},
remove: function(todo) {
todos = $.grep(todos, function(value) {return value != todo;});
app.events.todoRemoved.fire(todo);
return todo;
},
getAll: function() {
return todos;
},
getCompleted: function() {
return $.grep(todos, function(value) {return value.completed});
},
getActive: function() {
return $.grep(todos, function(value) {return !value.completed});
}
}
})($, todomvcJiant);
Простейшая реализация на основе массива. Стоит заметить только что здесь мы запускаем некоторые события приложения. В данном случае добавление модели в json описание проекта носит косметический характер (например, там нет метода getActive()), Jiant пока никак не занимается моделями данных.
Реализация плагинов
Дальше, для каждого элемента логики из спецификации — пишем свой плагин и добавляем его. В любой момент времени у нас все работает, функционал наращивается, не задевая остальное. Ниже пара примеров, комментарий прямо в коде
stateCtls.js
jiant.onUiBound(function($, app) { // плагин регистрируется на событие когда API приложения проинициализировано
var ctlsView = app.views.footer,
ctls = {
"showActiveCtl": app.states.active, // просто используем ссылки на состояния
"showCompletedCtl": app.states.completed,
"showAllCtl": app.states[""]
};
$.each(ctls, function(key, state) {
ctlsView[key].click(function() {
state.go();
});
state.start(function() { // состояние может включаться при переходе на него или при начальной загрузке страницы
ctlsView[key].addClass("selected");
});
state.end(function() {
ctlsView[key].removeClass("selected");
});
});
});
footerVisibility.js
jiant.onUiBound(function($, app) {
app.events.todoAdded.on(updateView); // подписываемся на событие
app.events.todoRemoved.on(updateView);
function updateView() { // конкретно добавленное todo не интересует, так как все время проверяем текущее состояние модели
app.model.todo.getAll().length > 0 ? app.views.footer.show() : app.views.footer.hide();
}
});
Аналогично добавляются другие плагины, используя onUiBound. Когда плагину требуется новый функционал API — создаем абстрактное объявление и реализацию. В данном случае в большинстве плагинов получилось что-то вроде глобального события smthChanged в ответ на которое плагины обновляют свой статус, но это частное совпадение.
Стоит заметить что html используется только как html, никаких кастом атрибутов или тэгов. Вся логика работы с UI написана на jQuery функциях.
Забавный факт — не понадобилось вводить идентификатор объекта. Вся работа ведется через прямые ссылки на объекты. Ссылка на UI реализацию объекта todo сохраняется как поле объекта todo, внутри todoRenderer.js и никем за его пределами не используется, порядок сохраняется внутри модели.
PS
Уже когда все написал на последней строчке понял что после изменения текста задачи — новый текст не сохраняется в localStorage, пока не произойдет какое-нибудь из уже имеющихся событий. Чтобы исправить, добавил новое событие todoTitleModified, кидается в редакторе после установки нового текста (todoRenderer.js) и подписка на событие в модуле сохранения (persistence.js).
Автор: Vecnas