С каждым днем сайты становятся все сложнее и динамичнее. Уже недостаточно просто «оживить» интерфейс — все чаще требуется создать полноценное одностраничное приложение. Ярким примером такого приложения является любая web-почта (например, Mail.Ru), где переходы по ссылкам приводят не к перезагрузке страницы, а только к смене представления. А это значит, что задача получения данных и их отображения в зависимости от маршрута, которая всегда была прерогативой сервера, ложится на клиент. Обычно эту проблему решают с помощью простенького роутера, на основе регулярных выражений, и дальше не развивают, в то время как на back-end этой теме уделяют гораздо больше внимания. В этой статье я постараюсь восполнить этот пробел.
Что такое роутинг?
Это, наверное, самая недооцененная часть JavaScript-приложения :]
На сервере роутинг — это процесс определения маршрута внутри приложения в зависимости от запроса. Проще говоря, это поиск контроллера по запрошенному URL и выполнение соответствующих действий.
Рассмотрим следующую задачу: нужно создать одностраничное приложение «Галерея», которое будет состоять из трех экранов:
- Главная — выбор направления в живописи
- Просмотр галереи — вывод картин с постраничной навигацией и возможностью изменять количество элементов на странице
- Детальный просмотр выбранного произведения
Схематично приложение будет выглядеть следующим образом:
<div id="app">
<div id="app-index" style="display: none">...</div>
<div id="app-gallery" style="display: none">...</div>
<div id="app-artwork" style="display: none">...</div>
</div>
Каждому экрану будет соответствовать свой URL, и роутер, их описывающий, может выглядеть, например, так:
var Router = {
routes: {
"/": "indexPage",
"/gallery/:tag/": "galleryPage",
"/gallery/:tag/:perPage/": "galleryPage",
"/gallery/:tag/:perPage/page/:page/": "galleryPage",
"/artwork/:id/": "artworkPage",
}
};
В объекте `routes` непосредственно задаются маршруты: ключ — шаблон пути, а значение — название функции-контроллера.
Далее нужно преобразовать ключи объекта `Router.routes` в регулярные выражения. Для этого определим метод `Router.init`:
var Router = {
routes: { /* ... */ },
init: function (){
this._routes = [];
for( var route in this.routes ){
var methodName = this.routes[route];
this._routes.push({
pattern: new RegExp('^'+route.replace(/:w+/, '(\w+)')+'$'),
callback: this[methodName]
});
}
}
};
Осталось описать метод навигации, который будет осуществлять поиск маршрута и вызов контроллера:
var Router = {
routes: { /* … */ },
init: function (){ /* … */ },
nav: function (path){
var i = this._routes.length;
while( i-- ){
var args = path.match(this._routes[i].pattern);
if( args ){
this._routes[i].callback.apply(this, args.slice(1));
}
}
}
};
Когда всё готово, инициализируем роутер и выставляем начальную точку навигации. Важно не забыть перехватить событие `click` со всех ссылок и перенаправить на маршрутизатор.
Router.init();
Router.nav("/");
// Перехватывает клики
$("body").on("click", "a", function (evt){
Router.nav(evt.currentTarget.href);
evt.preventDefault();
});
Как видите, ничего сложного; думаю, многим знаком подобный подход. Обычно все отличия в реализациях сводятся к формату записи маршрута и его трансформации в регулярное выражение.
Вернемся к нашему примеру. Единственное, что в нем отсутствует — это реализация функций, отвечающих за обработку маршрута. Обычно в них идет сбор данных и отрисовка, например, так:
var Router = {
routes: { /*...*/ },
init: function (){ /*...*/ },
nav: function (url){ /*...*/ },
indexPage: function (){
ManagerView.set("index");
},
galleryPage: function (tag, perPage, page){
var query = {
tag: tag,
page: page,
perPage: perPage
};
api.find(query, function (items){
ManagerView.set("gallery", items);
});
},
artworkPage: function (id){
api.findById(id, function (item){
ManagerView.set("artwork", item);
});
}
};
На первый взгляд, выглядит неплохо, но есть и подводные камни. Получение данных происходит асинхронно, и при быстром перемещении между маршрутами можно получить совсем не то, что ожидалось. Например, рассмотрим такую ситуацию: пользователь кликнул на ссылку второй страницы галереи, но в процессе загрузки заинтересовался произведением с первой страницы и решил посмотреть его подробно. В итоге ушло два запроса. Отработать они могут в произвольном порядке, и пользователь вместо картины получит вторую страницу галереи.
Эту проблему можно решить разными способами; каждый выбирает свой путь. Например, можно вызвать `abort` для предыдущего запроса, или перенести логику в `ManagerView.set`.
Что же делает `ManagerView`? Метод `set(name, data)` принимает два параметра: название «экрана» и «данные» для его построения. В нашем случае задача сильно упрощена, и метод `set` отображает нужный элемент по id. Он использует название вида как постфикс `«app-»+name`, а данные — для построения html. Также `ManagerView` должен запоминать название предыдущего экрана и определять, когда начался/изменился/закончился маршрут, чтобы корректно манипулировать внешним видом.
Вот мы и создали одностраничное приложение, со своим `Router` и `ManagerView`, но пройдет время, и нужно будет добавить новый функционал. Например, раздел «Статьи», где будут описания «работ» и ссылки на них. При переходе на просмотр «работы» нужно построить ссылку «Назад к статье» или «Назад в галерею», в зависимости от того, откуда пришел пользователь. Но как это сделать? Ни `ManagerView`, ни `Router` не обладают подобными данными.
Также остался ещё один важный момент — это ссылки. Постраничная навигация, ссылки на разделы и т.п., как их «строить»? «Зашить» прямо в код? Создать функцию, которая будет возвращать URL по мнемонике и параметрам? Первый вариант совсем плохой, второй лучше, но не идеален. С моей точки зрения, наилучший вариант — это возможность задать `id` маршрута и метод, который позволяет получать URL по ID и параметрам. Это хорошо тем, что маршрут и правило для формирования URL есть одно и то же, к тому же этот вариант не приводит к дублированию логики получения URL.
Как видите, такой роутер не решает поставленных задач, поэтому, чтобы не выдумывать велосипед, я отправился в поисковик, сформировав список требований к идеальному (в моём понимании) маршрутизатору:
- максимально гибкий синтаксис описания маршрута (например, как у Express)
- работа именно с запросом, а не только отдельными параметрами (как в примере)
- события «начала», «изменения»и «конца» маршрута (/gallery/cubism/ -> /gallery/cubism/12/page/2 -> /artwork/123/)
- возможность назначения нескольких обработчиков на один маршрут
- возможность назначения ID маршрутам и осуществления навигации по ним
- иной способ взаимодействия `data ←→ view` (по возможности)
Как вы уже догадались, я не нашел то, чего хотел, хотя попадались очень достойные решения, такие как:
- Crossroads.js — очень мощная работа с маршрутами
- Path.js — есть реализация событий «начала» и «конца» маршрута, 1KB (Closure compiler + gzipped)
- Router.js — простой и функциональный, всего 443 байта (Closure compiler + gzipped)
Pilot
А теперь пришло время сделать всё то же самое, но используя Pilot. Он состоит из трех частей:
- Pilot — сам маршрутизатор
- Pilot.Route — контроллер маршрута
- Pilot.View — расширенный контроллер маршрута, наследует Pilot.Route
Определим контроллеры, отвечающие за маршруты. HTML-структура приложения остается той же, что и в примере в первой части статьи.
// Объект, где будут храниться контроллеры
var page = {};
// Контроллер для главной страницы
pages.index = Pilot.View.extend({
el: "#app-index"
});
// Просмотр галереи
pages.gallery = Pilot.View.extend({
el: "#app-gallery",
template: function (data/**Object*/){
/* шаблонизация на основе this.getData() */
return html;
},
loadData: function (req/**Object*/){
// app.find — возвращает $.Deferred();
return app.find(req.params, this.bound(function (items){
this.setData(items);
}));
},
onRoute: function (evt/**$.Event*/, req/**Object*/){
// Метод вызывается при routerstart и routeend
this.render();
}
});
// Просмотр произведения
pages.artwork = Pilot.View.extend({
el: "#app-artwork",
template: function (data/**Object*/){
/* шаблонизация на основе this.getData() */
return html;
},
loadData: function (req/**Object*/){
return api.findById(req.params.id, this.bound(function (data){
this.setData(data);
}));
},
onRoute: function (evt/**$.Event*/, req/**Object*/){
this.render();
}
});
Переключение между маршрутами влечет за собой смену экранов, поэтому в примере я использую Pilot.View. Помимо работы с DOM-элементами, экземпляр его класса изначально подписан на события routestart и routeend. При помощи этих событий Pilot.View контролирует отображение связанного с ним DOM-элемента, выставляя ему `display: none` или убирая его. Сам узел назначается через свойство `el`.
Существует три типа событий: routestart, routechange и routeend. Их вызывает роутер на котроллер(ы). Схематично это выглядит так:
Есть три маршрута и их контроллеры:
"/" -- pages.index
"/gallery/:page?" -- pages.gallery
"/artwork/:id/" -- pages.artwork
Каждому маршруту может соответствовать несколько URL. Если новый URL соответствует текущему маршруту, то роутер генерит событие routechage. Если маршрут изменился, то его контроллер получает событие routeend, а контроллер нового — событие routestart.
"/" -- pages.index.routestart
"/gallery/" -- pages.index.routeend, pages.gallery.routestart
"/gallery/2/" -- pages.gallery.routechange
"/gallery/3/" -- pages.gallery.routechange
"/artwork/123/" -- pages.artwork.routestart, pages.gallery.routeend
Помимо изменения видимости контейнера (`this.el`), как правило, нужно обновлять его содержимое. Для этого у Pilot.View есть следующие методы, которые нужно переопределить в зависимости от задачи:
template(data) — метод шаблонизации, внутри которого формируется HTML. В примере используются данные, полученные в loadData.
loadData(req) — пожалуй, самый важный метод контроллера. Вызывается каждый раз, когда изменяется URL, в качестве параметра получает объект запроса. У него есть особенность: если вернуть $.Deferred, роутер не перейдет на этот URL, пока данные не будут собраны.
{
url: "http://domain.ru/gallery/cubism/20/page/3",
path: "/gallery/cubism/20/page/123",
search: "",
query: {},
params: { tag: "cubism", perPage: 20, page: 123 },
referrer: "http://domain.ru/gallery/cubism/20/page/2"
}
onRoute(evt, req) — вспомогательное событие. Вызывается после routestart или routechange. В примере используется для обновления содержимого контейнера с помощью вызова метода render.
render() — метод для обновления HTML контейнера (`this.el`). Вызывает this.template(this.getData()).
Теперь осталось собрать приложение. Для этого нам понадобится роутер:
var GuideRouter = Pilot.extend({
init: function (){
// Задаем маршруты и их контроллеры:
this
.route("index", "/", pages.index)
.route("gallery", "/gallery/:tag/:prePage?(/page/:page/)?", pages.gallery)
.route("artwork", "/artwork/:id/", pages.artwork)
;
}
});
var Guide = new GuideRouter({
// Указываем элемент, внутри которого перехватываем клики на ссылках
el: "#app",
// Используем HistoryAPI
useHistory: true
});
// Запускаем роутер
Guide.start();
Первым делом мы создаем роутер и в методе `init` определяем маршруты. Маршрут задается методом `route`. Он принимает три аргумента: id маршрута, паттерн и контролер.
Синтаксис маршрута, лукавить не буду, позаимствован у Express. Он подошел по всем пунктам, и тем, кто уже работал с Express, будет проще. Единственное — добавил группы; они позволяют гибче настраивать паттерн маршрута и помогают при навигации по id.
Рассмотрим маршрут, отвечающий за галерею:
// Выражение в скобках и есть группа
this.route("gallery", "/gallery/:tag/:prePage?(/page/:page/)?", …)
// Скобки позволяют выделить часть паттерна, связанного с переменной `page`.
// Если она не задана, то весь блок не учитывается.
Guide.getUrl("gallery", { tag: "cubism" }); // "/gallery/cubism/";
Guide.getUrl("gallery", { tag: "cubism", page: 2 }); // "/gallery/cubism/page/2/";
Guide.getUrl("gallery", { tag: "cubism", page: 2, perPage: 20 }); // "/gallery/cubism/20/page/2/";
Получилось очень удобно: маршрут и URL есть одно и то же. Это позволяет избежать явных URL в коде и необходимости создавать дополнительные методы для формировал URL. Для навигации на нужный маршрут, используется Guide.go(id, params).
Последним действием создается инстанс GuideRouter с опциями перехвата ссылок и использования History API. По умолчанию Pilot работает с location.hash, но есть возможность использовать history.pushState. Для этого нужно установить Pilot.pushState = true. Но, если браузер не поддерживает location.hash или history.pushState, то для полноценной поддержки History API нужно использовать полифил, либо любую другую подходящую библиотеку. При реализации придется переопределить два метода — Pilot.getLocation() и Pilot.setLocation(req).
Вот в целом и всё. Остальные возможности можно узнать из документации.
Жду ваших вопросов, issue и любой другой отдачи :]
Полезные ссылки
— Пример (app.js)
— Документация
— Исходники
— jquery.leaks.js (утилита для мониторинга jQuery.cache)
Автор: RubaXa