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

в 9:03, , рубрики: javascript, routing, Блог компании Mail.Ru Group, Веб-разработка, маршрутизатор, маршрутизация, метки: , , , ,

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

Автор: RubaXa

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js