Decade Moon
Decade Moon

Reputation: 34286

How to display a "loading" animation while a lazy-loaded route component is being loaded?

I have split my app into multiple chunks with webpack's code splitting feature so that the entire application bundle isn't downloaded when the user visits my webpage.

The chunks that some routes require can be reasonably large and may take a noticeable amount of time to download. This is fine, except the user is not aware that the page is actually loading when they click an internal link, so I need to somehow display a loading animation or something.

My router is configured like this:

[
  {
    path: '/',
    component: () => import(/* webpackChunkName: 'landing' */ './landing.vue'),
  },
  {
    path: '/foo',
    component: () => import(/* webpackChunkName: 'main' */ './foo.vue'),
  },
  {
    path: '/bar',
    component: () => import(/* webpackChunkName: 'main' */ './bar.vue'),
  },
]

Advanced Async Components in the Vue.js guide shows how to display a particular "loading" component while the component is being resolved -- this is exactly what I need, however it also says:

Note that when used as a route component in vue-router, these properties will be ignored because async components are resolved upfront before the route navigation happens.

How can I achieve this in vue-router? If this is not possible, lazily-loaded components would be pretty much useless to me because it would provide a poor experience to the user.

Upvotes: 48

Views: 37802

Answers (5)

The Jared Wilcurt
The Jared Wilcurt

Reputation: 340

The top answer links to JSFiddles that don't work because they use @latest (loads Vue 3) but uses Vue 2's API. I've corrected these:

The main difference is the router definition now requires a history option because Vue-Router 3 auto-picked the most logical one for you, where as Vue-Router 4 is more annoying and doesn't. Also I added a scroll behavior function because it isn't well documented that you need a Promise/Resolve when using lazy loading, which I assume is why most people are on this SO page. Also the createRouter API is new in Vue-Router 4 to match the new Vue 3 createApp API change.

const router = VueRouter.createRouter({
  history: VueRouter.createWebHashHistory(),
  routes,
  scrollBehavior: function () {
    return new Promise((resolve) => {
      resolve({ left: 0, top: 0 });
    });
  }
});

Other than that, it's just how you mount with the new API.

const app = Vue.createApp();
app.use(router);
app.mount('#app');

Upvotes: 1

rogyvoje
rogyvoje

Reputation: 314

Accepted answer is excellent solution if you are showing loading indicator on one place for any pages even the nested ones.

But if someone is looking for a way to wait for RouterView to finish with lazy loading components and show some loading indicator here is a code that you can use.

<script setup lang="ts">
import Spinner from '@lib/components/spinner/Spinner.vue';
import { routerViewWithSuspenseService } from './router-view-with-suspense';

routerViewWithSuspenseService.register();
const { isCurrentIndexLoading } = routerViewWithSuspenseService.use();
</script>

<template>
    <template v-if="isCurrentIndexLoading">
        <slot>
            <div class="grid size-full place-items-center">
                <Spinner class="size-10"></Spinner>
            </div>
        </slot>
    </template>
    <RouterView v-else></RouterView>
</template>
import { useTimeoutFn } from '@vueuse/core';
import { computed, shallowRef } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';

function createRouterViewWithSuspenseService() {
    const registered = shallowRef(false);
    const index = shallowRef(0);
    const isLoading = shallowRef(false);
    const { start, stop } = useTimeoutFn(() => (isLoading.value = true), 100, { immediate: false });

    function register() {
        if (registered.value) {
            return;
        }

        registered.value = true;

        const router = useRouter();

        router.beforeEach(() => {
            start();
        });

        router.afterEach(() => {
            stop();
            isLoading.value = false;
        });
    }

    function use() {
        index.value += 1;
        const currentIndex = index.value;

        onBeforeRouteLeave(() => {
            index.value -= 1;
        });

        return {
            isCurrentIndexLoading: computed(() => currentIndex === index.value && isLoading.value),
        };
    }

    return {
        register,
        use,
    };
}

export const routerViewWithSuspenseService = createRouterViewWithSuspenseService();

Basically I replaced all RouterView code withh RouterViewWithSuspense and you can get the loading indicator showing.

Hope I save someone a lot of headache :D

P.S. I'm still not sure why native Suspense does not work well with dynamic components used with vue-router.

Upvotes: 0

Rahman Rezaee
Rahman Rezaee

Reputation: 2165

you can use this code for gobel route in one project of vuejs

const router = new Router({
  routes: [
      { path: '/', name: 'home', component: Home },
      { path: '/about', name: 'about', component: About }
  ]
})

router.beforeResolve((to, from, next) => {
  // If this isn't an initial page load.
  if (to.name) {
      // Start the route progress bar.
      NProgress.start()
  }
  next()
})

router.afterEach((to, from) => {
  // Complete the animation of the route progress bar.
  NProgress.done()
})

Upvotes: 0

Linus Borg
Linus Borg

Reputation: 23968

You can use navigation guards to activate/deactivate a loading state that shows/hides a loading component:

If you would like to use something like "nprogress" you can do it like this:

http://jsfiddle.net/xgrjzsup/2669/

const router = new VueRouter({
  routes
})

router.beforeEach((to, from, next) => {
  NProgress.start()
  next()
})
router.afterEach(() => {
  NProgress.done()
})

Alternatively, if you want to show someting in-place:

http://jsfiddle.net/h4x8ebye/1/

Vue.component('loading',{ template: '<div>Loading!</div>'})

const router = new VueRouter({
  routes
})

const app = new Vue({
  data: { loading: false },
  router
}).$mount('#app')

router.beforeEach((to, from, next) => {
  app.loading = true
    next()
})

router.afterEach(() => {
  setTimeout(() => app.loading = false, 1500) // timeout for demo purposes
})

Then in the template:

<loading v-if="$root.loading"></loading>
  <router-view v-else></router-view>

That could also be easily encapsulated in a very small component instead of using the $root component for the loading state.

Upvotes: 67

Decade Moon
Decade Moon

Reputation: 34286

For what it's worth, I'll share what I ended up doing for my situation.

I'm using Vuex so it was easy to create an app-wide "loading" state which any component can access, but you can use whatever mechanism you want to share this state.

Simplified, it works like this:

function componentLoader(store, fn) {
  return () => {
    // (Vuex) Loading begins now
    store.commit('LOADING_BAR_TASK_BEGIN');

    // (Vuex) Call when loading is done
    const done = () => store.commit('LOADING_BAR_TASK_END');

    const promise = fn();
    promise.then(done, done);
    return promise;
  };
}

function createRoutes(store) {
  const load = fn => componentLoader(store, fn);

  return [
    {
      path: '/foo',
      component: load(() => import('./components/foo.vue')),
    },
    {
      path: '/bar',
      component: load(() => import('./components/bar.vue')),
    },
  ];
}

So all I have to do is wrap every () => import() by my load() function which takes care of setting the loading state. Loading is determined by observing the promise directly instead of relying on router-specific before/after hooks.

Upvotes: 12

Related Questions