UNPKG

spa-hash-router

Version:

Simple hash router for Single Page Application (SPA)

482 lines (408 loc) 12.6 kB
/* * #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; };