company_banner

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 pages = {};
      
    // Контроллер для главной страницы
    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)
    Mail.ru Group
    1,032.30
    Строим Интернет
    Share post

    Comments 17

      +4
      Да, прикольно. Спасибо за либу! Попробую по возможности.
      Кстати, хотел узнать: будет ли документация на английском?
      Я тут недавно апрувил вашу либу на Jster.net, и было б неплохо, чтобы не только русскоязычные пользователи могли ею воспользоваться.
        +1
        В процессе.
        +1
        Мне не понравилось, что тянутся некоторые зависимости. Я понимаю, что jQuery тянется для Deferred и селекторов, но всё равно, роутер получился слишком перегруженным. Да и реализация View в роутере, мне кажется, весьма сомнительные плюсы даёт.
          +4
          Есть такое. В планах была идея отказа от jQuery, но большинство задач так и или иначе связанны с использование его, поэтому решил обождать. Также Pilot.View можно использовать как замену Backbone.View, который в свою очередь завязан на jQuery. Возможно в дальнейшем появиться «чистая» сборка.
          +3
          А чем это отличается от Backbone.js?
            +3
            Backbone — MVC фреймворк
            Pilot — только роутер с расширенными возможностями.

            Правильно сравнивать Backbone.Router и Pilot. Тут основное отличие в том, что обработчик маршрута не просто получает какие-то параметры, а знает, что именно произошло (события: routestart, routechange, routeend), а также работает непосредственно с объектом "запроса". И только малая часть, посмотрите документацию (например метод go или loadData), не пожелеете :]
            +1
            А чем это решение лучше director'a?

            События (on, before, after, once) есть, есть и так называемые scopes (обработчики для сегмента пути, например, для /profile/common и /profile/contacts назначить один общий обработчик для profile, и отдельные для common и contacts), обработка «404», поддержка хеша и history api, никаких зависимостей, работа на сервере и клиенте, и прочее, и прочее.

            Из отсутствующих вещей я на вскидку вижу только «переключение адреса до разрешения некоего deferred», но это, на самом деле, спорная фича: если ссылка поменяется сразу, то выполняемое действие можно, например, отменить нажатием кнопки «назад», а так этой возможности нет.
              +1
              Смотрел director перед тем, как начать что-то делать. Он почти подходил, но как и другие решения работает не с запросом, а только параметрами. + в нем есть расширение прототипа для Array, а это сужало возможность использования либы. Также не решен вопрос маршрутизации по id маршрута и нет групп.

              По поводу «back» не понял, можно поподробнее?
                0
                Не совсем понял «работает не с запросом, а только параметрами».

                По поводу «back» не понял, можно поподробнее?

                Если я правильно понял идею loadData:

                0. пользователь переходит на некий адрес в нашем приложении с about:blank
                1. пользователь вызывает некое действие, вызывающее смену маршрута
                2. роутер вызывает loadData, которая возвращает deferred
                3. роутер ждет разрешения deferred
                4. роутер меняет адресную строку и вызывает onRoute.

                Если я прав на счет №№3 и 4, то при нажатии back на шаге 3 произойдет переход на about:blank.
                  +2
                  Не совсем понял «работает не с запросом, а только параметрами».

                  В director и подобных, обработчик маршрута получает только параметры описанные в паттерне:
                  router.on('/books/view/:bookId', function (bookId){ /*...*/ })

                  также не понятно, как в этом случае получить GET-параметры?

                  Pilot передает объект запроса:
                  pilot.route('/books/view/:bookId', function (evt, req){ 
                      var path = req.path;
                      var query = req.query; // GET-параметры
                      var bookId = req.params.bookId;
                      /* и т.д. */
                  })

                  Если я правильно понял идею loadData:
                  Да, вы правы, сейчас это так.
                  Каюсь, забыл добавить второй режим, который позволяет менять url сразу (#1).

                  P.S. В этом есть свои + и -, например gmail работает именно по такой схеме.
                    0
                    И именно такую схему (как сейчас реализована) я бы считал дефолтной, а предложенную выше — опциональной.
                      0
                      Думаю, поведение навигации в браузере не должно отличаться для «обычных» приложений и одностраничных — просто из соображений удобства для пользователя, т. к. в таком случае не надо думать, а как себя поведет навигация сейчас. По умолчанию, браузер при переходе по ссылке меняет урл сразу, не дожидаясь загрузки данных, соответственно, поведение роутера (по крайней мере, по умолчанию) должно быть таким же.
                        0
                        Браузер меняет урл сразу после получения ответа от сервера (или его не получении), но не сразу после клика по ссылке. Пока ответ от сервера ожидается — урл старый остаётся.
              0
              Решил попробовать вашу разработку, возник такой вопрос, как можно пометить несколько роутов какой-то категорией, а потом её прочитать в обработчике. Поясню на примере:
              * есть блок навигации, где представлены только страницы верхнего уровня: Dashboard, Articles, Users
              * при этом самих страниц больше: /dashboard, /articles/list/:page?, /articles/edit/:id?, /users/list/:page?, /users/edit/:id?, /users/roles
              И вот задача, пометить список роутов категориями, чтобы подсвечивать нужный пунк в болке навигации. Т.е. в коде, я бы это хотел видеть, например, так:
              Кусок кода
              app.nav = Pilot.View.extend({
                  onRoute: function(e, req) {
                      this.$('.active').removeClass('active');
                      this.$('.cat-', req.route.options.cat).addClass('active');
                  }
              });
              // ... объявление прочих Views
              app.router = new Pilot()
                  .route('*', App.nav)
                  .route('usersList', '/users/list/:page?', App.usersList, { cat: 'users' })
                  .route('editUser', '/users/edit/:id?', App.editUser, { cat: 'users' })
                  .route('articlesList', '/articles/list/:page?', App.articlesList, { cat: 'articles' });
              

                0
                Идею понял, но пока в голове нет картины как это осуществить, т.к. на один маршрут может быть N-ое количество контроллеров, либо сделать так:
                Пример
                app.nav = Pilot.View.extend({
                    onRoute: function(e, req) {
                        this.$('.active').removeClass('active');
                        this.$('.cat-', req.params.cat).addClass('active');
                    }
                });
                
                app.router = new Pilot
                   .route('/:cat(users|articles)/*', app.nav)
                ;
                
                  0
                  Хм..., предложенный вариант мне подходит, но не подойдёт для общего случая. Про N контроллеров я потом уже и сам понял, и что это и мешает как раз, даже писал комментарий, но то ли отправить его забыл, то ли потерялся он где-то по дороге до хабра. Это, кстати, довольно не стандартно для моего восприятия маршрутов. Я привык, что срабатывает первый подошедший маршрут и все последующие шаблоны отбразываются (опыт работы есть только с серверными реализациями).
                  В любом случае, спасибо, за ответ и за либу.

                  А не планируется ли какого-нибудь описания для начинающих, а то для того, кто не имел опыта с подобными вещами в JS-apps, явно не хватает ни этой статьи, ни довольно кратких доков.
                    0
                    По поводу множественных контролером.
                    Это делалось специально, т.к. страница может стоять из «блоков», есть спец. параметр singleton для удобства. Например вот такая задача:
                    app.router
                       .route('settings', '/:page/settings', app.layer.settings)
                       .route('page', '/:page/*', app.page);
                    ;
                    


                    По поводу раздела для начинающий, полностью согласен, осталось придумать примеры, на которых всё разбирать :]

              Only users with full accounts can post comments. Log in, please.