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

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?#

For now, 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.

And a simple way to extend a component in vue 2.7 is done by using mixins

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

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.tsconst 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 linkconst 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);    });  },  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 ๐Ÿš€.