Одностраничные приложения (Single-Page Applications, SPA) — это веб-приложения, которые загружают одну HTML-страницу и динамически обновляют ее при взаимодействии с пользователем.
SPA используют AJAX и HTML5 для создания гибких и адаптивных веб-приложений без постоянных перезагрузок страницы. Однако это означает, что большая часть работы возлагается на клиентскую сторону, а именно на JavaScript-код. Разработчику для традиционной ASP.NET может быть трудно совершить такой кульбит. К счастью, существует множество JavaScript-инфраструктур с открытым исходным кодом, которые облегчают создание SPA.
В этой статье я пошагово пройду процесс создания простого SPA-приложения. Попутно вы ознакомитесь с некоторыми фундаментальными концепциями создания SPA, в том числе с шаблонами Model-View-Controller (MVC) и Model-View-ViewModel (MVVM), связыванием с данными и маршрутизацией (routing).
О приложении-примере
Я создал приложение-пример для операций с простой базой данных по фильмам (рис. 1). В крайнем слева столбце страницы отображается список жанров. Выбор жанра приводит к появлению списка соответствующих фильмов. Кнопка Edit рядом с записью позволяет изменять эту запись. После редактирования можно щелкнуть кнопку Save для передачи обновления на сервер или кнопку Cancel для отмены изменений.
Рис. 1. SPA-приложение для базы данных по фильмам
Я создал две версии этого приложения: одна из них использует библиотеку Knockout.js, а другая — библиотеку Ember.js. Эти две библиотеки основаны на разных подходах, поэтому будет весьма поучительно сравнить их. В обоих случаях клиентское приложение не требовало более 150 строк JavaScript-кода. На серверной стороне я задействовал ASP.NET Web API, чтобы обслуживать JSON для клиента. Исходный код обеих версий вы найдете на github.com/MikeWasson/MoviesSPA.
(Примечание Я создавал приложение, используя RC-версию Visual Studio 2013. В RTM-версии некоторые вещи могли измениться, но они не должны повлиять на код.)
Обзор
В традиционном веб-приложении при каждом вызове сервера тот осуществляет рендеринг новой HTML-страницы. Это вызывает обновление страницы в браузере. Если вы когда-нибудь писали приложение Web Forms или PHP, этот жизненный цикл страниц должен быть знаком вам.
В SPA после загрузки первой страницы все взаимодействие с сервером происходит через AJAX-вызовы. Эти AJAX-вызовы возвращают данные (не разметку) — обычно в формате JSON. Приложение использует JSON-данные для динамического обновления страницы без ее перезагрузки.
Traditional Page Lifecycle | Традиционный жизненный цикл страницы |
Client | Клиент |
Page Reload | Перезагрузка страницы |
Server | Сервер |
Initial Request | Начальный запрос |
HTML | HTML |
Form POST | Передача формы командой POST |
SPA Lifecycle | Жизненный цикл в SPA |
AJAX | AJAX |
JSON | JSON |
Одно из преимуществ SPA очевидно: приложения более гибкие и адаптивные, свободные от рваного эффекта перезагрузки страницы и ее рендеринга заново. Другое преимущество может оказаться менее очевидным и касается того, как вы проектируете архитектуру веб-приложения. Отправка данных приложения как JSON обеспечивает разделение между презентационной частью (HTML-разметкой) и прикладной логикой (AJAX-запросы плюс JSON-ответы).
Это разделение упрощает проектирование и развитие каждого уровня. В SPA-приложении с тщательно продуманной архитектурой можно изменять HTML-разметку, не касаясь кода, который реализует прикладную логику (по крайней мере, в идеале). Вы увидите это на практике, когда мы будем обсуждать связывание с данными.
В чистом SPA все UI-взаимодействие происходит на клиентской стороне через JavaScript и CSS. После начальной загрузки страницы сервер действует исключительно как уровень сервисов. Клиенту нужно просто знать, какие HTTP-запросы он должен посылать. Ему не важно, как сервер реализует свою часть.
При такой архитектуре клиент и сервис независимы. Вы могли бы полностью заменить серверную часть, которая выполняет сервис, и, пока вы не изменяете API, вы никак не нарушите работу клиента. Верно и обратное: вы можете заменить все клиентское приложение, не изменяя уровень сервисов. Например, вы могли бы написать родной мобильный клиент, который использует этот сервис.
Создание проекта в Visual Studio
В Visual Studio 2013 есть один тип проекта ASP.NET Web Application. Мастер этого проекта позволяет выбрать ASP.NET-компоненты, которые будут включены в проект. Я начал с шаблона Empty, а затем добавил в проект ASP.NET Web API, установив флажок Web API в разделе Add folders and core references for, как показано на рис. 3.
Рис. 3. Создание нового ASP.NET-проекта в Visual Studio 2013
В новом проекте есть все библиотеки, необходимые для Web API, а также кое-какой конфигурационный код Web API. Я не вводил никаких зависимостей от Web Forms или ASP.NET MVC.
Обратите внимание на рис. 3, что Visual Studio 2013 включает шаблон Single Page Application. Этот шаблон устанавливает скелет SPA-приложения, основанный на Knockout.js. Он поддерживает вход с применением базы данных с информацией о членстве в группах или с помощью внешнего провайдера аутентификации. Я не стал использовать этот шаблон в своем приложении, потому что хотел показать более простой пример с нуля. Шаблон SPA — отличный ресурс, особенно если вам нужно добавить аутентификацию в приложение.
Создание уровня сервисов
Я использовал ASP.NET Web API, чтобы создать простой REST API для приложения. Не буду здесь вдаваться в детали Web API — подробности вы можете прочитать по ссылке asp.net/web-api.
Сначала я создал класс Movie, представляющий фильм. Этот класс делает две вещи:
- сообщает Entity Framework (EF), как создавать таблицы базы данных для хранения информации о фильмах;
- сообщает Web API, как форматировать полезные данные JSON.
Вы не обязаны использовать одну модель для обеих целей. Например, вам может понадобиться, чтобы схема базы данных отличалась от полезных данных JSON. В этом приложении я ничего не усложняю:
namespace MoviesSPA.Models
{
public class Movie
{
public int ID { get; set; }
public string Title { get; set; }
public int Year { get; set; }
public string Genre { get; set; }
public string Rating { get; set; }
}
}
Затем я воспользовался технологией scaffolding в Visual Studio для создания контроллера Web API, который задействует EF в качестве уровня данных. Чтобы применить эту технологию, щелкните правой кнопкой мыши папку Controllers в Solution Explorer и выберите Add | New Scaffolded Item. В мастере Add Scaffold укажите Web API 2 Controller with actions, using Entity Framework, как показано на рис. 4.
Рис. 4. Добавление контроллера Web API
На рис. 5 приведен мастер Add Controller. Я присвоил контроллеру имя MoviesController. Имя имеет значение, так как URI для REST API основываются на имени контроллера. Я также установил флажок Use async controller actions, чтобы задействовать преимущества новой функциональности async в EF 6. Я выбрал класс Movie в качестве модели и указал New data context, чтобы создать новый контекст данных EF.
Рис. 5. Мастер Add Controller
Мастер добавляет два файла:
- MoviesController.cs — определяет контроллер Web API, который реализует REST API для приложения;
- MovieSPAContext.cs — это в основном склеивающий слой EF, который предоставляет методы для запроса нижележащей базы данных.
В табл. 1 показан REST API по умолчанию, создаваемый технологией scaffolding.
Табл. 1. REST API по умолчанию, созданный технологией scaffolding из Web API
HTTP-команда | URI | Описание |
GET | /api/movies | Получить список всех фильмов |
GET | /api/movies/{id} | Получить фильм с идентификатором, равным {id} |
PUT | /api/movies/{id} | Обновить фильм с идентификатором, равным {id} |
POST | /api/movies | Добавить новый фильм в базу данных |
DELETE | /api/movies/{id} | Удалить фильм из базы данных |
Значения в фигурных скобках являются заменителями для подстановки. Например, чтобы получить фильм с идентификатором, равным 5, URI должен выглядеть так: /api/movies/5.
Я расширил этот API, добавив метод, который находит все фильмы указанного жанра:
public class MoviesController : ApiController
{
public IQueryable<Movie> GetMoviesByGenre(string genre)
{
return db.Movies.Where(m =>
m.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase));
}
// Остальной код не показан
Клиент указывает жанр в строке запроса URI. Например, чтобы получить все фильмы жанра Drama, клиент посылает GET-запрос на /api/movies?genre=drama. Web API автоматически связывает параметр запроса с параметром genre в методе GetMoviesByGenre.
Создание веб-клиента
До сих пор я просто создавал REST API. Если вы отправите GET-запрос на /api/movies?genre=drama, исходный HTTP-ответ будет выглядеть так:
HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json; charset=utf-8
Date: Tue, 10 Sep 2013 15:20:59 GMT
Content-Length: 240
[{"ID":5,"Title":"Forgotten Doors","Year":2009,"Genre":"Drama","Rating":"R"}, {"ID":6,"Title":"Blue Moon June","Year":1998,"Genre":"Drama","Rating":"PG-13"},{"ID":7,"Title":"The Edge of the Sun","Year":1977,"Genre":"Drama","Rating":"PG-13"}]
Теперь мне нужно написать клиентское приложение, которое делает с этим что-то осмысленное. Базовый рабочий процесс такой:
- UI инициирует AJAX-запрос;
- обновляем HTML для отображения полезных данных ответа;
- обрабатываем AJAX-ошибки.
Вы могли закодировать все это вручную. Например, вот некоторый jQuery-код, который создает список названий фильмов:
$.getJSON(url)
.done(function (data) {
// При успехе data содержит список фильмов
var ul = $("<ul></ul>")
$.each(data, function (key, item) {
// Добавляем элемент в список
$('<li>', { text: item.Title }).appendTo(ul);
});
$('#movies').html(ul);
});
В этом коде есть кое-какие проблемы. Он смешивает прикладную логику с презентационной и тесно связан с вашим HTML. Кроме того, писать его весьма утомительно. Вместо того чтобы сосредоточиться на приложении, вы тратите время на написание обработчиков событий и кода, манипулирующего DOM.
Решение заключается в том, чтобы использовать JavaScript-инфраструктуру. К счастью, их выбор довольно велик, и эти инфраструктуры имеют открытый исходный код. К некоторым из более популярных инфраструктур относятся Backbone, Angular, Ember, Knockout, Dojo и JavaScriptMVC. Большинство использует вариации шаблонов MVC или MVVM, поэтому будет полезно вкратце рассмотреть эти шаблоны.
Шаблоны MVC и MVVM
Корни шаблона MVC уходят в 80-е годы прошлого века и связаны с ранними графическими UI. Цель MVC — разбиение кода на три уровня со своими обязанностями (рис. 6). Вот что они делают:
- модель представляет данные и бизнес-логику предметной области;
- представление отображает модель;
- контроллер принимает пользовательский ввод и обновляет модель.
Рис. 6. Шаблон MVC
View | View |
Controller | Controller |
Model | Model |
User Input | Пользовательский ввод |
Updates | Обновления |
Modifies | Модифицирует |
Более современная вариация MVC — шаблон MVVM. В шаблоне MVVM:
- модель по-прежнему представляет данные предметной области;
- модель представления — это абстрактное отражение представления;
- представление отображает модель представления и посылает пользовательский ввод модели представления.
В JavaScript-инфраструктуре MVVM представлением является разметка, а моделью представления — код.
MVC имеет много вариаций, а литература по MVC зачастую противоречива и запутана. По-видимому, это и не удивительно для проектировочного шаблона, который начал свою жизнь со Smalltalk-76 и все еще применяется в современных веб-приложениях. Поэтому, хоть и хорошо знать теорию, главное — понимать конкретную инфраструктуру MVC, используемую вами.
Создание веб-клиента с применением Knockout.js
Для первой версии своего приложения я использовал библиотеку Knockout.js. Knockout следует шаблону MVVM, соединяя представление и модель представления через связывание с данными.
Чтобы создать привязки данных, вы добавляете к HTML-элементам специальный атрибут data-binding. Например, следующая разметка связывает элемент span со свойством genre в модели представления. Всякий раз, когда изменяется значение genre, Knockout автоматически обновляет HTML:
<h1><span data-bind="text: genre"></span></h1>
Привязки также могут работать в другом направлении, скажем, если пользователь вводит текст в поле, Knockout обновляет соответствующее свойство в модели представления.
Удобно, что связывание с данными осуществляется декларативно. Вам не требуется подключать модель представления к элементам HTML-страницы. Просто добавьте атрибут data-binding, и Knockout сделает остальное.
Я начал с создания HTML-страницы с базовой разметкой без связывания с данными, как показано на рис. 8.
Рис. 8. Начальная HTML-разметка
<!DOCTYPE html>
<html>
<head>
<title>Movies SPA</title>
</head>
<body>
<ul>
<li><a href="#"><!-- Genre --></a></li>
</ul>
<table>
<thead>
<tr><th>Title</th><th>Year</th><th>Rating</th>
</tr>
</thead>
<tbody>
<tr>
<td><!-- Title --></td>
<td><!-- Year --></td>
<td><!-- Rating --></td></tr>
</tbody>
</table>
<p><!-- Error message --></p>
<p>No records found.</p>
</body>
</html>
(Примечание Я использовал библиотеку Bootstrap для оформления внешнего вида приложения, поэтому в настоящем приложении уйма дополнительных элементов <div> и CSS-классов, управляющих форматированием. Для ясности я убрал все это из кода.)
Создание модели представления
Наблюдаемые объекты (observables) занимают центральное место в системе связывания с данными в Knockout. Наблюдаемым является объект, который хранит какое-то значение и может уведомлять подписчиков об изменении этого значения. Следующий код преобразует JSON-представление фильма в эквивалентный объект с наблюдаемыми полями:
function movie(data) {
var self = this;
data = data || {};
// Данные из модели
self.ID = data.ID;
self.Title = ko.observable(data.Title);
self.Year = ko.observable(data.Year);
self.Rating = ko.observable(data.Rating);
self.Genre = ko.observable(data.Genre);
};
На рис. 9 показана начальная реализация модели представления. Эта версия поддерживает только получение списка фильмов. Средства редактирования я добавлю позже. Модель представления содержит наблюдаемые свойства для списка фильмов, строку ошибки и текущий жанр.
Рис. 9. Модель представления
var ViewModel = function () {
var self = this;
// Наблюдаемые свойства модели представления
self.movies = ko.observableArray();
self.error = ko.observable();
// Жанр, просматриваемый пользователем в данный момент
self.genre = ko.observable();
// Доступные жанры
self.genres = ['Action', 'Drama', 'Fantasy', 'Horror', 'Romantic Comedy'];
// Добавляем JSON-массив объектов movie
// в модель представления
function addMovies(data) {
var mapped = ko.utils.arrayMap(data, function (item) {
return new movie(item);
});
self.movies(mapped);
}
// Обратный вызов для получения ошибок от сервера
function onError(error) {
self.error('Error: ' + error.status + ' ' + error.statusText);
}
// Получаем список фильмов по жанру
// и обновляем модель представления
self.getByGenre = function (genre) {
self.error(''); // очистка ошибки
self.genre(genre);
app.service.byGenre(genre).then(addMovies, onError);
};
// Инициализируем приложение, получая первый жанр
self.getByGenre(self.genres[0]);
}
// Создаем экземпляр модели представления и передаем в Knockout
ko.applyBindings(new ViewModel());
Заметьте, что фильмы находятся в observableArray. Как и подразумевает его имя, observableArray действует как массив, уведомляющий подписчиков об изменении своего содержимого.
Функция getByGenre выдает AJAX-запрос серверу на получение списка фильмов, а затем заполняет результатами массив self.movies.
При использовании REST API одна из самых хитрых частей — обработка асинхронной природы HTTP. jQuery-функция ajax возвращает объект, реализующий Promises API. Вы можете задействовать метод then объекта Promise, чтобы установить обратный вызов, инициируемый, когда AJAX-вызов завершается успешно, и еще один обратный вызов, запускаемый при неудачном AJAX-вызове:
app.service.byGenre(genre).then(addMovies, onError);
Привязки данных
Теперь, когда у меня есть модель представления, я могу связать ее с HTML через привязки данных. Для полного списка жанров, который появляется на левой стороне экрана, я использую следующие привязки данных:
<ul data-bind="foreach: genres">
<li><a href="#"><span data-bind="text: $data"></span></a></li>
</ul>
Атрибут data-bind содержит одно или более объявлений привязок, где каждая привязка имеет форму «привязка: выражение». В этом примере привязка foreach сообщает Knockout перебирать в цикле содержимое массива genres в модели представления. Для каждого элемента в массиве Knockout создает новый элемент <li>. Привязка text в <span> присваивает text в span значение элемента массива, каковой в данном случае является названием жанра.
На данный момент щелчок названия жанра ни к чему не приводит, поэтому я добавляю привязку click для обработки событий щелчка:
<li><a href="#" data-bind="click: $parent.getByGenre">
<span data-bind="text: $data"></span></a></li>
Это связывает событие щелчка с функцией getByGenre в модели представления. Здесь нужно было использовать $parent, так как эта привязка осуществляется в контексте foreach. По умолчанию привязки в foreach ссылаются на текущий элемент в цикле.
Чтобы отобразить список фильмов, я добавил привязки в таблицу, как показано на рис. 10.
Рис. 10. Добавление привязок в таблицу для отображения списка фильмов
<table data-bind="visible: movies().length > 0">
<thead>
<tr><th>Title</th><th>Year</th><th>Rating</th><th></th></tr>
</thead>
<tbody data-bind="foreach: movies">
<tr>
<td><span data-bind="text: Title"></span></td>
<td><span data-bind="text: Year"></span></td>
<td><span data-bind="text: Rating"></span></td>
<td><!-- кнопка Edit будет здесь --></td>
</tr>
</tbody>
</table>
На рис. 10 привязка foreach перебирает в цикле массив объектов movie. Внутри foreach привязки text ссылаются на свойства текущего объекта.
Привязка visible в элементе <table> контролирует, визуализируется ли таблица. Таблица будет скрыта, если массив movies пуст.
Наконец, вот привязки для сообщения об ошибке и сообщения «No records found» (заметьте, что вы можете помещать в привязку сложные выражения):
<p data-bind="visible: error, text: error"></p>
<p data-bind="visible: !error() && movies().length == 0">No records found.</p>
Редактирование записей
Последняя часть этого приложения дает возможность пользователю редактировать записи в таблице. Для этого необходима следующая функциональность:
- переключение между режимами просмотра (только текст) и редактирования (элементы управления вводом);
- передача обновлений на сервер;
- поддержка отмены изменений и восстановление исходных данных.
Чтобы отслеживать режим просмотра/редактирования, я добавил булев флаг в объект movie как наблюдаемое свойство:
function movie(data) {
// Другие свойства опущены
self.editing = ko.observable(false);
};
Мне нужно было, чтобы таблица фильмов отображала текст, когда свойство editing равно false, но переключалась на элементы управления вводом, когда оно — true. Для этого я использовал Knockout-привязки if и ifnot, как показано на рис. 11. Синтаксис «<!-- ko -->» позволяет включать привязки if и ifnot без их размещения внутри элемента HTML-контейнера.
Рис. 11. Поддержка редактирования записей о фильмах
<tr>
<!-- ko if: editing -->
<td><input data-bind="value: Title" /></td>
<td><input type="number" class="input-small" data-bind="value: Year" /></td>
<td><select class="input-small"
data-bind="options: $parent.ratings, value: Rating"></select></td>
<td>
<button class="btn" data-bind="click: $parent.save">Save</button>
<button class="btn" data-bind="click: $parent.cancel">Cancel</button>
</td>
<!-- /ko -->
<!-- ko ifnot: editing -->
<td><span data-bind="text: Title"></span></td>
<td><span data-bind="text: Year"></span></td>
<td><span data-bind="text: Rating"></span></td>
<td><button class="btn" data-bind="click: $parent.edit">Edit</button></td>
<!-- /ko -->
</tr>
Привязка value задает значение элемента управления вводом. Это двухсторонняя привязка, поэтому, когда пользователь вводит что-то в текстовое поле или изменяет выбор в раскрывающемся списке, изменение автоматически распространяется на модель представления.
Я связал обработчики щелчка кнопок с функциями save, cancel и edit в модели представления.
Функция edit проста. Достаточно установить флаг editing в true:
self.edit = function (item) {
item.editing(true);
};
Функции save и cancel немного посложнее. Для поддержки отмены мне нужен был какой-то способ кеширования исходного значения при редактировании. К счастью, Knockout упрощает расширение поведения наблюдаемых объектов. В коде на рис. 12 добавляется функция store в класс observable. Вызов функции store из observable придает этому классу две новые функции: revert и commit.
Рис. 12. Расширение ko.observable функциями revert и commit
ko.observable.fn.store = function () {
var self = this;
var oldValue = self();
var observable = ko.computed({
read: function () {
return self();
},
write: function (value) {
oldValue = self();
self(value);
}
});
this.revert = function () {
self(oldValue);
}
this.commit = function () {
oldValue = self();
}
return this;
}
Теперь я могу вызвать функцию store, чтобы добавить эту функциональность в модель:
function movie(data) {
// ...
// Новый код:
self.Title = ko.observable(data.Title).store();
self.Year = ko.observable(data.Year).store();
self.Rating = ko.observable(data.Rating).store();
self.Genre = ko.observable(data.Genre).store();
};
Рис. 13 демонстрирует функции save и cancel в модели представления.
Рис. 13. Добавление функций save и cancel
self.cancel = function (item) {
revertChanges(item);
item.editing(false);
};
self.save = function (item) {
app.service.update(item).then(
function () {
commitChanges(item);
},
function (error) {
onError(error);
revertChanges(item);
}).always(function () {
item.editing(false);
});
}
function commitChanges(item) {
for (var prop in item) {
if (item.hasOwnProperty(prop) && item[prop].commit) {
item[prop].commit();
}
}
}
function revertChanges(item) {
for (var prop in item) {
if (item.hasOwnProperty(prop) && item[prop].revert) {
item[prop].revert();
}
}
}
Создание веб-клиента с применением Ember
Для сравнения я написал другую версию своего приложения, используя библиотеку Ember.js.
Ember-приложение начинает с таблицы маршрутизации (routing table), которая определяет навигацию пользователя в рамках приложения:
window.App = Ember.Application.create();
App.Router.map(function () {
this.route('about');
this.resource('genres', function () {
this.route('movies', { path: '/:genre_name' });
});
});
Первая строка кода создает Ember-приложение. Вызов Router.map создает три маршрута. Каждый маршрут соответствует URI или шаблону URI:
/#/about
/#/genres
/#/genres/genre_name
Для каждого маршрута вы создаете HTML-шаблон, используя библиотеку шаблонов Handlebars.
В Ember имеется шаблон верхнего уровня для всего приложения. Этот шаблон подвергается рендерингу для каждого маршрута. На рис. 14 показан шаблон application для моего приложения. Как видите, этот шаблон в основном является HTML-кодом, размещаемым в теге script с type="text/x-handlebars". Шаблон содержит специальную разметку Handlebars в двойных фигурных скобках: {{ }}. Эта разметка служит той же цели, что и атрибут data-bind в Knockout. Например, {{#linkTo}} создает ссылку на маршрут.
Рис. 14. Шаблон Handlebars уровня приложения
ko.observable.fn.store = function () {
var self = this;
var oldValue = self();
var observable = ko.computed({
read: function () {
return self();
},
write: function (value) {
oldValue = self();
self(value);
}
});
this.revert = function () {
self(oldValue);
}
this.commit = function () {
oldValue = self();
}
return this;
}
<script type="text/x-handlebars" data-template-name="application">
<div class="container">
<div class="page-header">
<h1>Movies</h1>
</div>
<div class="well">
<div class="navbar navbar-static-top">
<div class="navbar-inner">
<ul class="nav nav-tabs">
<li>{{#linkTo 'genres'}}Genres{{/linkTo}} </li>
<li>{{#linkTo 'about'}}About{{/linkTo}} </li>
</ul>
</div>
</div>
</div>
<div class="container">
<div class="row">{{outlet}}</div>
</div>
</div>
<div class="container"><p>©2013 Mike Wasson</p></div>
</script>
Теперь допустим, что пользователь переходит к /#/about. Это активирует маршрут about. Ember сначала осуществляет рендеринг шаблона application верхнего уровня, затем шаблона about в {{outlet}} шаблона application. Вот шаблон about:
<script type="text/x-handlebars" data-template-name="about">
<h2>Movies App</h2>
<h3>About this app...</h3>
</script>
На рис. 15 показано, как выполняется рендеринг шаблона about в шаблоне application.
Рис. 15. Рендеринг шаблона about
Поскольку у каждого маршрута свой URI, история браузера сохраняется. Пользователь может осуществлять навигацию кнопкой Back, а также обновлять страницу, не теряя контекст или закладку, и перезагружать ту же страницу.
Контроллеры и модели в Ember
В Ember каждый маршрут имеет модель и контроллер. Модель содержит данные предметной области. Контроллер действует как прокси для модели и хранит все данные состояния приложения для представления. (Это не совпадает с классическим определением MVC. В некоторых отношениях контроллер больше похож на модель представления.)
Вот как я определил модель movie:
App.Movie = DS.Model.extend({
Title: DS.attr(),
Genre: DS.attr(),
Year: DS.attr(),
Rating: DS.attr(),
});
Контроллер наследует от Ember.ObjectController (рис. 16).
Рис. 16. Контроллер Movie наследует от Ember.ObjectController
App.MovieController = Ember.ObjectController.extend({
isEditing: false,
actions: {
edit: function () {
this.set('isEditing', true);
},
save: function () {
this.content.save();
this.set('isEditing', false);
},
cancel: function () {
this.set('isEditing', false);
this.content.rollback();
}
}
});
Здесь происходит кое-что интересное. Во-первых, я не указывал модель в классе контроллера. По умолчанию маршрут автоматически устанавливает модель в контроллере. Во-вторых, функции save и cancel используют средства транзакций, встроенные в класс DS.Model. Для отмены изменений просто вызовите функцию rollback модели.
Ember использует массу соглашений по именованию для подключения различных компонентов. Маршрут genres взаимодействует с GenresController, который выполняет рендеринг шаблона genres. По сути, Ember будет автоматически создавать объект GenresController, если вы его не определили. Однако вы можете переопределять все, что предлагается по умолчанию.
В своем приложении я сконфигурировал маршрут genres/movies на использование другого контроллера, реализовав точку подключения (hook) renderTemplate. Тем самым несколько маршрутов может использовать один и тот же контроллер (рис. 17).
Рис. 17. Несколько маршрутов могут иметь общий контроллер
App.GenresMoviesRoute = Ember.Route.extend({
serialize: function (model) {
return { genre_name: model.get('name') };
},
renderTemplate: function () {
this.render({ controller: 'movies' });
},
afterModel: function (genre) {
var controller = this.controllerFor('movies');
var store = controller.store;
return store.findQuery('movie', { genre: genre.get('name') })
.then(function (data) {
controller.set('model', data);
});
}
});
Одна из приятных особенностей Ember в том, что многое можно делать с помощью минимума кода. Мое приложение-пример состоит примерно из 110 строк кода на JavaScript. Эта версия короче, чем версия на основе Knockout, и вдобавок я безо всяких усилий получил поддержку истории браузера. С другой стороны, Ember также является весьма «своенравной» инфраструктурой. Если вы не пишете код в стиле Ember, то скорее всего попадете в неприятности. Так что при выборе инфраструктуры следует принимать во внимание набор функциональности, стиль кодирования и то, насколько общая архитектура инфраструктуры подходит под ваши требования.
Где узнать больше
В этой статье я показал, как JavaScript-инфраструктуры упрощают создание SPA. Попутно я рассказал о некоторых общих средствах этих библиотек, в том числе о связывании с данными, маршрутизации и шаблонах MVC и MVVM. Узнать больше о создании SPA с помощью ASP.NET можно по ссылке asp.net/single-page-application.