Lazy-Loading Routes with Vue Router with a Progress Bar

Introduction

By default, when writing a Vue.js Single Page Application (SPA), all necessary assets such as JavaScript and CSS files are loaded together when the page is loaded. When dealing with large files, this can lead to unsatisfactory user experience.

With the help of Webpack, it is possible to load pages on demand in Vue.js using the import() function instead of the import keyword.

Why Load on Demand?

A typical SPA in Vue.js works by having all the functionality and assets packaged and delivered together to allow users to use the application without the need to refresh pages. If you have not explicitly designed the application to load pages on demand, all the pages will be loaded either at once, or prefetched/preloaded ahead of time, using unnecessary bandwidth and slowing page loading.

This makes it especially bad for large SPA with many pages. People with slow internet connection or low end devices such as mobile phones would have a bad user experience. By loading on demand, users would never need to download more than they need.

Vue.js does not come with any loading indicator for dynamic modules. Even with prefetching and preloading - no visual indicator lets the users know how the loading is going. We'll also be adding a progress bar to improve user experience.

Preparing the Project

First we need a way for our progress bar to communicate with the Vue Router. To do that, we will use the Event Bus Pattern.

The Event Bus is basically a singleton Vue instance. Since all Vue instances have an event system using $on and $emit, we can use it to pass events anywhere in our app.

Let's create a new file, eventHub.js in the components directory:

import Vue from 'vue'
export default new Vue()

Now, we'll configure Webpack to disable prefetching and preloading. We can either do this individually for each function, or disable it globally. Create a vue.config.js file at the root folder and add the configuration to disable prefetching and preloading:

module.exports = {
    chainWebpack: (config) => {
        // Disable prefetching and preloading
        config.plugins.delete('prefetch')
        config.plugins.delete('preload')
    },
}

Adding Routes and Pages

We'll be using Vue Router. To that end, we'll use npx to install it:

$ npx vue add router

Now, let's edit our router file, typically located under router/index.js and update our routes to use the import() function, instead of the import statement:

This is the default configuration:

import About from '../views/About.vue'
{
    path: '/about',
    name: 'About',
    component: About
},

We've changed it to:

{
    path: '/about',
    name: 'About',
    component: () => import('../views/About.vue')
},

If you prefer to select which pages to load on demand instead of disabling prefetching and preloading globally, use the special Webpack comments instead of configuring Webpack in vue.config.js:

import(
    /* webpackPrefetch: true */
    /* webpackPreload: true */
    '../views/About.vue'
)

The main difference between import() and import is that ES modules loaded with import() are loaded at runtime while the ones loaded with import are loaded during compile time. This means we can defer the loading of modules with import() and load only when necessary.

Implementing the Progress Bar

Since it's impossible accurately estimate when the page will load (or if it'll load at all), we can't really make a progress bar. There's no way to check how much the page has loaded either. What we can do is create a progress bar that finishes when the page loads.

Everything in between doesn't really reflect the progress, so in most cases, the progress depicted is just random jumps.

Let's install lodash.random first, since we'll be using that package to select some random numbers during the progress bar generation:

$ npm i lodash.random

Then, let's create a Vue component - components/ProgressBar.vue:

<template>
    <div :class="{'loading-container': true, loading: isLoading, visible: isVisible}">
        <div class="loader" :style="{ width: progress + '%' }">
            <div class="light"></div>
        </div>
        <div class="glow"></div>
    </div>
</template>

Now, to that component, we'll be adding a script. Within that script, we'll first import random and $eventHub, as we'll be using those:

<script>
import random from 'lodash.random'
import $eventHub from '../components/eventHub'
</script>

Now, after the imports, in the same script, we can define some variables that'll we'll be using:

// Assume that loading will complete under this amount of time.
const defaultDuration = 8000 
// How frequently to update
const defaultInterval = 1000 
// 0 - 1. Add some variation to how much the bar will grow at each interval
const variation = 0.5 
// 0 - 100. Where the progress bar should start from.
const startingPoint = 0 
// Limiting how far the progress bar will get to before loading is complete
const endingPoint = 90 

With those in place, let's write the logic to asynchronously load the components:

export default {
    name: 'ProgressBar',
    
    data: () => ({
        isLoading: true, // Once loading is done, start fading away
        isVisible: false, // Once animate finish, set display: none
        progress: startingPoint,
        timeoutId: undefined,
    }),

    mounted() {
        $eventHub.$on('asyncComponentLoading', this.start)
        $eventHub.$on('asyncComponentLoaded', this.stop)
    },

    methods: {
        start() {
            this.isLoading = true
            this.isVisible = true
            this.progress = startingPoint
            this.loop()
        },

        loop() {
            if (this.timeoutId) {
                clearTimeout(this.timeoutId)
            }
            if (this.progress >= endingPoint) {
                return
            }
            const size = (endingPoint - startingPoint) / (defaultDuration / defaultInterval)
            const p = Math.round(this.progress + random(size * (1 - variation), size * (1 + variation)))
            this.progress = Math.min(p, endingPoint)
            this.timeoutId = setTimeout(
                this.loop,
                random(defaultInterval * (1 - variation), defaultInterval * (1 + variation))
            )
        },

        stop() {
            this.isLoading = false
            this.progress = 100
            clearTimeout(this.timeoutId)
            const self = this
            setTimeout(() => {
                if (!self.isLoading) {
                    self.isVisible = false
                }
            }, 200)
        },
    },
}

In the mounted() function you'll see that we're making use of the event bus to listen for asynchronous component loading. It will start the loading animation once the router tells us that we have navigated to a page that has not been loaded yet.

And finally, let's add some style to it:

<style scoped>
.loading-container {
    font-size: 0; /* remove space */
    position: fixed;
    top: 0;
    left: 0;
    height: 5px;
    width: 100%;
    opacity: 0;
    display: none;
    z-index: 100;
    transition: opacity 200;
}

.loading-container.visible {
    display: block;
}
.loading-container.loading {
    opacity: 1;
}

.loader {
    background: #23d6d6;
    display: inline-block;
    height: 100%;
    width: 50%;
    overflow: hidden;
    border-radius: 0 0 5px 0;
    transition: 200 width ease-out;
}

.loader > .light {
    float: right;
    height: 100%;
    width: 20%;
    background-image: linear-gradient(to right, #23d6d6, #29ffff, #23d6d6);
    animation: loading-animation 2s ease-in infinite;
}

.glow {
    display: inline-block;
    height: 100%;
    width: 30px;
    margin-left: -30px;
    border-radius: 0 0 5px 0;
    box-shadow: 0 0 10px #23d6d6;
}

@keyframes loading-animation {
    0% {
        margin-right: 100%;
    }
    50% {
        margin-right: 100%;
    }
    100% {
        margin-right: -10%;
    }
}
</style>

Now, let's add our ProgressBar to our App.vue or a layout component as long as it is in the same component as the router view. We want it to be available throughout the app's lifecycle:

<template>
    <div>
        <progress-bar></progress-bar>
        <router-view></router-view>
        <!--- your other components -->
    </div>
</template>

<script>
import ProgressBar from './components/ProgressBar.vue'
export default {
       components: { ProgressBar },
}
</script>

This all results in a sleek progress bar, looking like this:

progress bar

Trigger Progress Bar for Lazy-Loaded Pages

Our ProgressBar is listening on the Event Bus for the asynchronous component loading event. When something is loading this way, we'll want to trigger the animation. Let's add a route guard to the router to pick up these events:

import $eventHub from '../components/eventHub'

router.beforeEach((to, from, next) => {
    if (typeof to.matched[0]?.components.default === 'function') {
        $eventHub.$emit('asyncComponentLoading', to) // Start progress bar
    }
    next()
})

router.beforeResolve((to, from, next) => {
    $eventHub.$emit('asyncComponentLoaded') // Stop progress bar
    next()
})

To detect if the page is lazy loaded, we need to check if the component is defined as a dynamic import i.e. component: () => import('...') instead ofcomponent: MyComponent.

This is done with typeof to.matched[0]?.components.default === 'function'. Components that were loaded with import statement will not be classified as functions.

Conclusion

In this article, we've explored the need to lazy-load certain pages. We've disabled prefetching and preloading in our Vue application and created a progress bar component that shows up to simulate the actual progress being made when loading a page.