Last active
November 15, 2018 00:28
-
-
Save klebba/fc5a75a72afa5544b25bb7e3ff3b14a2 to your computer and use it in GitHub Desktop.
A primitive pushState router
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export default class Router { | |
static init(routes, wildcard) { | |
Object.defineProperty(this, 'routes', { | |
value: routes, | |
writable: false, | |
}); | |
Object.defineProperty(this, 'patterns', { | |
value: this.build(routes), | |
writable: false, | |
}); | |
Object.defineProperty(this, 'wildcard', { | |
value: wildcard, | |
writable: false, | |
}); | |
window.addEventListener('popstate', this.onPopState.bind(this)); | |
document.addEventListener('click', this.onClick.bind(this)); | |
this.read(); | |
} | |
static onPopState() { | |
this.read(); | |
} | |
static onClick(evt) { | |
const path = evt.composedPath(); | |
const link = path.find(el => el.nodeName === 'A'); | |
if ( | |
evt.shiftKey === false && | |
evt.ctrlKey === false && | |
evt.metaKey === false && | |
this.processLink(link) | |
) { | |
evt.preventDefault(); | |
evt.stopPropagation(); | |
} | |
} | |
static read() { | |
const match = this.resolve(new URL(window.location)); | |
if (match) { | |
this.processRoute(match.route, match.params); | |
} else { | |
if (this.wildcard instanceof Function) { | |
this.processRoute(this.wildcard, new Map()); | |
} else { | |
throw new Error('No wildcard route defined.'); | |
} | |
} | |
} | |
static build(routes) { | |
const pattern = /:[^\s/]+/g; | |
return Array.from(routes.keys()).reduce((result, route) => { | |
// extract param names from the routes | |
const params = (route.match(pattern) || []).reduce((list, current) => { | |
const token = current.slice(1); | |
if (list.has(token)) { | |
throw new Error(`Ambiguous parameter name ${token}`); | |
} else { | |
return list.add(token); | |
} | |
}, new Set()); | |
// construct matching pattern | |
const test = route.replace(pattern, '([\\w-]+)'); | |
const regex = new RegExp(`^${test}$`); | |
return result.set(route, { regex, params }); | |
}, new Map()); | |
} | |
/** | |
* Search for a route definition that matches the provided url | |
*/ | |
static resolve(url) { | |
const patterns = Array.from(this.patterns.entries()); | |
const matches = patterns.reduce((result, [route, pattern]) => { | |
const match = url.pathname.match(pattern.regex); | |
if (Array.isArray(match)) { | |
const paramNames = Array.from(pattern.params); | |
const paramValues = match.slice(1); | |
const params = paramValues.reduce((list, param, index) => { | |
return list.set(paramNames[index], param); | |
}, new Map()); | |
result.set(route, params); | |
} | |
return result; | |
}, new Map()); | |
// prioritize literal matches | |
let result; | |
for (const [route, params] of matches.entries()) { | |
if (!result || result.params.size > params.size) { | |
const callback = this.routes.get(route); | |
result = { route: callback, params }; | |
} | |
} | |
return result; | |
} | |
/** | |
* Takes an <a> element as input and determines if it should change the location | |
*/ | |
static processLink(link) { | |
let processed = false; | |
if (link instanceof HTMLElement && link.href && !link.target) { | |
const from = new URL(window.location); | |
const dest = new URL(link); | |
// only inspect same-domain links | |
if (from.origin === dest.origin) { | |
const match = this.resolve(dest); | |
if (match) { | |
// publish new location | |
window.history.pushState({}, null, dest); | |
this.processRoute(match.route, match.params); | |
processed = true; | |
} | |
} | |
} | |
return processed; | |
} | |
static processRoute(route, params) { | |
if (route instanceof Function) { | |
const searchParams = new URLSearchParams(window.location.search); | |
route(params, searchParams, window.location.hash); | |
} | |
} | |
} |
import Router from 'x-router.js';
const routes = new Map();
const title = 'My Application';
routes.set('/', () => {
document.title = title;
});
routes.set('/page1', () => {
document.title = `Page 1 \u2022 ${title}`;
});
routes.set('/page2', () => {
document.title = `Page 2 \u2022 ${title}`;
});
routes.set('/page3/:bar/baz/:bazinga', (params) => {
document.title = `Page 3 (${params.get('bazinga')}) \u2022 ${title}`;
});
Router.init(routes, () => { console.error('No matching route.') });
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
As of this writing Firefox does not support
event.composedPath()
-- here's a Polyfill: