Представьте себе, как построена незамысловатая онлайн-фотогалерея. По-простому говоря, это две отдельные страницы: список всех фотографий и просмотр отдельно взятой фотографии. При переходе от одной странице к другой пользователю приходится ожидать полной перезагрузки страницы. Интерактивность теряется.
Другой подход: использование AJAX. Вся логика навигации по страницам перемещается в JavaScript. При первом обращении к галерее страница загружается полностью, при последующих действиях пользователя обновляется только нужная часть страницы.
У такого подхода есть недостатки:
- Сложная логика JavaScript.
- Не работает навигация браузера back/forward.
- У отдельных фотографий нет своего адреса URL для прямого перехода.
Визуально галерея будет выглядеть так:
Паттерн Page-View
Итак, в чем заключается основная идея. Введем две абстракции. Page – целая страница, которая требует полной перезагрузки в случае обновления. В нашем приложении она одна — вся фотогалерея. View – отдельное представление страницы. В случае галереи их два: представления списка и отдельной фотографии. Таким образом, одной странице соответствует несколько представлений, однако в любой момент времени активно лишь одно из них. Ниже представлена диаграмма классов иерархий Page и View:
Попробуем разобраться, что здесь изображено.
Начнем с того, как это использовать. Ниже приведен фрагмент html-кода единственной страницы в нашем приложении:
<!-- ListView -->
<div id="list_container" style="display:none;">
</div>
<!-- ImageView -->
<div id="image_container" style="display:none;">
<p><a id="back_link" href="javascript:;"><< Back to list</a></p>
<p id="img_place"></p>
</div>
<script language="javascript">
$(document).ready(function()
{
var p = new Gallery.Page();
p.Init();
});
</script>
* This source code was highlighted with Source Code Highlighter.
В приведенном коде задается разметка для двух представлений: ListView и ImageView. Затем инициализируется экземпляр класса Gallery.Page, представляющий страницу галереи. $(document).ready — конструкция библиотеки jQuery, позволяющая выполнять заданную функцию после загрузки всего содержимого документа, другими словами, когда вся document object model (DOM) документа будет готова к использованию. Библиотека jQuery будет неоднократно использована в дальнейшем. Ее вызовы легко узнаются по символу ‘$’.
Рассмотрим теперь описание класса PageBase.
// * abstract class PageBase *
//
// Represents an entire page.
function PageBase()
{
}
// Array of page's views.
PageBase.prototype._views = {};
PageBase.prototype._AddView = function(view)
{
this._views[view.GetTypeName()] = view;
}
* This source code was highlighted with Source Code Highlighter.
Поле _views – ассоциативный массив, ключом которого является название представления, значением – экземпляр конкретного класса ViewBase. Этот массив заполняется при инициализации подклассов PageBase c использованием функции _AddView.
// Performs page initialization.
PageBase.prototype.Init = function()
{
var t = this;
$.history.init(function(hash) { t._PageLoad(hash); });
}
PageBase.prototype._currentViewName = {};
// Handles page load event.
PageBase.prototype._PageLoad = function(hash)
{
var params = PageBase._ParseHash(hash);
if (!this._views[params.view])
{
// first view is the default one
for (var viewName in this._views)
{
PageBase.Goto(viewName, params);
return;
}
}
// checks if the passed view has changed
if (this._currentViewName && params.view !== this._currentViewName)
{
this._views[this._currentViewName].Hide();
}
this._currentViewName = params.view;
this._views[params.view].Show(params);
}
* This source code was highlighted with Source Code Highlighter.
В методе Init происходит инициализация плагина History для jQuery. Этот плагин позволяет переписывать url-страницы в зависимости от текущего View и его параметров, а также корректно работать с навигацией back/forward.
Закрытый (private) метод _PageLoad выбирает соответствующее параметрам url отображение и показывает его, при необходимости скрывая предыдущий View.
PageBase.Goto = function(viewName, params)
{
params.view = viewName;
var hash = PageBase._MakeHash(params);
$.history.load(hash);
}
* This source code was highlighted with Source Code Highlighter.
Goto – статическая функция, осуществляющая переход на заданный View. Обращаю внимание, что параметры с которыми работают все View – это ассоциативный массив. В то время как в url параметры (hash) записаны в виде строки. Для сериализации и десериализации параметров используются методы PageBase._MakeHash и PageBase._ParseHash, соответственно.
Перейдем к конкретной реализации класса PageBase, то есть к Gallery.Page:
// * namespace Gallery *
var Gallery = Gallery || {};
// * class Page *
//
// Represents an entire gallery page.
Gallery.Page = function() // extends PageBase
{
PageBase.call(this);
}
OO.Extends(Gallery.Page, PageBase);
// * public methods *
Gallery.Page.prototype.Init = function()
{
this._AddView(new Gallery.ListView());
this._AddView(new Gallery.ImageView());
Gallery.Page.superclass.Init.call(this);
}
* This source code was highlighted with Source Code Highlighter.
Функция OO.Extends – это паттерн наследования в JavaScript. Реализация позаимствована из книги Pro JavaScript Design Patterns (авторы: инженеры Yahoo и Google). Это лучшая книга по ООП на JavaScript из тех, что я знаю. Очень рекомендую.
Пришло время разобраться с классами иерархии View:
//* abstract class ViewBase *
//
// Represents a single view on a page.
function ViewBase()
{
}
// * private/protected fields *
ViewBase.prototype._params = null;
ViewBase.prototype._container = null;
// * public methods *
ViewBase.prototype.GetViewName = function()
{
throw "ViewBase.GetViewName is not implemented.";
}
ViewBase.prototype.Show = function(params)
{
if (!this._params) // lazy initialization
{
this._Init();
this._params = {};
}
if (!ViewBase._CompareParams(params, this._params))
{
this._params = params;
this._Refresh();
}
this._ShowImpl();
}
ViewBase.prototype.Hide = function()
{
this._container.hide();
}
// * private/protected methods *
ViewBase.prototype._ShowImpl = function()
{
this._container.show();
}
// View initialization. Container must be specified here (in subclasses).
ViewBase.prototype._Init = function()
{
throw "ViewBase._Init is not implemented.";
}
ViewBase.prototype._Refresh = function()
{
throw "ViewBase._Refresh is not implemented.";
}
* This source code was highlighted with Source Code Highlighter.
Поле _container – это jQuery объект, представляющий DOM-элемент контейнера, определенного в html-разметке страницы. Инициализация поля должна происходить в функции Init подклассов.
Благодаря проверке параметров на равенство с помощью функции ViewBase._CompareParams реализуется механизм кеширования: представление (View) не обновляется, если параметры не изменились с прошлого раза.
Функции, бросающие исключение, не что иное, как абстрактные методы класса. Таким образом, все что остаетcя сделать в подклассах ViewBase – определить реализации методов GetViewName, _Init, _Refresh. Вот как это делается в классе ListView:
// * namespace Gallery *
var Gallery = Gallery || {};
// * class ListView *
//
// Represents view on a page.
Gallery.ListView = function() // extends ViewBase
{
ViewBase.call(this);
}
OO.Extends(Gallery.ListView, ViewBase);
// * public methods *
Gallery.ListView.prototype.GetTypeName = function()
{
return "list";
}
// * private/protected methods *
Gallery.ListView.prototype._Init = function()
{
this._container = $("#list_container");
}
Gallery.ListView.prototype._Refresh = function()
{
var t = this;
$.ajax(
{
url: "/Home/List",
dataType: "json",
success: function(data) { t._ListLoaded(data); }
});
}
Gallery.ListView.prototype._ListLoaded = function(data)
{
this._container.empty();
for (var i = 0; i < data.Images.length; i++)
{
this._container.append("<p><a href='javascript:;'>" + data.Images[i] + "</a></p>");
}
$("a", this._container).click(function() { PageBase.Goto("image", { img: $(this).text() }); });
}
* This source code was highlighted with Source Code Highlighter.
Метод _Refresh для обновления данных страницы обращается к веб-сервису, расположенному по url /Home/List. Обратите внимание, что благодаря упомянутому выше механизму кеширования запрос выполняется только при открытии списка в первый раз.
Ниже приведен код реализации веб-сервиса на ASP.NET MVC:
[AcceptVerbs(HttpVerbs.Get)]
public JsonResult List()
{
var images = new List<string>();
foreach (var file in Directory.GetFiles(HostingEnvironment.ApplicationPhysicalPath
+ "/Content/Images/"))
{
images.Add(Path.GetFileName(file));
}
return Json(new {Images = images});
}
* This source code was highlighted with Source Code Highlighter.
Полный код приложения (VS 2008, ASP.NET MVC) можно скачать здесь.
JS-скрипты также можно скачать отдельно.
Подведение итогов
Благодаря паттерну Page-View удалось решить, указанные в начале статьи проблемы. Действительно, несмотря на то, что код получился достаточно громоздким, основная его часть сосредоточена в базовых абстракциях (PageBase и ViewBase). Таким образом, добавление новых страниц (Page) и представлений (View) становится тривиальной задачей.
Другой положительный результат применения паттерна. Обратите внимание, что серверная логика приложения сконцентрирована в методе List. Страница, содержащая разметку, статична. Это позволяет разрабатывать отлично масштабируемые веб-приложения. Динамическое поведение серверной части ограничивается обработкой ajax-запросов. Остальной контент, включая html-разметку и js-код, статичен, и, следовательно, может быть без проблем распределен по разным серверам кластера.
P.S. Огромное спасибо моему коллеге Дмитрию Егорову, который является автором большей части описанных здесь идей :)