Перевод статьи A modern JavaScript router in 100 lines
Требования:
Роутер должен:
- содержать меньше 100 строк
- поддерживать url с хэшами
- работать с History API
- предоставлять простой API интерфейс
- запускаться вручную
- отрабатывать только в тех случаях когда это необходимо
Модуль синглтон
1 2 3 4 5 |
var Router = { routes: [], mode: null, root: '/' } |
- routes – хранит список зарегистрированных точек
- mode – может принимать значение hash или history в зависимости от поддержки History API
- root – корневая точка приложения, необходима только при использовании pushState
Настройки
Метод для первичной настройки принимает два параметра, можно передать их в одном объекте.
1 2 3 4 5 6 7 8 9 10 11 |
var Router = { routes: [], mode: null, root: '/', config: function(options) { this.mode = options && options.mode && options.mode == 'history' && !!(history.pushState) ? 'history' : 'hash'; this.root = options && options.root ? '/' + this.clearSlashes(options.root) + '/' : '/'; return this; } } |
Режим ‘history’ будет включен только в том случае если передать необходимый параметр и при условии что браузер поддерживает pushState.
Разобрать текущий URL
Метод без которого соответственно ничего не заработает, отвечает за определение текущего адреса. Так как роутер работает в одном из двух режимов, то и обрабатывать url будем по разному.
1 2 3 4 5 6 7 8 9 10 11 12 |
getFragment: function() { var fragment = ''; if(this.mode === 'history') { fragment = this.clearSlashes(decodeURI(location.pathname + location.search)); fragment = fragment.replace(/\?(.*)$/, ''); fragment = this.root != '/' ? fragment.replace(this.root, '') : fragment; } else { var match = window.location.href.match(/#(.*)$/); fragment = match ? match[1] : ''; } return this.clearSlashes(fragment); } |
В обоих случаях определение URL происходит на основе глобального window.location. В режиме ‘history’, надо удалить корневую часть url. И все get параметры, при помощи регулярного выражения (/\?(.*)$/). Кроме того метод clearSlashes который удаляет слэши в начале и конце, для того чтобы унифицировать адрес, пользователь может вводить адрес со слэшем или без, мы всегда получим одинаковое значение.
1 2 3 |
clearSlashes: function(path) { return path.toString().replace(/\/$/, '').replace(/^\//, ''); } |
Добавление и удаление маршрутов
Пока я работал над AbsurdJS то всегда старался дать как можно больше свободы разработчикам. В большинстве js роутеров которые я встречал, сравнение работало на основании строки. но мне кажется применение регулярных выражений предоставляет намного больше возможностей.
1 2 3 4 5 6 7 8 |
add: function(re, handler) { if(typeof re == 'function') { handler = re; re = ''; } this.routes.push({ re: re, handler: handler}); return this; } |
Метод add наполняет массив маршрутов, путём передачи параметров: путь, обработчик. В случае если на вход add дан только один параметр и этот параметр функция, то считать эту функцию (колбэк) обработчиком корневого маршрута. Кстати, вы заметили, что почти все методы возвращают this, это позволяет строить цепочки вызовов.
1 2 3 4 5 6 7 8 9 |
remove: function(param) { for(var i=0, r; i<this.routes.length, r = this.routes[i]; i++) { if(r.handler === param || r.re.toString() === param.toString()) { this.routes.splice(i, 1); return this; } } return this; } |
Удаление маршрута произойдёт только в том случае, если передать точно такое же регулярное выражение или функцию обрабочик.
1 2 3 4 5 6 |
flush: function() { this.routes = []; this.mode = null; this.root = '/'; return this; } |
Если вдруг понадобится сбросить все настройки, для этого есть метод flush, который сбрасывает список маршрутов и режим работы.
Check-in
Итак, выше определены методы для выявления текущего адреса, добавления и удаления маршрутов. Следующим логическим шагом будет создание метода для сравнения текущего адреса и зарегистрированных маршрутов.
1 2 3 4 5 6 7 8 9 10 11 12 |
check: function(f) { var fragment = f || this.getFragment(); for(var i=0; i<this.routes.length; i++) { var match = fragment.match(this.routes[i].re); if(match) { match.shift(); this.routes[i].handler.apply({}, match); return this; } } return this; } |
Метод check в качестве параметра получает url, если параметр пуст, то при помощи getFragment, потом проходит по всем зарегистрированным маршрутам и сверяет с полученным фрагментом, при совпадении запускает callback передавая в него динамические части маршрута.
1 2 3 4 5 6 7 8 9 10 11 |
Router .add(/about/, function() { console.log('about'); }) .add(/products\/(.*)\/edit\/(.*)/, function() { console.log('products', arguments); }) .add(function() { console.log('default'); }) .check('/products/12/edit/22'); |
На выходе в консоли:
1 |
products ["12", "22"] |
Контроль изменений адреса
Так как нельзя постоянно находится в состоянии прослушки изменений, необходим какой-то триггер изменений в адресной строке, даже в том числе если пользователь нажал кнопку “назад”. В History API существует событие popstate, которое вызывает браузер при изменении адреса страницы, но некоторые браузеры вызывают это событие и при первичной загрузке сайта. Дабы сделать везде работу одинаковой мне пришлось придумать другой способ, использование setInterval.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
listen: function() { var self = this; var current = self.getFragment(); var fn = function() { if(current !== self.getFragment()) { current = self.getFragment(); self.check(current); } } clearInterval(this.interval); this.interval = setInterval(fn, 50); return this; } |
Храним последний активный url, чтобы сравнить с новым.
Changing the URL
Наконец роутеру недостаёт возможности самостоятельно менять адрес.
1 2 3 4 5 6 7 8 9 |
navigate: function(path) { path = path ? path : ''; if(this.mode === 'history') { history.pushState(null, null, this.root + this.clearSlashes(path)); } else { window.location.href = window.location.href.replace(/#(.*)$/, '') + '#' + path; } return this; } |
Ещё раз, если браузер поддерживает History API и вы выбран режим history, то можно использовать pushstate, иначе старый добрый друг window.location.
Полный скрипт и небольшой пример
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
var Router = { routes: [], mode: null, root: '/', config: function(options) { this.mode = options && options.mode && options.mode == 'history' && !!(history.pushState) ? 'history' : 'hash'; this.root = options && options.root ? '/' + this.clearSlashes(options.root) + '/' : '/'; return this; }, getFragment: function() { var fragment = ''; if(this.mode === 'history') { fragment = this.clearSlashes(decodeURI(location.pathname + location.search)); fragment = fragment.replace(/\?(.*)$/, ''); fragment = this.root != '/' ? fragment.replace(this.root, '') : fragment; } else { var match = window.location.href.match(/#(.*)$/); fragment = match ? match[1] : ''; } return this.clearSlashes(fragment); }, clearSlashes: function(path) { return path.toString().replace(/\/$/, '').replace(/^\//, ''); }, add: function(re, handler) { if(typeof re == 'function') { handler = re; re = ''; } this.routes.push({ re: re, handler: handler}); return this; }, remove: function(param) { for(var i=0, r; i<this.routes.length, r = this.routes[i]; i++) { if(r.handler === param || r.re.toString() === param.toString()) { this.routes.splice(i, 1); return this; } } return this; }, flush: function() { this.routes = []; this.mode = null; this.root = '/'; return this; }, check: function(f) { var fragment = f || this.getFragment(); for(var i=0; i<this.routes.length; i++) { var match = fragment.match(this.routes[i].re); if(match) { match.shift(); this.routes[i].handler.apply({}, match); return this; } } return this; }, listen: function() { var self = this; var current = self.getFragment(); var fn = function() { if(current !== self.getFragment()) { current = self.getFragment(); self.check(current); } } clearInterval(this.interval); this.interval = setInterval(fn, 50); return this; }, navigate: function(path) { path = path ? path : ''; if(this.mode === 'history') { history.pushState(null, null, this.root + this.clearSlashes(path)); } else { window.location.href = window.location.href.replace(/#(.*)$/, '') + '#' + path; } return this; } } // configuration Router.config({ mode: 'history'}); // returning the user to the initial state Router.navigate(); // adding routes Router .add(/about/, function() { console.log('about'); }) .add(/products\/(.*)\/edit\/(.*)/, function() { console.log('products', arguments); }) .add(function() { console.log('default'); }) .check('/products/12/edit/22').listen(); // forwarding Router.navigate('/about'); |