Что такое роутинг?
Это, наверное, самая недооцененная часть 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)
ссылка на оригинал статьи http://habrahabr.ru/company/mailru/blog/172333/
Добавить комментарий