spa-hash-router
Version:
Simple hash router for Single Page Application (SPA)
482 lines (408 loc) • 12.6 kB
JavaScript
/*
* #Router
*
* Copyright (c) 2016-2018 Valerii Zinchenko
* Licensed under MIT (https://gitlab.com/valerii-zinchenko/spa-hash-router/blob/master/LICENSE.txt).
* All source files are available at: http://gitlab.com/valerii-zinchenko/spa-hash-router
*/
/**
* @file #Router - simple hash router.
*
* @see HashRouter
*
* @author Valerii Zinchenko
*/
'use strict';
var _objToString = Object.prototype.toString;
function _is(toTest, type) {
return _objToString.call(toTest) === '[object ' + type + ']';
}
/**
* #Router - simple hash router.
*
* It behaves as singleton.
* Route can be defined by using named parts like ":name", or by using RegExp with captured groups like "(\\w+)". Remember to always escape a "\" character and use round braces to capture variable parts of a route.
* Please note, that appropriate methods will be executed only on first matched route. So plan your routes by importance in descending order.
*
* @throws {Error} "new" operator is required
*
* @constructor
* @param {Object} [options] - Router options
* @param {String} [options.prefix] - Prefix for all routes
* @param {String} [options.fallbackRoute] - Fallback route. It will be used for cases when the visiting history is empty and application tries to go back.
* @param {Function} [options.before] - Function that will be called [before]{@link HashRouter#_before} execution of all connected handlers.
* @param {Function} [options.after] - Function that will be called [after]{@link HashRouter#_after} execution of all connected handlers.
* @param {Object} [options.routes] - Object where the property name will be interpreted as a route name and its value (function or an array of functions) as route handler.
* @param {Object} [options.map] - Object where property name will be interpreted as route name and its value should be a method name or an array of method names of some context. This is used only in combination with options.context.
* @param {Object} [options.context] - Context. It is used only in combination with options.map.
*/
function HashRouter(options) {
if (!this) {
throw new Error('"new" operator is required');
}
if (HashRouter.instance !== null) {
return HashRouter.instance;
}
if (_is(options, 'Object')) {
if (_is(options.prefix, 'String')) {
this._prefix = options.prefix;
}
if (_is(options.fallbackRoute, 'String')) {
this._fallbackRoute = options.fallbackRoute;
}
if (_is(options.before, 'Function')) {
this._before = options.before;
}
if (_is(options.after, 'Function')) {
this._after = options.after;
}
if (_is(options.routes, 'Object')) {
this.add(options.routes);
}
if (_is(options.map, 'Object') && _is(options.context, 'Object')) {
this.addContextualMap(options.map, options.context);
}
}
HashRouter.instance = this;
};
HashRouter.instance = null;
/**
* RegExp to match the hash from the URL.
*
* @type {RegExp}
*/
HashRouter.prototype.hashRegExp = /(?:(\#.*)(?=\?))|(\#.*)/;
/**
* RegExp to match the hard (URL search query) and soft (query after fragment) query from the URL.
*
* @type {RegExp}
*/
HashRouter.prototype.queryRegExp = /(\?.*)(?=\#)(?:\#.*(\?.*))?|(\?.*)+?/;
/**
* Map of converted routes in RegExp and array of method names.
*
* @example
* {/^#user/([\w\-\+\.]+)$/: ['load', 'display']}
*
* @type {Object}
*/
HashRouter.prototype._routingMap = {};
/**
* Prefix for all routes.
*
* @type {Object}
*/
HashRouter.prototype._prefix = '';
/**
* Fallback route. It is used when visiting history doesn't have any items.
*
* @type {String}
*/
HashRouter.prototype._fallbackRoute = '';
/**
* Array of visited routes.
*
* @type {Array}
*/
HashRouter.prototype._history = [];
/**
* Function that will be called by all hash changes before all connected handlers.
*
* @since 2.0.0
*
* @type {Function}
*/
HashRouter.prototype._before = function(){};
/**
* Function that will be called by all hash changes after all connected handlers.
*
* @since 2.0.0
*
* @type {Function}
*/
HashRouter.prototype._after = function(){};
/**
* Add new route and methods, or add new methods for already existing route.
*
* @since 2.0.0
*
* @example
* add("user/:id", function(){})
* @example
* add("user/:id", [function(){}, function(){}])
* @example
* add({
* "user/:id": [function(){}, function(){}],
* "info": function(){}
* })
*
* @throws {Error} Incorrect amount of input arguments. Expected 1 or 2, but got {current}
* @throws {Error} Incorrect type of single argument. Expected "Object", but got {current}
* @throws {Error} Incorrect type of "route". Expected "String", but got {current}
* @throws {Error} Incorrect type of "methods". Expected "Array" or "Functions", but got {current}
*
* @param {Object | String} route - (1) Map of routes and methods. In this case the supplied object will be unwrapped to the form (2). (2) Route name.
* @param {Array | Function} [methods] - (1) not used. (2) Method or an array of methods.
*/
HashRouter.prototype.add = function(route, methods) {
var Nargs = arguments.length;
switch (Nargs) {
case 1:
if (!_is(route, 'Object')) {
throw new Error('Incorrect type of the single argument. Expected "Object", but got ' + _objToString.call(route));
}
for (var key in route) {
this.add(key, route[key]);
}
return;
case 2:
if (!_is(route, 'String')) {
throw new Error('Incorrect type of "route". Expected "String", but got ' + _objToString.call(route));
}
if (!_is(methods, 'Array')) {
if (_is(methods, 'Function')) {
methods = [methods];
} else {
throw new Error('Incorrect type of "methods". Expected "Array" or "Function", but got ' + _objToString.call(methods));
}
}
break;
default:
throw new Error('Incorrect amount of input arguments. Expected 1 or 2, but got ' + Nargs);
}
if (!this._routingMap[route]) {
this._routingMap[route] = {
regExp: new RegExp(this._preprocessRouteName(route, this._prefix)),
methods: []
};
}
var array = this._routingMap[route].methods;
for (var n = 0, N = methods.length; n < N; n++) {
array.push(methods[n]);
}
};
/**
* Add a contextual map of routes and handlers.
* First input argument defines the map, where the object property name will be transformed into route pattern and a property value is an array of method names, or simple the method name, of the context (second input argument).
*
* @since 2.0.0
*
* @example
* addContextualMap({
* "user/:id": ["load", "display"]
* }, {
* load: function(){},
* display: function(){}
* })
* @example
* addContextualMap({
* "user/(\\w+)": ["load", "display"],
* "(.*)": "404"
* }, {
* load: function(){},
* display: function(){},
* '404': function(){}
* })
*
* @throws {Error} Incorrect amount of input arguments. Expected 2, but got {current}
* @throws {Error} Incorrect type of "routingMap". Expected "Object", but got {current}
*
* @param {Object} routingMap - Object, where the property name will be used as route and value as handler for it.
* @param {Object} context - Context of added handlers.
*/
HashRouter.prototype.addContextualMap = function(routingMap, context) {
var Nargs = arguments.length;
if (Nargs !== 2) {
throw new Error('Incorrect amount of input arguments. Expected 2, but got ' + Nargs);
}
if (!_is(routingMap, 'Object')) {
throw new Error('Incorrect type of "routingMap". Expected "Object", but got ' + _objToString.call(routingMap));
}
var newMap = {};
var methods;
var n, N;
for (var route in routingMap) {
newMap[route] = [];
// convert single method name into an array
if (_is(routingMap[route], 'String')) {
routingMap[route] = [routingMap[route]];
}
// convert input argument into the map, that is acceptable by add()
methods = routingMap[route].forEach(function(method) {
var ref = context[method];
if (ref && _is(ref, 'Function')) {
newMap[route].push(ref.bind(context));
}
});
}
this.add(newMap);
};
/**
* Start the router.
*
* @param {String} [route] - Start point for router.
*/
HashRouter.prototype.start = function(route) {
window.addEventListener('hashchange', this.onHashChange.bind(this));
if (route) {
this._hashChange(this._getHash(route), this._getQuery(route));
}
},
/**
* Listener of "hashchange" event
*
* @param {Event} ev - Event object
*/
HashRouter.prototype.onHashChange = function(ev) {
this._hashChange(this._getHash(ev.newURL), this._getQuery(ev.newURL));
};
/**
* Route to the new path.
*
* @param {String} route - New route.
* @param {String} [queries] - Optional parameters.
*/
HashRouter.prototype.routeTo = function(route, queries) {
if (_is(queries, 'Object')) {
route += this.object2Query(queries);
}
location.hash = route;
};
/**
* Go back;
*/
HashRouter.prototype.back = function() {
this._history.pop();
if (this._history.length === 0 && this._fallbackRoute) {
this.routeTo(this._prefix + this._fallbackRoute);
return;
}
window.history.back();
};
/**
* Execute appropriate method(s) by changing hash to the new route.
*
* @param {String} hash - New route (should begin with '#').
* @param {String | Object} [queries] - Optional parameters (should begin with '?').
*/
HashRouter.prototype._hashChange = function(route, queries) {
var args;
var methods;
var map;
for (var routeMap in this._routingMap) {
map = this._routingMap[routeMap];
if (map.regExp.test(route)) {
args = map.regExp.exec(route);
methods = map.methods;
break;
}
}
if (!methods || methods.length === 0) {
// redirect to fallback route if non-registered route is trying to be accessed right at the start of the router
if (this._history.length === 0) {
this.routeTo(this._prefix + this._fallbackRoute);
}
return;
}
// Detect if user moves backward by using browser back button.
if (this._history[this._history.length-2] === route) {
this._history.pop();
} else {
this._history.push(route);
}
args = args.slice(1);
args.push(this.query2Object(queries));
this._before();
methods.forEach(function(method) {
method.apply(null, args);
});
this._after();
};
/**
* Convert query string into object.
* If query string is empty then an empty object will be returned.
*
* @example
* "?key1=value1&key2=value2" --> {"key1": "value1", "key2": "value2"}
* "?key1=value1;key2=value2" --> {"key1": "value1", "key2": "value2"}
*
* @param {String} query - Query string.
* @return {Object}
*/
HashRouter.prototype.query2Object = function(query) {
if (!query || query[0] !== '?' || !query[1]) {
return {};
}
// remove "?", "?&" "?;" at the beginning and "&" or ";" at the end
query = query.replace(/\?(?!&|;)|\?&|\?;|&$|;$/g, '');
query = query.replace(/=/g, '":"');
query = query.replace(/&|;/g, '","');
query = '{"' + query + '"}';
return JSON.parse(query);
};
/**
* Convert object into query string.
* If object is empty then an empty string willl be returned.
*
* @example
* {"key1": "value1", "key2": "value2"} --> "?key1=value1;key2=value2"
*
* @param {String} obj - Object that will be converted into query string.
* @return {String}
*/
HashRouter.prototype.object2Query = function(obj) {
var queries = [];
var value;
for (var key in obj) {
value = obj[key];
if (value) {
queries.push(key + '=' + value);
}
};
return queries.length === 0 ? '' : '?' + queries.join(';');
};
/**
* Convert human readable route like "path/:part" into RegExp understandable part "path/(\w+)"
* "#" symbol and prefix, if defined, will be automatically added at the beginning of each route pattern.
*
* @param {String} route - Route, that should be converted
* @return {String} RegExp understandable pattern
*/
HashRouter.prototype._preprocessRouteName = function(route, prefix) {
if (prefix) {
prefix = '(?:' + prefix + ')?';
} else {
prefix = '';
}
route = route.replace(/\:[\w\-\.]+/g, '([\\w\\-\\.]+\/?)');
return '^#' + prefix + route + '/?$';
};
/**
* Get hash part of the URL.
*
* @param {String} url - URL.
* @return {String}
*/
HashRouter.prototype._getHash = function(url) {
var match = this.hashRegExp.exec(url) || [];
return match[1] || match[2] || '#';
};
/**
* Get query part of the URL.
*
* @param {String} url - URL.
* @return {String | undefined}
*/
HashRouter.prototype._getQuery = function(url) {
var result;
var match = this.queryRegExp.exec(url) || [];
if (match[1]) {
result = match[1];
if (match[2]) {
result += ';' + (match[2]).slice(1);
}
} else if (match[3]) {
result = match[3];
}
return result;
};