This article is cross-posted in CSS-Tricks - https://css-tricks.com/build-a-custom-vue-router/
Plenty of tutorials exist that do a great job in explaining how Vue’s official routing library, [vue-router](https://router.vuejs.org/en/)
, can be integrated into an existing Vue application. vue-router
does a fantastic job by providing us with the items needed to map an application’s components to different browser URL routes.
But, simple applications often don’t need a fully fledged routing library like vue-router
. In this article, we'll build a simple custom client-side router with Vue. By doing so, we’ll gather an understanding of what needs to be handled to construct client-side routing as well as where potential shortcomings can exist.
Though this article assumes basic knowledge in Vue.js; we’ll be explaining things thoroughly as we start to write code!
First and foremost: let’s define routing for those who may be new to the concept.
In web development, routing often refers to splitting an application’s UIbased on rules derived from the browser URL. Imagine clicking a link and having the URL go from https://website.com
to https://website.com/article/
. That’s routing.
Routing is often categorized in two main buckets:
Client-side routing is where the term single-page application (or SPA for short) comes in. SPAs are web apps that load only once and are dynamically updated with user interaction without the need to make subsequent requests to the server. With routing in SPAs, JavaScript is the driving force that dynamically renders different UI.
Now that we have a brief understanding of client-side routing and SPAs, let’s get an overview of what we’ll be working on!
The app we aim to construct is a simple Pokémon app that displays details of a particular Pokémon based on the URL route.
The application will have three unique URL routes: /charizard
, /blastoise
, and /venusaur
. Based on the URL route entered, a different Pokémon will be shown:
In addition, footer links exist at the bottom of the application to direct the user to each respective route upon click:
For simple applications like this, we don’t necessarily need a client-side router to make our app functional. This particular app could be composed of a simple parent-child component hierarchy that uses Vue [props](https://vuejs.org/v2/guide/components.html#Props)
to dictate the information that should be displayed. Here’s a Pen that shows just this:
Though the app would functionally work, it misses a substantial feature that’s expected from most web applications — responding to browser navigation events. We’d want our Pokémon app to be accessible and to show different details for different pathnames: /charizard
, /blastoise
, and /venusaur
. This would allow users to refresh different pages and keep their location in the app, bookmark the URLs to come back to later, and potentially share the URL with others. These are some of the main benefits of creating routes within an application.
Now that we have an idea of what we’ll be working on, let’s start building!
The easiest way to follow along step-by-step (if you wish to do so) is to clone the GitHub repo I’ve set up:
When cloned, install the project dependencies with:
npm install
Let’s take a brief look within the project directory.
$ lsREADME.mdindex.htmlnode_modules/package.jsonpublic/src/static/webpack.config.js
There also exists the hidden files, .babelrc
and .gitignore
within the project scaffold.
This project is a simple webpack-configured application scaffolded with [vue-cli](https://github.com/vuejs-templates/webpack-simple)
, the Vue command line interface.
index.html
is where we declare the DOM element—#app
— with which we'll use to mount our Vue application:
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <link rel="stylesheet" href="
In the <head>
tag of the index.html
file, we introduce Bulma as our application’s CSS framework and our own styles.css
file that lives in the public/
folder.
Since our focus is on the usage of Vue.js, the application already has all the custom CSS laid out.
The src/
folder is where we’ll be working directly from:
$ ls src/app/main.js
src/main.js
represents the starting point of our Vue application. It’s where our Vue instance is instantiated, where we declare the parent component that is to be rendered, and the DOM element #app
with which our app is to be mounted to:
import Vue from 'vue';import App from './app/app';
new Vue({ el: '#app', render: h => h(App)});
We’re specifying the component, App
, from the src/app/app.js
file to be the main parent component of our application.
In the src/app
directory, there exists two other files - app-custom.js
and app-vue-router.js
:
$ ls src/app/app-custom.jsapp-vue-router.jsapp.js
app-custom.js
denotes the completed implementation of the application with a custom Vue router (i.e. what we’ll be building in this article). app-vue-router.js
is a completed routing implementation using the vue-router
library.
For the entire article, we’ll only be introducing code to the src/app/app.js
file. With that said, let’s take a look at the starting code within src/app/app.js
:
const CharizardCard = { name: 'charizard-card', template: ` <div class="card card--charizard has-text-weight-bold has-text-white"> <div class="card-image"> <div class="card-image-container"> <img src="../../static/charizard.png"/> </div> </div> <div class="card-content has-text-centered"> <div class="main"> <div class="title has-text-white">Charizard</div> <div class="hp">hp 78</div> </div> <div class="stats columns is-mobile"> <div class="column">🔥<br> <span class="tag is-warning">Type</span> </div> <div class="column center-column">199 lbs<br> <span class="tag is-warning">Weight</span> </div> <div class="column">1.7 m <br> <span class="tag is-warning">Height</span> </div> </div> </div> </div> `};
const App = { name: 'App', template: ` <div class="container"> <div class="pokemon"> <pokemon-card></pokemon-card> </div> </div> `, components: { 'pokemon-card': CharizardCard }};
export default App;
Currently, two components exist: CharizardCard
and App
. The CharizardCard
component is a simple template that displays details of the Charizard Pokémon. The App
component declares the CharizardCard
component in its components
property and renders it as <pokemon-card></pokemon-card>
within its template
.
We currently only have static content with which we’ll be able to see if we run our application:
npm run dev
And launch localhost:8080
:
To get things started, let’s introduce two new components: BlastoiseCard
and VenusaurCard
that contains details of the Blastoise and Venusaur Pokémon respectively. We can lay out these components right after CharizardCard
:
const CharizardCard = { // ... };const BlastoiseCard = { name: 'blastoise-card', template: ` <div class="card card--blastoise has-text-weight-bold has-text-white"> <div class="card-image"> <div class="card-image-container"> <img src="../../static/blastoise.png"/> </div> </div> <div class="card-content has-text-centered"> <div class="main"> <div class="title has-text-white">Blastoise</div> <div class="hp">hp 79</div> </div> <div class="stats columns is-mobile"> <div class="column">💧<br> <span class="tag is-light">Type</span> </div> <div class="column center-column">223 lbs<br> <span class="tag is-light">Weight</span> </div> <div class="column">1.6 m<br> <span class="tag is-light">Height</span> </div> </div> </div> </div> `};const VenusaurCard = { name: 'venusaur-card', template: ` <div class="card card--venusaur has-text-weight-bold has-text-white"> <div class="card-image"> <div class="card-image-container"> <img src="../../static/venusaur.png"/> </div> </div> <div class="card-content has-text-centered"> <div class="main"> <div class="title has-text-white">Venusaur</div> <div class="hp hp-venusaur">hp 80</div> </div> <div class="stats columns is-mobile"> <div class="column">🍃<br> <span class="tag is-danger">Type</span> </div> <div class="column center-column">220 lbs<br> <span class="tag is-danger">Weight</span> </div> <div class="column">2.0 m<br> <span class="tag is-danger">Height</span> </div> </div> </div> </div> `};const App = { // ... }export default App;
With our application components established, we can now begin to think how we’ll create routing between these components.
router-view
To establish routing, we’ll start by buiding a new component that holds the responsibility to render a specified component based on the app’s location. We’ll create this component in a constant variable named View
.
Before we create this component, let’s see how we might use it. In the template
of the App
component, we’ll remove the declaration of <pokemon-card>
and instead render the upcoming router-view
component. In the components
property; we’ll register the View
component constant as <router-view>
to be declared in the template.
const App = { name: 'App', template: ` <div class="container"> <div class="pokemon"> <router-view></router-view> </div> </div> `, components: { 'router-view': View }};
export default App;
The router-view
component will match the correct Pokémon component based on the URL route. This matching will be dictated in a routes
array that we’ll create. We’ll create this array right above the App
component:
const CharizardCard = { // ... };const BlastoiseCard = { // ... };const VenusaurCard = { // ... };
const routes = [ {path: '/', component: CharizardCard}, {path: '/charizard', component: CharizardCard}, {path: '/blastoise', component: BlastoiseCard}, {path: '/venusaur', component: VenusaurCard}];
const App = { // ... };
export default App;
We’ve set each Pokémon path to their own respective component (e.g. /blastoise
will render the BlastoiseCard
component). We’ve also set the root path /
to the CharizardCard
component.
Let’s now begin to create our router-view
component.
The router-view
component will essentially be a mounting point to dynamically switch between components. One way we can do this in Vue is by using the reserved <component>
element to establish Dynamic Components.
Let’s create a starting point for router-view
to get an understanding of how this works. As mentioned earlier; we’ll create router-view
within a constant variable named View
. So with that said, let’s set up View
right after our routes declaration:
const CharizardCard = { // ... };const BlastoiseCard = { // ... };const VenusaurCard = { // ... };const routes = [ // ...];const View = { name: 'router-view', template: `<component :is="currentView"></component>`, data() { return { currentView: CharizardCard } }}; const App = {// ... };export default App;
The reserved <component>
element will render whatever component the is
attribute is bound to. Above, we’ve attached the is
attribute to a currentView
data property that simply maps to the CharizardCard
component. As of now, our application resembles the starting point by displaying CharizardCard
regardless of what the URL route is.
Though router-view
is appropriately rendered within App
, it’s not currently dynamic. We need router-view
to display the correct component based on the URL pathname upon page load. To do this, we’ll use the created()
hook to filter the routes
array and return the component that has a path
that matches the URL path. This would make View
look something like this:
const View = { name: 'router-view', template: `<component :is="currentView"></component>`, data() { return { currentView: {} } }, created() { this.currentView = routes.find( route => route.path === window.location.pathname ).component; }};
In data
, we’re now instantiating currentView
with an empty object. In the created()
hook, we’re using JavaScript’s native find()
method to return the first object from routes
that matches route.path === window.location.pathname
. We can then get the component with object.component
(where object
is the returned object from find()
).
Inside a browser environment, window.location
is a special object containing the properties of the browser’s current location. We grab the pathname
from this object which is the path of the URL.
At this stage; we’ll be able to see the different Pokémon Card components based on the state of our browser URL!
The **BlastoiseCard**
component now renders at the **/blastoise**
route.
There’s something else we should consider. If a random URL pathname
is entered, our app will currently error and present nothing to the view.
To avoid this, let’s introduce a simple check to display a “Not Found” template if the URL pathname
doesn’t match any path
existing in the routes
array. We’ll separate out the find()
method to a component method named getRouteObject()
to avoid repetition. This updates the View
object to:
const View = { name: 'router-view', template: `<component :is="currentView"></component>`, data() { return { currentView: {} } }, created() { if (this.getRouteObject() === undefined) { this.currentView = { template: ` <h3 class="subtitle has-text-white"> Not Found :(. Pick a Pokémon from the list below! </h3> ` }; } else { this.currentView = this.getRouteObject().component; } }, methods: { getRouteObject() { return routes.find( route => route.path === window.location.pathname ); } }};
If the getRouteObject()
method returns undefined
, we display a "Not Found" template. If getRouteObject()
returns an object from routes
, we bind currentView
to the component of that object. Now if a random URL is entered, the user will be notified:
The “Not Found” view is rendered if the URL pathname
does not match any of the values in the routes array.
The “Not Found” template tells the user to pick a Pokémon from a list. This list will be the links we’ll create to allow the user to navigate to different URL routes.
Awesome! Our app is now responding to some external state, the location of the browser. router-view
determines which component should be displayed based on the app’s location. Now, we need to construct links that will change the location of the browser without making a web request. With the location updated, we want to re-render our Vue app and rely on router-view
to appropriately determine which component to render.
We’ll label these links as router-link
components.
router-link
In web interfaces, we use HTML <a>
tags to create links. What we want here is a special type of <a>
tag. When the user clicks on this tag, we’ll want the browser to skip its default routine of making a web request to fetch the next page. Instead, we just want to manually update the browser’s location.
Let’s compose a router-link
component that produces an <a>
tag with a special click
binding. When the user clicks on the router-link
component, we’ll use the browser’s history API to update the browser’s location.
Just like we did with router-view
, let’s see how we’ll use this component before we build it.
In the template of the App
component, let’s create three <router-link>
elements within a parent <div class="pokemon-links"></div>
element. Rather than using the href
attribute in <router-link>
, we’ll specify the desired location of the link using a to
attribute. We’ll also register the upcoming router-link
component (from a Link
constant variable) in the App
components
property:
const App = { name: 'App', template: ` <div class="container"> <div class="pokemon"> <router-view></router-view> <div class="pokemon-links has-text-centered"> <router-link to="/charizard"></router-link> <router-link to="/blastoise"></router-link> <router-link to="/venusaur"></router-link> </div> </div> </div> `, components: { 'router-view': View, 'router-link': Link }};
We’ll create the Link
object that represents router-link
right above the App
component. We’ve established the router-link
component should always be given a to
attribute (i.e. prop) that has a value of the target location. We can enforce this prop validation requirement like so:
const CharizardCard = { // ... };const BlastoiseCard = { // ... };const VenusaurCard = { // ... };
const routes = [ // ... ];
const View = { // ... };
const Link = { name: 'router-link', props: { to: { type: String, required: true } }};
const App = { // ... };
export default App;
We can create the template
of router-link
to consist of an <a>
tag with an @click
handler attribute. Upon trigger, the @click
handler will call a component method, labeled navigate()
, that navigates the browser to the desired location. This navigation will occur with the use of the [history.pushState()](https://developer.mozilla.org/en-US/docs/Web/API/History_API#The_pushState%28%29_method)
method. With that said, the Link
constant object will be updated to:
const Link = { name: 'router-link', props: { to: { type: String, required: true } }, template: `<a @click="navigate" :href="to">{{ to }}</a>`, methods: { navigate(evt) { evt.preventDefault(); window.history.pushState(null, null, this.to); } }};
Within the <a>
tag, we’ve bound the value of the to
prop to the element text content with {{ to }}
.
When navigate()
is triggered, it first calls preventDefault()
on the event object to prevent the browser from making a web request for the new location. The history.pushState()
method is then called to direct the user to the desired route location. history.pushState()
takes three arguments:
In our case, there is no state information that’s needed to be passed, so we’ve left the first argument as null
. Some browsers (e.g. Firefox) currently ignore the second parameter, title
, hence we’ve left that as null
as well.
The target location, the to
prop, is passed in to the third and last parameter. Since the to
prop contains the target location in a relative state, it will be resolved relative to the current URL. In our case, /blastoise
will resolve to [http://localhost:8080/blastoise](http://localhost:8080/blastoise.)
.
If we click any of the links now, we’ll notice our browser updates to the correct location without a full page reload. However, our app will not update and render the correct component.
This unexpected behaviour happens because when router-link
is updating the location of the browser, our Vue app is not alerted of the change. We’ll need to trigger our app (or simply just the router-view
component) to re-render whenever the location changes.
Though there’s a few ways to accomplish this behaviour, we’ll do this by using a custom [EventBus](https://alligator.io/vuejs/global-event-bus/)
. An EventBus
is a Vue instance responsible in allowing isolated components to subscribe and publish custom events between each other.
At the beginning of the file, we’ll import
the vue
library and create an EventBus
with a new Vue()
instance:
import Vue from 'vue';
const EventBus = new Vue();
When a link has been clicked, we need to notify the necessary part of the application (i.e. router-view
) that the user is navigating to a particular route. The first step is to create an event emitter using the EventBus
's events interface in the navigate()
method of router-link
. We’ll give this custom event a name of navigate
:
const Link = { // ..., methods: { navigate(evt) { evt.preventDefault(); window.history.pushState(null, null, this.to); EventBus.$emit('navigate'); } }};
We can now set the event listener/trigger in the created()
hook of router-view
. By setting the custom event listener outside of the if/else
statement, the created()
hook of View
will be updated to:
const View = { // ..., created() { if (this.getRouteObject() === undefined) { this.currentView = { template: ` <h3 class="subtitle has-text-white"> Not Found :(. Pick a Pokémon from the list below! </h3> ` }; } else { this.currentView = this.getRouteObject().component; }
// Event listener for link navigation EventBus.$on('navigate', () => { this.currentView = this.getRouteObject().component; }); }, .. ///};
When the browser’s location changes by clicking a <router-link>
element, this listening function will be invoked, re-rendering router-view
to match against the latest URL!
Great! Our app now navigates appropriately as we click each of the links.
There’s one last thing we need to consider. If we try to use the browser back/forward buttons to navigate through the browser history, our application will not currently re-render correctly. Although unexpected, this occurs because no event notifier is emitted when the user clicks browser back
or browser forward
.
To make this work, we’ll use the [onpopstate](https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate)
event handler.
The onpopstate
event is fired each time the active history entry changes. A history change is invoked by clicking the browser back
or browser forward
buttons, or calling history.back()
or history.forward()
programmatically.
Right after our EventBus
creation, let’s set up the onpopstate
event listener to emit the navigate event when a history change is invoked:
window.addEventListener('popstate', () => { EventBus.$emit('navigate'); });
Our application will now respond appropriately even when the browser navigation buttons are used!
And there we have it! We’ve just built a custom Vue router using an EventBus
and dynamic components. Even with the tiny size of our app we can enjoy a noticeable performance improvement. Avoiding a full page load also saves hundreds of milliseconds and prevents our app from "blinking" during the page change.
I love Vue. One reason as to why — it’s incredibly easy to use and manipulate Vue components just like we saw in this article.
In the introduction, we mentioned how Vue provides the vue-router
library as the official routing library of the framework. We’ve just created simple versions of the same main items that are used in vue-router
:
routes
: the array responsible in mapping components to respective URL pathnames.router-view
: the component that renders a specified app component based on the app’s locationrouter-link
: the component that allows the user to change the location of the browser without making a web request.For very simple applications, the routing we’ve built (or a variation thereof like this one built by Chris Fritz) can do the minimal amount of work needed to route our applications.
The vue-router
library, on the other hand, is built in a more complicated manner and introduces incredibly useful capabilities, often needed in larger applications like:
Though the vue-router
library does come with additional boilerplate, it’s fairly easy to integrate once your application is composed of well isolated and distinct components. If you're interested, you can see the components of vue-router
being used to enable routing in this application here.
Hopefully this was as enjoyable to you as it was for me in compiling this post! Thanks for reading!
— — — — — — — — — — — — — — —♥ — — — — — — — — — — — — — — — This article is an adapted (and summarized) segment from the book, Fullstack Vue. Fullstack Vue is a project driven approach to learning Vue.js since everything is explained within the context of building a larger application. Fullstack Vue is currently available and you can download the first chapter for free from the main website: https://www.fullstack.io/vue