Hierarchical router library for single page applications.
Content
Introduction
What is Abyssa?
Abyssa is a stateful, hierarchical client side router.
What does stateful mean? It means all states are not equal and abyssa knows how to go from one state to another efficiently.
Abyssa does only one thing: Routing.
Upon entering a state, it can be rendered using any technique: Direct DOM manipulation, client or server side templating, with the help of a binding library, etc.
A state can even be abstract and not render anything.
Abyssa is versatile
Abyssa can be used like a traditional stateless url -> callback router:
var show = { enter: articleEnter };
var edit = { enter: articleEditEnter };
Router({
article: State('articles/:id', show),
articleEdit: State('articles/:id/edit', edit)
})
.init();
Or we can leverage abyssa's state machine nature and nest states when it serves us:
var article = { enter: loadArticle };
var show = { enter: articleEnter, exit: articleExit };
var edit = { enter: articleEditEnter, exit: articleEditExit };
Router({
article: State('articles/:id', article, {
show: State('', show),
edit: State('edit', edit)
})
})
.init();
Now we can freely switch between viewing and editing an article without any pause because the article data is loaded in the parent state and can be shared in the child states.
Abyssa is performant
What is the main advantage of stateful routers? Performance: Less redraws, less wasteful data loading, less wasteful setUp logic, etc.
When going from a state A to a state B, as far as a stateless router is concerned, everything has to be done from scratch even if the two states are closely related. Trying to optimize state transitions by hand is going to be awkward and lead to an explosion of custom state variables. On the other hand, abyssa make it simple to reason about what makes each state different and thus compute the minimum set of changes needed to transition from state A to state B.
Here, Abyssa will simply swap the red bit for the green bit. Why should everything be redrawn? It's slower and the software would lose all the state implicitly stored in the previous DOM.
Read this excellent blog post for more information: Make the most of your routes
Note: With the emergence of VDOM approaches, using abyssa as a stateful router has less of an impact, as VDOM diffing/patching will usually take care of good enough performances. Also, a component based view library can handle hierarchical data loading and caching.
Installation
Using abyssa as a commonJS/browserify module
npm install abyssa
...
var Router = require('abyssa').Router;
Using abyssa as a global
Use one of the provided prebuilt files in the target folder.
Transitions
Example
- There can be several root states. The router doesn't enforce the use of a single, top level state like some state machine implementations do.
The transition from the state A1
to the state B
would consist of the following steps:
A1 exit -> PA exit -> B enter
API
Router
configure (options: Object): Router
Configure the router before its initialization.
The available options are:
- enableLogs: Whether (debug and error) console logs should be enabled. Defaults to false.
- interceptAnchors: Whether anchor mousedown/clicks should be intercepted and trigger a state change. Defaults to true.
- notFound: The State to enter when no state matching the current path query or name could be found. This is a string representing the fullName of an existing state. Defaults to null.
- urlSync: How the router state and the URL should be kept in sync. Defaults to 'history'. Possible values are:
- 'history': The router uses the history pushState API.
- 'hash': The router uses the hash part of the URL for all browsers.
- hashPrefix: Customize the hash separator. Set to '!' in order to have a hashbang like '/#!/'. Defaults to empty string.
init (initState: String, initParams: Object): Router
Initialize the router.
The router will immediately initiate a transition to, in order of priority:
- The init state passed as an argument (mostly useful for testing and debugging)
- The state captured by the current URL
addState (name: String, state: Object): Router
Add a new root state to the router.
Returns the router to allow chaining.
The state Object is a simple POJO. See State
transitionTo (stateName: String, params: Object, acc: Object): void
transitionTo (pathQuery: String, acc: Object): void
Request a programmatic, synchronous state change.
While you can change state programmatically, the more idiomatic way to do it is sometimes using anchor tags with the proper href.
Two notations are supported:
transitionTo('my.target.state', { id: 33, filter: 'desc' })
transitionTo('target/33?filter=desc')
The acc
parameter can be used to specify an object that will be passed up then down every state involved in the transition.
It can be used to share information from a state with the subsequent states.
backTo (stateName: String, defaultParams: Object, acc: Object): void
Attempt to navigate to 'stateName' with its previous params or
fallback to the defaultParams parameter if the state was never entered.
link (stateName: String, params: Object): String
Compute a link that can be used in anchors' href attributes
from a state name and a list of params, a.k.a reverse routing.
Returns the previous state of the router or null if the router is still in its initial state.
Returns the current state of the router.
findState(optionsOrFullName): State
Returns the state object that was built with the given options Object or that has the given fullName String.
Returns undefined if the state doesn't exist.
isFirstTransition(): Boolean
Returns whether the router is executing its first transition.
replaceParams(params: Object): void
Replaces the current state's params in the history with new params.
Note: replaceParams
only works with urlSync
= history
.
The state is NOT exited/re-entered. That means you must store this params state outside the router to know
what to render. This functionality is useful when some url changes shouldn't re-render the whole application, nor create a separate entry in the browser history. (ex: scroll position, active filters, whether a popup is visible)
paramsDiff(): Object
Returns the diff between the current params and the previous ones
var diff = router.paramsDiff();
{
update: {
id: true
},
enter: {
q: true
},
exit: {
section: true
},
all: {
id: true,
q: true,
section: true
}
}
The paramsDiff is also accessible from the current state.
Events
All event handlers receive the current state and the old state as arguments (of type StateWithParams).
router.on('started', handler)
router.on('ended', handler)
To remove the event handler, attach a null/undefined callback.
State
Basics
States are simple POJOs used to build the router and represent path segments of an url (indeed, the router only matches routes against states' paths).
A state can also own a list of query params: While all states will be able to read these params, isolated changes to these
will only trigger a transition up to the state owning them (it will be exited and re-entered). The same applies to dynamic query params.
How much you decompose your applications into states is completely up to you.
Properties
A state is really just an object with an uri
property. Optionally, the following properties can be specified:
enter
, exit
, update
, data
, children
.
uri: String
The path segment this state owns. Can also contain a query string. Ex: uri: 'articles/:id?filter'
enter (params: Object, value: Any, router: Router): void
Specify a function that should be called when the state is entered.
The params are the dynamic params (path and query alike in one object) of the current url.
This is where you could render the data into the DOM or do some general work once for many child states.
exit (params: Object, value: Any, router: Router): void
Same as the enter function but called when the state is exited.
This is where you could teardown any state or side effects introduced by the enter function, if needed.
update (params: Object, value: Any, router: Router): void
The update callback is called when the router is moving to the same state as the current state, but with different path/query params.
Specifying an update callback can be seen as an optimization preventing doing wasteful work in exit/enter, e.g removing and adding the same DOM elements that were already present in the document before the state change.
var router = router({
people: State('people/:id', {
enter: function() {},
update: function() {},
exit: function() {},
})
}).init('people/33');
During init, enter
will be called.
Later, if the router transitions from 'people/33' to 'people/44', only update
will be called. If an update
callback wasn't specified,
exit
then enter
would have been called in succession.
data: Object
Custom data properties can be specified declaratively when building the state.
children: Object
A map of child names to states.
Usage examples
Construction
Given a state represented by the path "articles", with a child state named "item" represented by the dynamic path "id".
When the router is in the state "articles.item" with the id param equal to 33, the browser url is http://yourdomain/articles/33.
There are at least 3 ways to build such a router; It is advised to build the router centrally, even if the state definitions are
located in their own modules.
Using pojos
var router = Router({
articles: {
uri: 'articles',
children: {
item: {
uri: ':id'
}
}
}
}).init();
Or using the State
factory shorthand:
var router = Router({
articles: State('articles', {}, {
item: State(':id', {})
}).init();
Or using the imperative form:
var router = Router();
var articles = State('articles');
articles.children.item = State(':id');
router.addState(articles);
router.init();
Pathless states
A state represented by the path "articles" with a path-less child state named "show"
When the router is in the state "articles.show", the browser url is http://yourdomain/articles
var state = State('articles', {}, {
show: State('')
});
router.addState('articles', state);
Query strings
Now the articles state also tells us it owns the query param named 'filter' in its state hierarchy.
This means that any isolated change to the filter query param (meaning the filter was added, removed or changed but the path remained the same) is going to make that state exit and re-enter so that it can process the new filter value. If you do not specify which state owns the query param, all states above the currently selected state are exited and reentered, which can be less efficient. Also, Enumerating the possible query strings is mandatory if you want these to appear when using reverse routing or name-based state changes.
var state = State('articles?filter', {}, {
show: State('')
});
Rest segments
Additionaly, the last path segment of a state can end with a *
to match any number of extra path segments:
State('path/:rest*')
StateWithParams
StateWithParams
objects are returned from router.previous()
, router.current()
and passed in event handlers.
uri: String
The current uri associated with this state
params: Object
The path and query params set for this state
name: String
The (local) name of the state
fullName: String
The fully qualified, unique name of the state
isIn(fullName: String): Boolean
Returns whether this state or any of its parents has the given fullName.
Anchor interception
By default, the router will intercept anchor clicks and automatically navigate to a state if some conditions are met (left button pressed, href on the same domain, etc).
This behavior can be turned off by using the corresponding router configuration setting
You may want to turn off anchor interception on mobile optimised apps and perform manual router.transitionTo() calls on touch/pointer events.
You can also intercept mousedown events instead of the usual click events by using a data-attribute as follow:
<a data-nav="mousedown" href="/">
If a same-domain link should not be intercepted by Abyssa, you can use:
<a data-nav="ignore" href="/">
Code examples
Demo app
Demo: Abyssa demo async
Source: Abyssa demo async source
Abyssa + React
JSFiddle
Cookbook
Removing router <-> state circular dependencies
States must be added to the router but states also often need to call methods on the router, for instance to create href links.
This creates circular dependencies which are annoying when using primitive module systems such as CommonJS'.
To break that circular dependency, simply require the api object instead of the router in your states:
var api = require('abyssa').api;
Central router, modular states
It is much easier to reason about an application and its routes if the various uris can be all be read in one place instead of being spread all over the code base. However, states should be modularized for the sake of easier maintenance and separation of concerns. Here's how it might be achieved with CommonJS modules:
var Router = require('abyssa').Router;
var State = require('abyssa').State;
var index = require('./index'),
articles = require('./articles'),
articlesDetail = require('./articles/detail'),
articlesDetailEdit = require('./articles/detailEdit');
Router({
index: State('', index),
articles: State('articles', articles, {
articlesDetail: State(':id/show', articlesDetail),
articlesDetailEdit: State(':id/edit', articlesDetailEdit),
})
}).init();
module.exports = {
enter: function() {
console.log('index entered');
},
exit: function() {
console.log('index exited');
}
};
Handling the change of some params differently in update
update
is an optional hook that will be called whenever the router moves to the same state but with updated path/query params.
However, not all params are equal: A change in the path param representing the resource id may induce more work than the change of some secondary query param.
Example of a conditional update:
var api = require('abyssa').api;
var state = State({
enter: function(params) {
loadResourceForId(params.id);
},
update: function(params) {
var diff = api.paramsDiff();
if (diff.update.id) {
loadResourceForId(params.id);
}
else {
filterInPlace(params);
}
}
});
Integrating with React
Check this gist to get a ReactState
that can be used to automatically insert React children based on routing: