Voie /vwa/ (fr. "way") is a simple router / layout manager for Vue.js. Use it to build SPAs of your dreams.
Current status: active development — any feedback is appreciated.
Simple example app is available on GitHub and live on Netlify.
Standalone bundles are also available, mostly for using with jsfiddle, jsbin, codepen, etc. (note, Vue.js is not included in bundles).
You should never use them in real development — use module bundlers instead.
Unlike official vue-router which is organized around URLs, Voie is organized around states. Voie-based apps are basically finite-state machines.
State is simply a named logical "place" within your application.
Each state can optionally have:
- URL pattern
- Vue component
- enter hook to populate state with data
- leave hook to cleanup things
States are organized into hierarchies: child states will inherit parameters and data
from parent state. Also, if child state has a component, then it will be rendered at the location
specified by parent (or nearest ancestor) state denoted by <v-view>
directive.
Consider this example:
app.add('user', {
path: '/user/:userId',
redirect: 'user.dashboard', // specify "default" sub-state
enter: (ctx) => { // can return a Promise
return fetch('/user/' + ctx.params.userId)
.then(res => res.json())
.then(data = ctx.data.user = data);
},
component: {
template: '<div class="user-layout"><v-view></v-view></div>'
}
});
app.add('user.dashboard', {
component: {
template: '<h1>Hello, {{ user.name }}!</h1>'
}
});
In this example visiting /user/123
would fetch a user with id 123
from a server
and then render following markup (assuming user has name "Alice"):
<div class="user-layout">
<h1>Hello, Alice!</h1>
</div>
Note: fragment instances
are not supported as components. In other words, make sure all components
contain a single top-level element without flow control directives (v-if
, v-for
, etc.)
Examples assume ES6 and build environment (browserify + babelify or webpack + babel-loader) which is a mainstream.
npm i --save voie
You also need an es6-shim to make everything go smooth in not-so-modern browsers.
Voie app is an instance of StateManager
. You provide it with el
, which
is an "entry-point" DOM node where views will be entered.
import { StateManager } from 'voie'
export default new StateManager({
el: '#app' // entry point, either a selector or HTMLElement
});
It is a good idea to expose state manager instance as a singleton module (i.e. single instance per application) since you will often want to use it in your Vue methods and stores.
Next thing you want to do is to register some states. Your app will probably contain plenty of states, so you'll need some structure. I prefer "domain-centric" directory structure:
// states.js
import './users';
import './groups';
// ...
// users/index.js
import app from '../app';
import UsersLayout from './layout.vue';
import UsersList from './list.vue';
app.add('users', {
component: UsersLayout
...
});
app.add('users.list', {
component: UsersList,
...
});
app.add('users.create', { ... });
app.add('user', { ... });
app.add('user.view', { ... });
app.add('user.edit', { ... });
app.add('user.delete', { ... });
// groups/index.js
import app from '../app';
app.add('groups', { ... });
// ...
Structuring apps is a matter of preference, so you are free to choose whatever suits you best.
Finally, run your state manager like this:
// index.js
import app from './app';
import './states';
app.start();
It will begin listening for history events and match-and-render current route.
States are automatically organized into a tree-like structure. Each state
will have a single parent state. "Root" states would have a null
parent
There are two ways of specifying a parent:
-
using dot character
.
in state name (e.g.user
->user.transaction
->user.transaction.details
) -
explicitly using
parent
configuration parameter:app.add('users', { ... }); app.add('user', { parent: 'users' });
Each way has its own advantages, so it's usually OK to use both styles in the same app.
Specifically, use qualified names to outline context (e.g. "User's profile page" would
be user.profile
) or entity-relationship (e.g. "User's transactions list would
be user.transactions
).
Specifying parent
is handy in cases when you want to preserve concise and clean state name
while being able to use a different layout or add some global "enter" hook on state subtree.
A typical example would be authentication/authorization:
app.add('root', { ... });
app.add('login', {
parent: 'root',
...
});
app.add('authenticated', {
parent: 'root',
enter: ctx => {
if (!UserService.isAuthenticated()) {
return { redirect: 'login' };
}
}
});
app.add('users', {
parent: 'authenticated',
...
});
app.add('groups', {
parent: 'authenticated',
...
});
Use stateManager.go
to navigate programmatically:
stateManager.go({
name: 'user.dashboard',
params: {
userId: '123'
}
});
In templates you can use v-link
directive with the same semantics:
<a v-link="{ name: 'user.dashboard', params: { userId: 123 } }">
Dashboard
</a>
In addition to invoking stateManager.go
it will also update the href
attribute and apply an active
class if current state "includes"
the state specified by link.
Active class name can be customised globally:
new StateManager({
el: '#el',
activeClass: 'highlighted'
});
State can optionally define enter
and leave
hooks which are functions
that accept state context object.
State context contains:
params
— a hash ofstring
parameters matched from URL pattern, specified explicitly viastateManager.go(...)
and inherited from parent contextdata
— object where you can write data to be exposed to Vue component and inherited statesstate
—State
object to which this context correspondsparent
— parent context of this object
Typical enter
hook will use params
to fetch or prepare some data and expose it
via data
object.
Both enter
and leave
can return a Promise
, which makes hooks asynchronous.
In case of enter
the component will only be entered when promise is resolved.
Example:
{
enter: (ctx) => UserService.findByEmail(ctx.params.email)
.then(user => ctx.data.user = user)
}
Additionally one can configure global beforeEach
and afterEach
hooks
that will be applied before enter
hooks and after leave
hook on each state
respectively.
Global hooks are configured on StateManager
:
new StateManager({
beforeEach(ctx) {
if (ctx.state.name === 'private') {
return { redirect: 'not_allowed' };
}
}
});
Enter can optionally redirect to another state by
returning (or resolving via promise) an object like this:
{ redirect: 'state.name' }
or
{ redirect: { name: 'state.name', params: {} }
.
When redirect
is returned by enter
hook the transition will always redirect
whenever it enters specified state (even if this state was not a destination).
Redirect can also be specified at state configuration level:
app.add('users', {
redirect: 'users.list',
// or with params
redirect: {
name: 'users.list',
params: { sort: '+name' }
},
// or even function Transition => Promise(stateName)
redirect: (transition) => {
transition.params.sort = '+name';
return Promise.resolve('users.list');
}
});
When redirect
is specified as state configuration option it will
only be effective when moving specifically to this state (in other words,
no redirect occurs when transitioning through this state to another one).
Consider following components hierarchy:
A
/ \
B D
| |
C E
Going from C to E implies:
- leaving state C
- leaving state B
- entering state D
- entering state E
By "leaving" we mean:
- executing
leave
hook - destroying Vue component, if any
- restoring the original state of
<v-view>
element where the component was rendered
By "entering" we mean:
- preparing new context
- executing
enter
hook - rendering Vue component, if any
- preserving the original state of
<v-view>
so that it could later be restored
Each state has a specification of parameters it can accept when entered.
Mandatory parameters (e.g. userId
for state user
) are classically specified
in pathname (e.g. /user/28
).
Optional parameters (e.g. page
, limit
for lists) are usually specified
in querystring (e.g. /users?page=5&limit=100
).
Here's how you define both parameter types when registering states:
app.add('user', {
path: '/user/:userId', // userId param is mandatory
params: {
section: null, // these are optional
collapsed: false // with optional default values
}
});
Both querystring and pathname parameters are accessible in ctx.params
object
which is exposed both to enter
hook and components.
Example:
location.href = '/user/123?section=profile&collapsed=true';
app.context.params
// { userId: '123', section: 'profile', collapsed: 'true' }
Note: Voie doesn't do any type conversion on params, so they are returned as strings.
When navigating between states specify parameters in go
(or v-link
):
app.go({
name: 'user',
params: {
userId: '123',
section: 'profile',
unknown: 'wut?' // Important, this will be dropped!
}
});
Note: params must be listed explicitly when registering states,
all other parameters will be dropped. In the example above
parameter unknown
is not specified in path
or params
of user
state (or its ancestors),
so it's not part of user
state spec and, therefore, will not be accessible in ctx.params
.
A common need for any web application is to update browser URL upon navigating to a state with URL mapping, so that when the user presses "Refresh" application loads the most recent state (not the "start" screen). Decent SPAs would also have Back/Forward buttons working as expected. We refer to these features as "history support".
Now there's two ways of implementing history support in your application:
-
hash (uncool, but fairly simple) — state will be maintained using hash portions of URL (e.g.
https://myapp/#user/1/transactions
) -
HTML5 (cool, a bit more complex) — state will be maintained using pathname portion of URL (e.g.
https://myapp/user/1/transactions
)
HTML5 history requires server-side setup: server must reply with the same HTML wrapper to all URLs used by your application.
Here's an example Express server:
import express from 'express';
let app = express();
// Allow using static resources with `/static` prefix
app.use('/static', express.static('static'));
// Serve application data with `/~` prefix
app.use('/~', appDataRouter);
// Serve SPA entry-point HTML to all other routes
app.get('/*', (req, res) => res.sendFile('app.html'))
This example shows potential gotchas:
since app.html
will be served for all GET requests, in order
to serve other resources (e.g. static or compiled assets, scripts,
stylesheets, application data, etc.) you'll need separate prefixes.
Without these prefixes it won't be easy to configure your front web server
(nginx or Apache) for production.
Voie uses awesome history to provide apps with history support. HTML5 mode is used by default.
Here's how to switch to hash-history (you need to install history
):
// app.js
import { StateManager } from 'voie';
import { createHashHistory } from 'history';
export default new StateManager({
el: '#app',
history: createHashHistory()
});
See history docs for more options.
It's a bit easier to setup servers using "base" URL for your app, e.g.:
app.get('/app*', (req, res) => res.sendFile('app.html'))
In this case all you need to do is to add <base href="/app"/>
inside the <head>
of your HTML wrapper.
Or you can just specify base
configuration parameter (it's only used
when history
is not present):
// app.js
import { StateManager } from 'voie';
export default new StateManager({
el: '#app',
base: '/app'
});
Voie is in active development, so more docs are coming soon.
Copyright (c) 2015-2016, Boris Okunskiy boris@okunskiy.name
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.