Pilot: многофункциональный javascript роутер

от автора

С каждым днем сайты становятся все сложнее и динамичнее. Уже недостаточно просто «оживить» интерфейс — все чаще требуется создать полноценное одностраничное приложение. Ярким примером такого приложения является любая 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. Он состоит из трех частей:

  1. Pilot — сам маршрутизатор
  2. Pilot.Route — контроллер маршрута
  3. 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, пока данные не будут собраны.

req — запрос

{      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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *