Простые одностраничные приложения, основанные на 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.
Благодарю за внимание.
IonianWind
В чём профит от такого объявления метода класса?
Чем это луше обычного объявления?
AriesUa
Это не лучше, но и не хуже. Автор просто не любит явный код.
Данный код
После компиляции тайпскриптом или бабелем становится таким
Это что бы можно было передавать метод в setTimeout/setInterval не прибегая к bind или явному указанию стрелочной функции. Так же минус этого подхода в том, что это это не метод как таковой, а проперти инстанса класса. Наследование работать не будет.
Лично я предпочитаю явный код
kubk
Ну и хорошо, что наследование не будет работать, вместо него лучше композиция: en.wikipedia.org/wiki/Composition_over_inheritance
Необходимость помнить о потере this — лишняя когнитивная нагрузка, а бесконечный поток статей про контекст в JS тому подтверждение.
faiwer
Но зачем тогда вам вообще
class
-ы?MaZaAa
А разве обязательное условие использования классов это наследование? И если в классах использовать композиции, то это фу-фу-фу и работать не будет, поддерживать нереально и т.д. и т.п.?
Я тоже предпочитаю в классах композиции вместо наследования и ни кто и ни что при этом не страдает, а только получают преимущества.
faiwer
Ну дык вопрос тот же. Зачем вам классы? Хотя кажется я знаю ответ — декораторы.
P.S. ещё вспомнил, в nest JS за счёт классов пытаются runtime type-checking делать.
MaZaAa
Правильно, декораторы вещь крутая и мощная, поэтому можно совмещать преимущества со всех фронтов и ни в чем себе не отказывать)
justboris
Что значит явный/неявный? Class properties уже поддерживаются и в Chrome, и в Firefox, конвертировать совсем не обязательно.
И интересно, почему наследование работать не будет? Конструктор родителя вызовется первым, все нормально переопределится
CoolWolf
(ворчание) А потом они удивляются, почему вкладка с todo-list app съедает пару сотен мегабайт оперативки...Это не наследование, а запись методов напрямую в экземпляр. От того, что вы через родительский конструктор запишите эти методы в экземпляр дочернего класса никакое наследование не появится.
В JS наследование реализуется через прототипы, а не методы в class properties
Вот такой код
фактически означает вот это
А вот такой код
фактически означает
faiwer
Come on… Вы сами себя обманываете. Следите за руками:
Где вы видите экономию сотен MiB памяти?
Если по итогу вы в любом случае создаёте замыкание или bind-метод, то это больше уже вопрос о вкусах, нежели о какой-либо разницы в производительности.
В защиту prototype могу лишь сказать, что если тестировать код с подменой метода в прототипе, то это куда проще сделать, когда метод есть сразу, а не создаётся в конструкторе, т.к. не существует способов переписать конструктор (поправьте меня если я не прав).
CoolWolf
В варианте 2 память выделяется при вызове метода, в котором лежит setInterval, и освобождается после завершения работы, а в других вариантах этого не происходит.
Если у вас всего один экземпляр класса, то вы не заметите разницу. Только зачем вам тогда вообще классы?
А вот если экземпляров много, то случае с прототипным наследованием методы хранятся в прототипе, а в случае с хранением их в экземплярах — они дублируются для каждого экземпляра.
faiwer
В данном случае время жизни интервала равно времени жизни router-а. Память не высвобождается никогда (пока жива страница).
Применимо же в некому абстрактному случаю в вакууме, вы можете получить выигрыш по памяти тогда, когда время жизни вашего callback-а заведомо меньше времени жизни объекта владельца. Но стоит отметить что и проиграть вы можете там же, если, скажем, ваш метод может использоваться сразу в нескольких местах, но вы пересоздаёте его всякий раз заново. В общем тут нужно смотреть конкретный случай под микроскопом.
Сразу отмечу, что весь этот спор обретает хоть какой-либо смысл только тогда, когда у вас большие объёмы. Скажем это ядро какой-нибудь сетевой игры, или скажем недра реактивного-фреймворка, или некий html-шаблонизатор. Если речь не идёт о десятках тысячах объектов, то это последнее место, в котором стоит обсуждать вопрос памяти.
justboris
А практическая разница от этих отличий какая? Наследники перекрывают свойства у родителей, что еще нам нужно?
Патчинг свойств у прототипа для тестирования, как показывает faiwer – это анти-паттерн, такого кода лучше избегать.
faiwer
Мне кажется тестирование это в целом кладезь самых разных антипаттернов в одном месте. Все эти stub-ы, mock-и и пр. Особенно в динамически типизированных языках.
CoolWolf
Функции — точно такой же объект, как любой другой. Их даже создавать можно через конструктор new Function.
Если метод лежит в прототипе, то все потомки по prototype chain получают возможность использовать одну копию метода.
А если мы их пишем в сам объект в конструкторе, то память под методы выделяется при создании каждого экземпляра. Чем больше экземпляров, тем больше расход памяти.
justboris
Там уже выше faiwer показал, что в этом примере с таймерами, дополнительная копия метода создается в любом случае, как ни крути.
Понятное дело, если метод всегда вызывается с правильным this, то его можно объявить по-обычному, в прототипе. Мой основной вопрос был к этому предложению:
Обе конструкции одинаково явные, автор просто предпочел более компактный синтаксис, не более того.
faiwer
Подход с class-properties применимо в методам используется не всегда, а тогда, когда нужно за-bind-ить метод к instance-у его объекта. Тут та же зависимость: чем больше экземпляров, тем больше расход памяти.
ThisMan
Сразу биндится контекст выполнения
TheMrPonchik
Сохраняется контекст если передать метод по ссылке.
Обычное такое используют в обработчиках событий. Конкретно тут, да, это можно считать лишним.