Продолжаю свое сравнение JS фреймворков на примере проекта todomvc. Для emberjs есть реализация todomvc, но, правда для версии 1.10 (в то время как уже имеется Ember 2.1). Но, наверное, то не должно сильно влиять на понимание принципов построения приложения на Ember. Надеюсь проверить это предположение позже.
Взять код примера можно в github-репозитори. Рассматривается самая свежая версия примера на момент создания статьи. Но всегда можно скачать архив здесь.
На сайте Ember есть пошаговое написание варианта проекта todomvc. Это может сойти за разбор кода, но мне хочется подойти немного с другой стороны, собрать информацию несколько в иной форме. В итоге я хочу получить статью, по которой довольно быстро можно будет восстановить в памяти принципы написания приложения. А так же хочется получить информацию, которая позволит сравнить Ember с другими фреймворками.
В папке проекта есть по-прежнему index.html, папка js и папка node_modules. Как и в случае с ReactJS в папке node_modules содержатся нужные библиотеки, а сам код приложения на Ember находится в папке js.
Индексный файл (index.html) на этот раз отнюдь не пуст, а наполнен смыслом. В нем содержатся основные шаблоны приложения todo-list и todos.
<script type="text/x-handlebars" data-template-name="todo-list">
.....
</script>
.....
<script type="text/x-handlebars" data-template-name="todos">
.....
</script>
Первый шаблон представляет собой разметку списка с пунктами списка, а второй шаблон содержит остальные элементы оформления и управления нашего приложения. Второй шаблон стоит выше в иерархии вложенности.
Далее временно перенесемся на описание данных.
В файле /js/models/todo.js у нас модель, в которой 2 свойства. Здесь все просто:
Todos.Todo = DS.Model.extend({
title: DS.attr('string'),
isCompleted: DS.attr('boolean')
});
Еще есть /js/app.js, где мы создаем приложение с именем Todos и инициализируем адаптер хранения данных в локальном хранилище:
window.Todos = Ember.Application.create();
Todos.ApplicationAdapter = DS.LSAdapter.extend({
namespace: 'todos-emberjs'
});
В файле router.js мы видим следующее:
Todos.Router.map(function () {
this.resource('todos', { path: '/' }, function () {
this.route('active');
this.route('completed');
});
});
Это позволят дать понять фреймворку, что при использовании пути начинающегося с ‘/’ (тоесть любого пути) нужно использовать шаблон todos. Наш шаблон верхнего уровня.
Далее в этом файлу у нас идет описание маршрута (отдельный route):
Todos.TodosRoute = Ember.Route.extend({
model: function () {
return this.store.find('todo');
}
});
Здесь мы видим, что сообщаем фреймворку какую модель и откуда мы будем грузить. Итого у нас уже есть шаблон (todos, описанный в index.html) и модель (todo, описанная в /js/models/todo.js).
В принципе, этого может оказаться достаточно и на эти случаи Ember создает контроллер по-умолчанию, с соответсвующим поведением. Основана эта работа на соглашении об именовании. Но в нашем приложении нам не хватит поведения контроллера по-умолчанию. Поэтому в файле /js/controllers/todos_controller.js у нас описан контроллер Todos.TodosController, унаследовавший поведение Ember.ArrayController.
В Ember есть 2 типа контроллеров ObjectController (для единственного экземпляра сущности) и ArrayController (для набора экземпляров сущности).
Шаблон todos буден у нас служить для отображения массива задач (набор экземпляров сущности), поэтому и контроллер у такого шаблона соответствующий.
То же можно сказать и о шаблоне todo-list, который непосредственно представляет собой список.
Рассмотрим же подробно этот шаблон для понимания взаимодействия шаблона и контроллера.
<script type="text/x-handlebars" data-template-name="todo-list">
{{#if length}}
<section id="main">
{{#if canToggle}}
{{input type="checkbox" id="toggle-all" checked=allTodos.allAreDone}}
{{/if}}
<ul id="todo-list">
{{#each}}
<li {{bind-attr class="isCompleted:completed isEditing:editing"}}>
{{#if isEditing}}
{{todo-input type="text" class="edit" value=bufferedTitle focus-out="doneEditing" insert-newline="doneEditing" escape-press="cancelEditing"}}
{{else}}
{{input type="checkbox" class="toggle" checked=isCompleted}}
<label {{action "editTodo" on="doubleClick"}}>{{title}}</label>
<button {{action "removeTodo"}} class="destroy"></button>
{{/if}}
</li>
{{/each}}
</ul>
</section>
{{/if}}
</script>
Контроллер для данного шаблона выглядит следующим образом:
Todos.TodosListController = Ember.ArrayController.extend({
needs: ['todos'],
allTodos: Ember.computed.alias('controllers.todos'),
itemController: 'todo',
canToggle: function () {
var anyTodos = this.get('allTodos.length');
var isEditing = this.isAny('isEditing');
return anyTodos && !isEditing;
}.property('allTodos.length', '@each.isEditing')
});
В шаблоне мы видим условие, проверяющее перменную canToggle, а в контроллере мы видим такой же метод. Так же в шаблоне мы видим проверку переменной length. В контроллере не описано напрямую такого метода или свойства. Но мы видим, что он унаследован от ArrayController, а значит представляет массив (набор). Таким образом идет обращение к свойству length этого массива экземпляров модели.
Далее обратим внимание на свойство itemController. Это свойство содержит имя контроллера, которым будут обрабатываться шаблоны внутри конструкции #each шаблона todo-list. По сути внутри этой конструкции у нас представлен шаблон отдельного пункта списка.
Соответствующий контроллер объявляется как
Todos.TodoController = Ember.ObjectController.extend({...})
Он служит для работы с одним экземпляром модели.
Рассмотрим несколько подробнее шаблон todo (то, что расположено в блоке #each).
Мы видим, как задаются классы для тега li:
<li {{bind-attr class="isCompleted:completed isEditing:editing"}}>
Если атрибут isCompleted = TRUE, то элемент получает класс completed. Если isEditing = TRUE, то – класс editing.
Посмотрим на контроллер /js/controllers/todo_controller.js:
Todos.TodoController = Ember.ObjectController.extend({
isEditing: false,
bufferedTitle: Ember.computed.oneWay('title'),
actions: {
....
},
removeTodo: function () {
var todo = this.get('model');
todo.deleteRecord();
todo.save();
},
saveWhenCompleted: function () {
this.get('model').save();
}.observes('isCompleted')
});
В нем есть свойство isEditing, но свойства isCompleted – нет. Но последнее свойство есть в модели. Именно так это и работает. Если свойство есть в контроллере, то используется оно, если нет – то идет обращение к модели. Те свойства сущность, что нужно держать в хранилище (например БД) обычно помещают в модель. А те свойства, что относятся к тому, как отобразить сущность помещают в контроллере, который помогает шаблону правильно отобразить сущность.
Далее по коду шаблона todo у нас идет проверка свойства isEditing, что позволяет представлению принимать 2 различные формы в зависимости от состояния объекта.
При выполнении условия, когда идет редактирование объекта (isEditing = TRUE) выполняется этот код
{{todo-input type="text" class="edit" value=bufferedTitle focus-out="doneEditing" insert-newline="doneEditing" escape-press="cancelEditing"}}
Это переопределенный элемент input. Его код можно найти в файле /js/views/todo_input_component.js.
Будем о нем думать как о тектовом поле. Атрибуты focus-out=”doneEditing”, insert-newline=”doneEditing” и escape-press=”cancelEditing” определяют какие функции из блока actions нашего контроллера будут обработчиками событий. И мы видим, что при потере фокуса и вставке символа новой строки (нажатием Enter) будет вызываться функция doneEditing, а при нажатии клавиши Esc – функция cancelEditing.
В атрибут value нашего текстового поля будет всегда подставляться значение свойства bufferedTitle, которое (судя по описанию в контроллере) будет принимать значение поля title нашей модели. Итак, в данной ветке шаблона (и в сопутствующем коде контроллера) по сути описано поведение редактирования элемента нашего списка задач.
Рассмотрим теперь вторую ветку (FALSE) условия проверки isEditing в шаблоне. Там кода немного больше
{{input type="checkbox" class="toggle" checked=isCompleted}}
<label {{action "editTodo" on="doubleClick"}}>{{title}}</label>
<button {{action "removeTodo"}} class="destroy"></button>
У первого элемента (checkbox) состояние checked зависит от свойства isCompleted, как это было с css-классами элемента li выше.
Второй элемент (li) и трейти элемент (button) имеют в своем описании ссылку на обработчики их событий. Для label это – событие двойного клика мышью, а для button событие не уточнено, так как понятно, что это нажатие кнопки. И конечно обработчики этих событий находятся в блоке actions нашего контроллера.
В обработчиках есть еще кое какие интересные нам вызовы. Например, следующие
this.set('bufferedTitle', this.get('title'));
this.set('isEditing', false);
Вот эти set() – это установка свойств, а get() – чтение. Причем здесь так же могут читаться как свойства модели, так и контроллера. Разницы в синтаксисе нет.
Теперь давайте вернемся в шалоне на уровень вложености выше.
Там у нас есть код чекбокса отметки выбранными всех записей списка:
{{input type="checkbox" id="toggle-all" checked=allTodos.allAreDone}}
Здесь атрибут checked определяется свойством allTodos из контроллера Todos.TodosListController. Но свойство это не простое, а ссылка на метод allAreDone в другом контроллере – Todos.TodosController.
Этот метод allAreDone примечателен тем, что он заканчивается конструкцией
.property('length', 'completed.length')
Эта конструкция позволяет указать на то, что метод будет использоваться как свойство. Параметры, которые передаются в конструкцию property служат для указания на от каких свойств зависит данное. Наше свойство allAreDone зависит от свойств length и completed.length и будет пересчитано, если какое-либо из них изменится. Подробнее в соответствующем разделе Ember API. Кстати говоря, такое описание свойств устарело, но об этом в других статьях.
Затем в методе идет обращение к этим данным через get().
var length = this.get('length');
var completedLength = this.get('completed.length');
В методе еще есть и следующая часть
this.setEach('isCompleted', value);
return value;
Если предыдушую часть можно назвать getter, то эту часть можно назвать setter. Служит она для присвоения полю isCompleted в массиве сущностей (записей в списке) значение в соотвествии с состоянием чекбокса #toggle-all.
Конструкция property есть так же в файле /js/controllers/todos_list_controller.js. Там она выглядит так:
canToggle: function () {
var anyTodos = this.get('allTodos.length');
var isEditing = this.isAny('isEditing');
return anyTodos && !isEditing;
}.property('allTodos.length', '@each.isEditing')
Работает аналогично рассмотренному. Только есть прием @each.isEditing, который позволяет отследить в нашем массиве сущностей любое изменение свойства isEditing.
На этот момент мы уже представляем как работают маршруты (routes), шаблоны, модели и контроллеры. Остались небольшие частности. Разберем их.
С помощью конструкции observes можно вешать обработчики на свойства контроллера или модели.
Например, в контроллере /js/controllers/todo_controller.js есть обработчик изменения свойства isComplete модели.
saveWhenCompleted: function () {
this.get('model').save();
}.observes('isCompleted')
В контроллере /js/controllers/todos_list_controller.js есть конструкция needs: [‘todos’]. Это позволяет работать данному контроллеру со связным контроллером. В данном случае у него появится свойство todos (экземпляр Todos.TodosController).
В /js/app.js мы видим инициализацию адаптера хранения данных
Todos.ApplicationAdapter = DS.LSAdapter.extend({
namespace: 'todos-emberjs'
});
LSAdapter – адаптер для хранения данных в Locale Storage.
Подведу итоги. В целом EmberJS мне кажется более мощным, стройным и гармоничным инструментом чем ReactJS из прошлой статьи. Мне нравится, что Ember использует шаблонизатор, а не смешивает XML (HTML) код c JavaScript кодом в одном файле. Есть шаблоны и есть функционал. Код с использование Ember более структурированный и понятный. Но конечно, за такое удобство мы платим большим объемом фреймворка и более высоким порогом вхождения в него. Хотя, надо отдать должное, документация у Ember хорошая.