Marc-Olivier Castagnetti

Marc-Olivier Castagnetti

Pre-loading pages with links in viewport using vue-router 3

Pre-load pages while navigating using IntersectionObserver and vue-router 3


Preload page components with vue-router

TL;DR You can take a look at the the vue-router plugin we created to load pages chunks once a router-link enters the viewport in this gist.

While scrolling my Twitter feed recently, I saw this thread about fast page transition.

How VitePress based site have such speedy page transitions?#

Simply by prefetching page chunks as described later in the very same Twitter feed. Every page of which links are inside the viewport are prefetched by adding a <link rel="prefetch" href="/assets/{js_chunk_of_the_related_page}.js">. Once the user wants to access one of this page, as the page is preloaded, the transition feels nearly instant.

That gave us the idea to apply the same mechanism to our own application.

But how do we do it?#

At the time of writing, we are still using vue@2.7 with vue-router@3. Our links are all handled by the built-in <router-link /> component, so the idea was to extend this <router-link /> to automatically apply our new prefetch logic to every link of the app.

A simple way to extend a component in Vue 2.7 is done by using mixins (once VueRouter plugin is installed):

Vue.options.components.RouterLink.mixin({
  mounted() {
    console.log("Hello World from each router-link component")
  },
});

In Vue 3 we now monkey patch the RouterLink component:

import { RouterLink } from 'vue-router';

RouterLink.mixins = [
  {
    mounted() {}
  }
];

vue-router's Lazy load to the rescue#

As it is possible to define an async import statement for the component property to lazy load its content, we can get the same import statement in an other part of the app to trigger it programatically whenever we want.

Lets say we have this router definition for a profile page:

// router.ts
const routes = [{
  path: '/profile/:userId',
  name: 'Profile',
  component: () => import('@/pages/Profile.vue'),
}]

And to import it in our mixin from the definition of the router.

Vue.options.components.RouterLink.mixin({
  mounted() {
    // Find the target route among all routes definitions (flatten by Vue Router itself, eg. don't have to search for `children`)
    // `component` property of the route definitions becomes `components` because there can be names components.
    const { components } = this.$router.getRoutes().find(({ name }) => name === this.$props.to.name);

    Object.values(components).forEach((component) => {
      if (typeof component !== 'function') {
        return;
      }

      // Triggers the page's chunks fetching: `component()` will actually be `import('@/pages/Profile.vue')`
      component();
    });
  },
});

Once the chunck is imported, when accessing the page later, vue-router will try to fetch the chunk using the import syntax. As the chunk is already imported and cached by the browser, the chunk resolution will be instant.

But to consider the mechanism complete, we want three things:

  • Pre-fecthing only when the browser is idle to avoid the pre-fetch request to have a negative effect on user experience
  • Only pre-fetch when the link is in the viewport
  • Avoid prefetching if the user has enabled the "save data" mode

IntersectionObserver and requestIdleCallback to prefetch ressources at the right moment without degrading user experience#

First, we want to store a state of all our <router-link /> that are currently loaded in the application in a Map.

As the <router-link /> will ultimately be compiled as a <a> element, the Map gives us the opportunity to use its actual DOM reference of this element as a key. And as a value we can add the async import: () => import('pages/Profile.vue').

type AsyncImport = () => Promise<void>;

const importMap = new Map<HTMLAnchorElement, AsyncImport>();

Vue.options.components.RouterLink.mixin({
  mounted() {
    // Avoid prefetching if the save data setting is enabled
    if (window.navigator?.connection?.saveData === true) {
      return;
    }

    const el: HTMLAnchorElement = this.$el;
    const { components } = this.$router.getRoutes().find(({ name }: RouteConfig) => name === this.$props.to.name);

    Object.values(components as Record<string, AsyncImport>).forEach((component) => {
      if (typeof component !== 'function') {
        return;
      }

      importMap.set(el, component);
    });
  },
  destroyed() {
    importMap.delete(this.$el);
  },
});

We now have a clear state of all the <router-link /> and what they are supposed to load inside a Map.

Using the IntersectionObserver, we can now track which compiled <a> elements are really inside the viewport.

window.requestIdleCallback will trigger the pre-fetch code once the browser has nothing else to do.

// requestIdleCallback is not available is not yet available in Safari 😠
const ric = window.requestIdleCallback || setTimeout;

// For performances reasons, create a single observer that will observe every link
const observer = new IntersectionObserver((entries) => {
  // For each link inside the viewport
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // Get the `<a>` DOM node
      const link = entry.target as HTMLAnchorElement;

      ric(async () => {
        // Get the related import of this link using the previously created Map
        const asyncImport = importMap.get(link);

        // Trigger the import
        await asyncImport();

        // Remove the Map entry as its no longer needed
        importMap.delete(link);

        // Remove all the same Map entries that are importing the same page
        for (const [key, value] of importMap) {
          if (value === asyncImport) {
            importMap.delete(key);
            observer.unobserve(key);
          }
        }

        // Unload the IntersectionObserver of this link to avoid unecessary
        // intersection callback calls.
        observer.unobserve(link);
      });
    }
  });
});

As multiple links can be in the same page, multiple entries for the same component pre-fetch can be present in the Map. This is why we release the Map of every imports that are duplicated once the first one is pre-fetched, and at the same time, unobserve the elements to avoid intersecting callbacks to be triggered.

To link the router-link to the intersection observer, we only have to make the previously created IntersectionObserver instance to observe the created <a> element in the mixin:

Vue.options.components.RouterLink.mixin({
  mounted() {
    // @ts-expect-error connection is not implemented in Navigator interface yet
    if (window.navigator?.connection?.saveData === true) {
      return;
    }

    const el: HTMLAnchorElement = this.$el;
    const { components } = this.$router.getRoutes().find(({ name }: RouteConfig) => name === this.$props.to.name);

    Object.values(components as Record<string, AsyncImport>).forEach((component) => {
      if (typeof component !== 'function') {
        return;
      }

      importMap.set(el, component);

      observer.observe(el);
    });
  },
  // `unmounted` in Vue 3
  destroyed() {
    importMap.delete(this.$el);
    observer.unobserve(this.$el as HTMLAnchorElement);
  },
});

Now, all chunk of pages of which links are in the viewport are pre-loaded, thus offering the user a better performance feeling 🚀.