Lazy load components
Recently, I got some performances issues on a frontend project because my application was loading more than hundred large components. Thus, during the loading of the page, the JavaScript main thread was locked and the UI was stuck during few seconds.
It’s possible to detect that using the developper tools and start a performance test. Here is the result:
The take away from this flamgraph is the hilighted part. The horizontal red bar means that the JavaScript main thread is computing. It means the browser can’t render anything meanwhile. In my example, we see it lasts ~6 seconds, which is a lot!
The issue was quite obvious, the loading imply to mutate a too big amount of the DOM and the browser had too much work to do.
There is some way to solve this. We could implement a pagination and load a different slice of the element to render: It’s easy to implement but it degrades the UX because the user needs to click on the next page button.
So I searched a simple way to implement a scroll listener and load components once it’s visible in the viewport.
Intersection Observer
Hopefully, JavaScript covers our need using IntersectionObserver
.
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport. — MDN
The API is really simple, we just need to take care of closing the IntersectionObserver
once we don’t need it anymore:
const observer = new IntersectionObserver((entries) => {
const visible = entries.some((element) => element.isIntersecting);
if (!visible) return;
observer.unobserve(element);
// 🙌 The element is visible
});
observer.observe(element);
currentObserver.value = observer;
Wrap everything into a component for VueJS
Now we have a way to observe the visibility of an element, let’s wrap everything into a component.
NOTE: I’ll use Vue.js for the example but it can be any frontend framework.
We’ll create a component LazyLoader
with two slots:
content
, the component to displayspinner
, a simple component responsible to “reserve” the space and/or display a pretty spinner
We’ll display the right slot accoring to the visibility.
// src/components/LazyLoader.vue
<template>
<div class="lazy-load" ref="container">
<slot name="spinner" v-if="!isVisible" />
<slot name="content" v-else />
</div>
</template>
Now, we just need to use the composition API to:
- get the
container
- introduce a ref
isVisible
- use the snippet bellow to toggle
isVisible
- makes sure to disconnect observer on unmout
Here is the result:
export default {
name: "LazyLoader",
setup() {
const container = ref<Element>();
const isVisible = ref(false);
const currentObserver = ref<IntersectionObserver | undefined>();
function observeVisibility(element: Element) {
// close the old IntersectionObserver if exists
currentObserver.value?.disconnect();
const observer = new IntersectionObserver((entries) => {
// check if any observed elements are visible
const visible = entries.some((element) => element.isIntersecting);
if (!visible) return;
// the element is visible, we don't need the observer anymore
observer.unobserve(element);
isVisible.value = true;
});
// observe the element and save the observer to close it if necessary
observer.observe(element);
currentObserver.value = observer;
}
watch(
container,
() => {
// handle if the browser doesn't support IntersectionObserver
if (!window.IntersectionObserver) return (isVisible.value = true);
// if HTML ref exists, start to observe visibility
if (container.value) observeVisibility(container.value);
},
{ immediate: true }
);
// be sure to close IntersectionObserver if component is destroyed
onUnmounted(() => currentObserver.value?.disconnect());
return { container, isVisible };
},
};
Now we just need to use the component in the list
<template>
<LazyLoader v-for="(item, index) of bigList" :key="index">
<template v-slot:content>
<Card :item="item" />
</template>
<template v-slot:spinner>
<CardLoader />
</template>
</LazyLoader>
</template>
Result
The implementation is really simple, it doesn’t require any dependency and it’s performant.
In my use case, it reduced the rendering time to 700mx (-90%).
I want to mention my example was with Vue.js but it can be adapted to other frontend framework.
Try it yourself:
See other related posts
A fun experimentation to publish the same JSX component to 3 frontend framework using a monorepo