Пишем современный маршрутизатор на JavaScript

Автор оригинала: Riccardo Canella
  • Перевод
Доброго времени суток, друзья!

Простые одностраничные приложения, основанные на React, Vue или чистом JavaScript, окружают нас повсюду. Хороший «одностраничник» предполагает соответствующий механизм маршрутизации.

Такие библиотеки, как «navigo» или «react-router», приносят большую пользу. Но как они работают? Необходимо ли нам импортировать всю библиотеку? Или достаточно какой-то части, скажем, 10%? В действительности, быстрый и полезный маршрутизатор можно легко написать самому, это займет немного времени, а программа будет состоять менее чем из 100 строчек кода.

Требования


Наш маршрутизатор должен быть:

  • написан на ES6+
  • совместим с историей и хешем
  • переиспользуемой библиотекой

Обычно в веб приложении используется один экземпляр маршрутизатора, но во многих случаях нам требуется несколько экземпляров, поэтому мы не сможем использовать синглтон (Singleton) в качестве шаблона. Для работы нашему маршрутизатору необходимы следующие свойства:

  • маршрутизаторы (routes): список зарегистрированных маршрутизаторов
  • режим (mode): хеш или история
  • корневой элемент (root): корневой элемент приложения, если мы находимся в режиме использования истории
  • конструктор (constructor): основная функция для создания нового экземпляра маршрутизатора

class Router {
    routes = []
    mode = null
    root = '/'

    constructor(options) {
        this.mode = window.history.pushState ? 'history' : 'hash'
        if (options.mode) this.mode = options.mode
        if (options.root) this.root = options.root
    }
}

export default Router

Добавление и удаление маршрутизаторов


Добавление и удаление маршрутизаторов осуществляется через добавление и удаление элементов массива:

class Router {
    routes = []
    mode = null
    root = '/'

    constructor(options) {
        this.mode = window.history.pushState ? 'history' : 'hash'
        if (options.mode) this.mode = options.mode
        if (options.root) this.root = options.root
    }

    add = (path, cb) => {
        this.routes.push({
            path,
            cb
        })
        return this
    }

    remove = path => {
        for (let i = 0; i < this.routes.length; i += 1) {
            if (this.routes[i].path === path) {
                this.routes.slice(i, 1)
                return this
            }
        }
        return this
    }

    flush = () => {
        this.routes = []
        return this
    }
}

export default Router

Получение текущего пути


Мы должны знать, где находимся в приложении в определенный момент времени.

Для этого нам потребуется обработка обоих режимов (истории и хеша). В первом случае, нам нужно удалить путь к корневому элементу из window.location, во втором — "#". Нам также необходима функция (clearSlash) для удаления всех маршрутизаторов (строки от начала до конца):

[...]

    clearSlashes = path =>
        path
        .toString()
        .replace(/\/$/, '')
        .replace(/^\//, '')

    getFragment = () => {
        let fragment = ''

        if (this.mode === 'history') {
            fragment = this.clearSlashes(decodeURI(window.location.pathname + window.location.search))
            fragment = fragment.replace(/\?(.*)$/, '')
            fragment = this.root !== '/' ? fragment.replace(this.root, '') : fragment
        } else {
            const match = window.location.href.match(/#(.*)$/)
            fragment = match ? match[1] : ''
        }
        return this.clearSlashes(fragment)
    }
}

export default Router

Навигация


Ок, у нас имеется API для добавления и удаления URL. Также у нас имеется возможность получать текущий адрес. Следующий шаг — навигация по маршрутизатору. Работаем со свойством «mode»:

[...]

    getFragment = () => {
        let fragment = ''

        if (this.mode === 'history') {
            fragment = this.clearSlashes(decodeURI(window.location.pathname + window.location.search))
            fragment = fragment.replace(/\?(.*)$/, '')
            fragment = this.root !== '/' ? fragment.replace(this.root, '') : fragment
        } else {
            const match = window.location.href.match(/#(.*)$/)
            fragment = match ? match[1] : ''
        }
        return this.clearSlashes(fragment)
    }

    navigate = (path = '') => {
        if (this.mode === 'history') {
            window.history.pushState(null, null, this.root + this.clearSlashes(path))
        } else {
            window.location.href = `${window.location.href.replace(/#(.*)$/, '')}#${path}`
        }
        return this
    }
}

export default Router

Наблюдаем за изменениями


Теперь нам нужна логика для отслеживания изменений адреса как с помощью ссылки, так и с помощью созданного нами метода «navigate». Также нам необходимо обеспечить рендеринг правильной страницы при первом посещении. Мы могли бы использовать состояние приложения для регистрации изменений, однако в целях изучения сделаем это с помощью setInterval:

class Router {
    routes = [];
    mode = null;
    root = "/";

    constructor(options) {
        this.mode = window.history.pushState ? "history" : "hash";
        if (options.mode) this.mode = options.mode;
        if (options.root) this.root = options.root;

        this.listen();
    }

    [...]

    listen = () => {
        clearInterval(this.interval)
        this.interval = setInterval(this.interval, 50)
    }

    interval = () => {
        if (this.current === this.getFragment()) return
        this.current = this.getFragment()

        this.routes.some(route => {
            const match = this.current.match(route.path)

            if (match) {
                match.shift()
                route.cb.apply({}, match)
                return match
            }
            return false
        })
    }
}

export default Router

Заключение


Наша библиотека готова к использованию. Она состоит всего лишь из 84 строчек кода!

Код и пример использования на Github.

Благодарю за внимание.

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 38

    +2
    add = (path, cb) => {

    В чём профит от такого объявления метода класса?

    Чем это луше обычного объявления?
    add(path, cb) {
      +3
      Это не лучше, но и не хуже. Автор просто не любит явный код.

      Данный код
      class Test {
        myMethod = () => {
          ...
        };
      
        set = () => {
          setInterval(this.myMethod, 300);
        }
      }
      


      После компиляции тайпскриптом или бабелем становится таким
      class Test {
        constructor() {
          this.myMethod = () => {
            ...
          };
          this.set = () => {
            setInterval(this.myMethod, 300);
          };
        }
      }
      


      Это что бы можно было передавать метод в setTimeout/setInterval не прибегая к bind или явному указанию стрелочной функции. Так же минус этого подхода в том, что это это не метод как таковой, а проперти инстанса класса. Наследование работать не будет.

      Лично я предпочитаю явный код
      class Test {
        myMethod() {
          ...
        }
        
        set() {
          setInterval(() => this.myMethod(), 300);
        }
      }
      
        –3
        Ну и хорошо, что наследование не будет работать, вместо него лучше композиция: en.wikipedia.org/wiki/Composition_over_inheritance
        Необходимость помнить о потере this — лишняя когнитивная нагрузка, а бесконечный поток статей про контекст в JS тому подтверждение.
          +1
          Ну и хорошо, что наследование не будет работать, вместо него лучше композиция

          Но зачем тогда вам вообще class-ы?

            –1
            А разве обязательное условие использования классов это наследование? И если в классах использовать композиции, то это фу-фу-фу и работать не будет, поддерживать нереально и т.д. и т.п.?
            Я тоже предпочитаю в классах композиции вместо наследования и ни кто и ни что при этом не страдает, а только получают преимущества.
              +1

              Ну дык вопрос тот же. Зачем вам классы? Хотя кажется я знаю ответ — декораторы.


              P.S. ещё вспомнил, в nest JS за счёт классов пытаются runtime type-checking делать.

                0
                Правильно, декораторы вещь крутая и мощная, поэтому можно совмещать преимущества со всех фронтов и ни в чем себе не отказывать)
          –1

          Что значит явный/неявный? Class properties уже поддерживаются и в Chrome, и в Firefox, конвертировать совсем не обязательно.


          И интересно, почему наследование работать не будет? Конструктор родителя вызовется первым, все нормально переопределится


          class Parent {
             getValue = () => 1
          }
          
          class Child extends Parent {
             getValue = () => 2
          }
          
          console.log(new Parent().getValue()) // 1
          console.log(new Child().getValue()) // 2
            +1

            (ворчание) А потом они удивляются, почему вкладка с todo-list app съедает пару сотен мегабайт оперативки...


            Это не наследование, а запись методов напрямую в экземпляр. От того, что вы через родительский конструктор запишите эти методы в экземпляр дочернего класса никакое наследование не появится.


            В JS наследование реализуется через прототипы, а не методы в class properties


            Вот такой код


            class Test {
              myMethod() {
                ...
              }
            
              set() {
                setInterval(() => this.myMethod(), 300);
              }
            }
            

            фактически означает вот это


            function Test() {}
            
            Test.prototype.myMethod = function() {...};
            
            Test.prototype.set = function() { 
                setInterval(() => this.myMethod(), 300); 
            };
            

            А вот такой код


            class Parent {
               getValue = () => 1
            }

            фактически означает


            function Parent() {
              this.getValue =  () => 1
            }
              +1
              А потом они удивляются, почему вкладка с todo-list app съедает пару сотен мегабайт оперативки...

              Come on… Вы сами себя обманываете. Следите за руками:


              // variant 1
              class Parent {
                 getValue = () => 1 // +1 method
              }
              
              // variant 2
              setInterval(() => this.myMethod(), 300); // +1 method
              
              // variant 3
              this.myMethod = this.myMethod.bind(this); // +1 method

              Где вы видите экономию сотен MiB памяти?


              Если по итогу вы в любом случае создаёте замыкание или bind-метод, то это больше уже вопрос о вкусах, нежели о какой-либо разницы в производительности.


              В защиту prototype могу лишь сказать, что если тестировать код с подменой метода в прототипе, то это куда проще сделать, когда метод есть сразу, а не создаётся в конструкторе, т.к. не существует способов переписать конструктор (поправьте меня если я не прав).

                0

                В варианте 2 память выделяется при вызове метода, в котором лежит setInterval, и освобождается после завершения работы, а в других вариантах этого не происходит.


                Если у вас всего один экземпляр класса, то вы не заметите разницу. Только зачем вам тогда вообще классы?


                А вот если экземпляров много, то случае с прототипным наследованием методы хранятся в прототипе, а в случае с хранением их в экземплярах — они дублируются для каждого экземпляра.

                  0
                  В варианте 2 память выделяется при вызове при вызове метода, в котором лежит setInterval, и освобождается после завершения работы

                  В данном случае время жизни интервала равно времени жизни router-а. Память не высвобождается никогда (пока жива страница).


                  Применимо же в некому абстрактному случаю в вакууме, вы можете получить выигрыш по памяти тогда, когда время жизни вашего callback-а заведомо меньше времени жизни объекта владельца. Но стоит отметить что и проиграть вы можете там же, если, скажем, ваш метод может использоваться сразу в нескольких местах, но вы пересоздаёте его всякий раз заново. В общем тут нужно смотреть конкретный случай под микроскопом.


                  Сразу отмечу, что весь этот спор обретает хоть какой-либо смысл только тогда, когда у вас большие объёмы. Скажем это ядро какой-нибудь сетевой игры, или скажем недра реактивного-фреймворка, или некий html-шаблонизатор. Если речь не идёт о десятках тысячах объектов, то это последнее место, в котором стоит обсуждать вопрос памяти.

                –1

                А практическая разница от этих отличий какая? Наследники перекрывают свойства у родителей, что еще нам нужно?


                Патчинг свойств у прототипа для тестирования, как показывает faiwer – это анти-паттерн, такого кода лучше избегать.

                  0
                  это анти-паттерн, такого кода лучше избегать.

                  Мне кажется тестирование это в целом кладезь самых разных антипаттернов в одном месте. Все эти stub-ы, mock-и и пр. Особенно в динамически типизированных языках.

                    0

                    Функции — точно такой же объект, как любой другой. Их даже создавать можно через конструктор new Function.


                    Если метод лежит в прототипе, то все потомки по prototype chain получают возможность использовать одну копию метода.


                    А если мы их пишем в сам объект в конструкторе, то память под методы выделяется при создании каждого экземпляра. Чем больше экземпляров, тем больше расход памяти.

                      0

                      Там уже выше faiwer показал, что в этом примере с таймерами, дополнительная копия метода создается в любом случае, как ни крути.


                      Понятное дело, если метод всегда вызывается с правильным this, то его можно объявить по-обычному, в прототипе. Мой основной вопрос был к этому предложению:


                      Автор просто не любит явный код.

                      class Test {
                        myMethod = () => {}
                      }
                      
                      class Test {
                        constructor() {
                          this.myMethod = () => {};
                        }
                      }

                      Обе конструкции одинаково явные, автор просто предпочел более компактный синтаксис, не более того.

                        0
                        А если мы их пишем в сам объект в конструкторе, то память под методы выделяется при создании каждого экземпляра. Чем больше экземпляров, тем больше расход памяти.

                        Подход с class-properties применимо в методам используется не всегда, а тогда, когда нужно за-bind-ить метод к instance-у его объекта. Тут та же зависимость: чем больше экземпляров, тем больше расход памяти.

                0

                Сразу биндится контекст выполнения

                  0

                  Сохраняется контекст если передать метод по ссылке.
                  Обычное такое используют в обработчиках событий. Конкретно тут, да, это можно считать лишним.

                  +6
                  Думаю вам стоит переписать ваш роутер. Во первых использовать setInterval категорически не рекомендую. Почитайте о блокировках с setInterval. Если есть возможность, то надо избегать данную функицию.

                  Так же настоятельно рекомедую почитать документацию о «window.history» и событиях hashchange/popstate.
                    0
                    Согласен. Ужасное решение.
                    0

                    А почему в JS избегают именованных маршрутов?

                      0

                      Мне кажется, концепция "роутер" переоценена. Почему, просто в стэйт не писать распарсенный url? Почему урл вообще занимает какое то особенное место в источниках данных мне непонятно.

                        0

                        https://github.com/kitze/mobx-router вот эта идея мне очень понравилась в своё время

                          0

                          Это тоже слегка не то, по крайней мере в примерах, опять, точки входа, явно описываются компоненты итд. Может у меня конечно профдеформация, т.к. те спа, которые я пишу, не требуют роутера в принципе. Но что то мне подсказывает, что есть проблема восприятия — считается, что адрес, это информация от юзера, но в спа на самом деле, это генерированная приложением информация, своеобразный сторэдж, почему бы по /login просто не отрендерить диалог входа на главной, вместо того, чтобы роутить в непонятного дикобраза, который возьмет главную как декоратор и заинжектит туда форму входа? Например.

                            0
                            В плюсы роутинга можно отнести:
                            • навигация средствами браузера Back/Forward
                            • навигация по истории браузере
                            • линк можно сохранить в Bookmarks и открыть нужную страницу быстро
                            • линк можно скопировать и отправить другому юрезу, к примеру линк на какой нибуть репорт


                            Если не использовать роутинг в браузере, то это придется эмулировать в SPA и не факт, что получится так же удобно, как стандартные средства браузера.

                            Еще к плюсам, думаю, можно отнести единую точку входа в приложение, имею ввиду, что если для вас проект новый и что-то надо пофиксить. Копать в начнете с роутинга, раскручивая цепочку вызовов итд.
                              0

                              Хотел добавить, что я не против использования url, и тем более history api. Я против такой фиксации на таких неважных вещах. По моему адрес из строки в SPA должен обрабатываться в бизнес логике, а указывать компоненты как точку входа, непонятно зачем.

                                0

                                Какая же это бизнес-логика? Вроде чистая логика отображения и компоненты вполне в ней уместны.

                                  0
                                  Мне кажется вы путаете Backend API url и Frontend url в SPA.

                                  Для бекенда есть своя точка входа, куда вы отправляете запрос и получаете результат — список данных, созданный, обновленный или удаленный объект итд. Бизнес логика приложения бекенда.

                                  Для фронтенда url служит для указания страницы или состояния. К примеру url откроет страницу с каким нибудь отчетом или графиком за период. Или это может быть страница товара или услуги. А под капотом у этой страницы, могут быть несколько запросов к BE API. И это уже логика фронтенда. И url это удобный инструмент для фиксирования или указания состояния.

                                  В каждом случае url для BE и FE служат разным целям.
                                    0
                                    И url это удобный инструмент для фиксирования или указания состояния.

                                    Как не ищу, всё никак не могу найти плюсы клиентского роутинга. Напротив — одни проблемы. Особеннно когда делают несколько уровней вложенности, а в особых случаях даже на попапы отдельные роуты заводят. Но при этом забывают, что пользователь может нажать f5 — и всё то, что он накликал до этого теряется и приложение падает. А потом просят исправить.

                                    В плюсы роутинга можно отнести:
                                    навигация средствами браузера Back/Forward
                                    навигация по истории браузере

                                    Чувствуете «семантику»?..)
                                    Браузерные кнопки Back/Forward — служат для навигации по истории БРАУЗЕРА. Перебивать его клиентским роутингом — это какой-то костыль, можно сказать «несемантично».
                                    Если нужна навигация по истории внутри ПРИЛОЖЕНИЯ — это делается кнопками в интерфейсе самого приложения. Как правило всегда есть кнопки «назад», «отменить» и т.п. Если таких кнопок в интерфейсе нет — то значит навигация этому приложению и не нужна вовсе.
                                    SPA — ОДНОстраничное приложение, опять «семантика» ощущается..) Оно как бы за себя говорит — что должно находится на одном серверном роуте.

                                    На самом деле для себя вижу только один кейс для клиентского роутинга — когда пользовательский сценарий приложения предусматривает сценарий «поделиться ссылкой на текущую вьюху» (тот же Zeplin онлайн допутим). Т.е. это есть в требованиях. Буду рад если подскажите ещё кейсы.

                                    Можете сказать, что есть сценарий заполнения длинной формы/визарда, и при рефреше она должна сохранятся. Ок. Только вот как правило такие формы/визарды сохраняют промежуточные шаги на сервере. Восстановить нужный шаг не проблема, роутинг для этого не нужен.

                                    Более того, некоторые пользователи ожидают и хотят, чтоб при рефреше странички — всё сбросилось в первоначальный вид и можно было начать заново. Например затупил где-то, или запутался где-то глубоко в интерфейсе. Да и приложению полезно — память подчистится, всё проинициализируется в правильном порядке, менее бажный код, больше надёжность вобщем.

                                    Вобщем мой поинт в следующем:
                                    — Роутинг — это про навигацию по истории браузера.
                                    — Для навигации по истории приложения — лучше использовать элементы интерфейса самого приложения.
                                    — Добавлять клиентский роутинг — только при явных требованиях, а не раньше времени, просто потому что так принято (ещё и ~+25кб тянуть).

                                    Пока что придерживаюсь мнения, что клиентский роутинг зло и порождает одни проблемы при поддержке.

                                      +1
                                      Перебивать его клиентским роутингом — это какой-то костыль, можно сказать «несемантично».

                                      «Вы не любите собак? Вы просто не умеете их готовить.» (с)

                                      Если вы не видите плюсов роутинга в вашем приложении, возможно приложение изначально было спроектировано так, что роутинг сложно добавить.

                                      SPA — ОДНОстраничное приложение, опять «семантика» ощущается..) Оно как бы за себя говорит — что должно находится на одном серверном роуте.

                                      SPA и роутинг это как сравнивать теплое с мягким. SPA это в первую очередь веб приложение, которое не требует перезагрузки страницы. Что позволяет: снизить нагрузку на бекенд — не нужны затраты на серверный рендеринг; снизить нагрузку на браузер — не надо заново рендерить всю страницу.

                                      Роутинг это указание текущего состояния или страницы. К примеру имеем шаблон роута

                                      /controller/action(/arg1)(/arg2)(/argN...)?options

                                      И сразу как пример страница редактирования товара

                                      /product/edit/12345

                                      Второй пример отчет по продукту

                                      /product/report/12345?dStart=01.04.2020&dEnd=15.04.2020&sortName=name&sort=asc

                                      Глядя на примеры выше, кадый поймет что должно быть на странице. И трактовать двояко никак не получится. Это и есть семантика роутинга.

                                      Даже если юзер нажмет f5, он попадет на нужную страницу с нужным контентом. Так же юзер без пробмем может совершать навигацию по приложению, кликая на линки или использую кнопки back/forward. Я постоянно наблюдаю ситуацию, когда пользователь просто кликает раз пять «back» что бы вернутся на какой нибыть продукт, который был открыт ранее. Потому что так проще и быстрее, чем открыть страницу списка товаров, кликнуть в поле поиска и вбить имя товара или его номер.

                                      Можете сказать, что есть сценарий заполнения длинной формы/визарда, и при рефреше она должна сохранятся

                                      Что касаемо визардов. Роутинг служит точкой входа, но не точкой промежуточного состояния. Если требуется сохранить промежуточное состояние, то здесь как вы указали, надо сохранять данные на сервере, или в localStorage, или в IndexesDB. Но еще раз почеркну, визард и роутинг вещи разные.

                                      На самом деле для себя вижу только один кейс для клиентского роутинга...

                                      Плюсы роутинга я написал выше.
                                      Добавлю еще один пункт к плюсам — табы браузера, когда линк можно открыть новом табе. К примеру сравнить какие нибудь данные.

                                      Использовать роуты или нет — выбор каждого. Если вы не используете роутинг и пользователи довольны, то почему бы и нет. Хотя, здесь и другая может быть ситуация — у них нет выбора и используют что есть, даже если это неудобно.
                                        0
                                        Глядя на примеры выше, кадый поймет что должно быть на странице.

                                        Обычный пользователь не поймёт, он туда даже не смотрит.

                                        Я постоянно наблюдаю ситуацию, когда пользователь просто кликает раз пять «back» что бы вернутся на какой нибыть продукт, который был открыт ранее. Потому что так проще и быстрее, чем открыть страницу списка товаров, кликнуть в поле поиска и вбить имя товара или его номер.

                                        Если вы говорите про какие-то интернет магазины / блоги, где основной так сказать «business value» — это фильтры и карточка товара / статьи, которые должны в первую очередь индексироваться поисковиками, то делать это как SPA считай разводить заказчика на деньги.
                                        Такие вещи делать необходимо как раз-таки с классическим серверным рендерингом (не SSR который оживляет SPA, а по старинке — серверные html-шаблоны), и с серверным роутингом. При этом где какой роут (его название, глубина и т.д) должен соответствовать какой странице — решает в первую очередь seo-специалист, а не фронт на свой вкус. Да, внезапно, на бекенде придётся больше поработать чем на фронте..)
                                        +1

                                        А как вы представляете себе тот же vk или facebook без client routing-а? К чёрту ссылки? Пусть всё ведёт на главную? Или пусть любая ссылка перегружает всё приложение? Серьёзно?


                                        На самом деле для себя вижу только один кейс для клиентского роутинга — когда пользовательский сценарий приложения предусматривает сценарий «поделиться ссылкой на текущую вьюху» (тот же Zeplin онлайн допутим). Т.е. это есть в требованиях. Буду рад если подскажите ещё кейсы.

                                        Стоит добавить, что поделиться не только с другими, но и с самим собой. Обновить страницу и не потерять положение в приложении. Открыть страницу в другой вкладке. Открыть много страниц в других вкладках. Скажем есть у вас список товаров\постов\чего-угодно. Средней кнопкой мыши можно открыть каждую из них в отдельной вкладке.

                                          +1

                                          Сохранить в закладках прежде всего

                                            0
                                            А как вы представляете себе тот же vk или facebook без client routing-а?

                                            Стоит добавить, что поделиться не только с другими, но и с самим собой. Обновить страницу и не потерять положение в приложении.

                                            Я ж непротив. В самом начале написал, что если сценарий приложения действительно предпологает кейсы, когда необходимо поделиться текущей вьюхой, сохранить в избранное — то да, это требование, и его надо реализовывать с помощью клиентского роутинга.
                                            Если же это личный кабинет, дашборд, с максимум каким-то сайдбаром сбоку — то роутинг избыточен. Повторюсь, если ранее в требованиях его не было.
                                              0
                                              Если же это личный кабинет

                                              Сложный личный кабинет подразумевает routing


                                              с максимум каким-то сайдбаром сбоку

                                              Если sidebar содержит "разделы", то это автоматически подразумевает роутинг.


                                              Если его не было в требованиях, то требования были сформулированы плохо.


                                              На самом деле сложно найти SPA размером больше 5-10 тысяч строк, где не был бы нужен хотя бы элементарный routing. Это уже скорее не SPA даже.

                                            +1

                                            Не могу не добавить, что когда важный и сложный SPA написан без routing-а хочется убивать. Это страшно неудобно. Такое ощущение, что такие приложения пишут люди, которые всё остальное время пишут мобильные приложения и просто не понимают, что такое URL

                                      0

                                      Адрес в адресной строке — это как информация от юзера, так и информация от приложения. По сути это controlled input.

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое