Let's imagine we have ourselves a little web app that displays a column of images (of kittens, of course).
We open the code and see that we have 3 friendly Svelte components greeting us. Let's take a look at each one in turn:
-
App.svelte
sets some basic styles and renders aList
component. We won't be editing this file but here it is for clarity:
<script>
import List from "./List.svelte";
</script>
<style>
main {
width: 300px;
margin: 0 auto;
text-align: center;
}
</style>
<main>
<h1>Kittens</h1>
<List />
</main>
-
List.svelte
generates a list of images (such ashttps://placekitten.com/g/300/500?image=01
) and renders aListItem
component for each of them:
<script>
import ListItem from "./ListItem.svelte";
// generate image data:
const prefix = "https://placekitten.com/g/300/500?image=";
const items = ["01", "02", "03", "04", "05"].map(num => prefix + num);
</script>
{#each items as item}
<ListItem {item} />
{/each}
-
ListItem.svelte
is in charge of rendering an individual image inside an article tag:
<script>
export let item;
let src = item;
</script>
<style>
article {
width: 300px;
height: 500px;
margin-bottom: 0.5rem;
}
</style>
<article>
<img {src} alt='kitten'/>
</article>
So we're loading and rendering a few images that are 300 pixels wide and 500 pixels tall from placekitten.com. Nice and easy.
The Issue At Hand
Most of the images (each being 500px tall) are naturally off screen when the user lands on the page. They might never scroll down to see all our awesome content below the fold. So they're downloading data for nothing on initial load, and slowing down their experience.
Even if they do scroll all the way down, it would be nice to load the images only when they are about to enter the viewport and lighten the initial load. We can improve the user's experience and serve fewer images on our end. Win-win.
When Lazy is Good
So let's lazy load our images! But not the first 2, we want to fetch those right away, and then load the rest as we scroll down.
First, let's have our List
component pass down a lazy
prop to ListItem
, which will be true
starting from the third image. When it's true
, ListItem
will set src to an empty string so that no image is requested at first.
In List.svelte
, we pass down a new lazy
prop:
{#each items as item, i}
<ListItem {item} lazy={i > 1} />
{/each}
In ListItem.svelte
, we set the image src
:
export let item;
export let lazy;
let src = lazy ? '' : item;
So, at this stage, we're loading the first two images but the rest is never loading. How shall we trigger this effect?
Intersection Observer
The Intersection Observer is a web API that allows us to know when an element is intersecting (or about to intersect) with the viewport. It's got solid browser support (it's just not available in IE11).
How does it work? We create an observer using IntersectionObserver
and give it a function that will run when a DOM node that we've registered is intersecting with the viewport.
const observer = new IntersectionObserver(onIntersect);
function onIntersect(entries){
// todo: update relevant img src
}
We can observe (and unobserve) a node using a Svelte action:
<script>
function lazyLoad(node) {
observer.observe(node);
return {
destroy() {
observer.unobserve(node)
}
}
}
</script>
<article use:lazyLoad>
<!-- img -->
</article>
Putting it together our ListItem.svelte
looks like this (minus the styles which haven't changed):
<script>
export let item;
export let lazy = false;
let src = item;
let observer = null;
if (lazy) {
src = "";
observer = new IntersectionObserver(onIntersect, {rootMargin: '200px'});
}
function onIntersect(entries) {
if (!src && entries[0].isIntersecting) {
src = item;
}
}
function lazyLoad(node) {
observer && observer.observe(node);
return {
destroy() {
observer && observer.unobserve(node)
}
}
}
</script>
<article use:lazyLoad>
<img {src} alt='kitten'/>
</article>
When the lazy
prop is passed in as true
, we immediately set the src
to empty string and create an observer
. We add a rootMargin
option so that the onIntersect
function is triggered 200 pixels before the element comes into view. In lazyLoad
, we register the article node that we want to watch.
Effectively, we are creating an observer with a single node for each ListItem
, so we can check if that node (entries[0]
) is in fact intersecting in our OnIntersect
function and set src = item
which will request the image.
And just like that, we're lazy loading our images! We can see in the devtools that we are not requesting all images upfront, as illustrated in this GIF:
Last thing, Let's make sure our app doesn't blow up if intersectionObserver
isn't available (IE11) by adding a hasAPI
check in List.svelte
<script>
import ListItem from "./ListItem.svelte";
const prefix = "https://placekitten.com/g/300/500?image=";
const items = ["01", "02", "03", "04", "05"].map(img => prefix + img);
const hasAPI = "IntersectionObserver" in window; // new
</script>
{#each items as item, i}
<ListItem {item} lazy={hasAPI && i > 1} />
{/each}
Here is the updated sandbox shall you want to tinker with this code:
This is a technique I recently implemented for a painter's portfolio website that I built using Sapper. You can see it at https://john-hong-studio.com.
Thanks for reading! Don't hesitate to leave a comment or connect with me on twitter!
Top comments (2)
Hello. Thank You for this article. It was very helpful for me.
I want to add something. In current situation, we have an IntersectionObserver which never ends observing until node will be destroyed. But we need only one intersection. So I propose to edit your code a little bit.
We also can pass an observer through parameters props. Thanks to this, a lazyLoad action could be reused in many places.
Hello
Thanks for you code. However I have a question. Could you show me the code in order to access the images on my local server:
const prefix = "XXXXXX?image=";
Should I put the image folder in "/public" and must the folder structure be the same as in: "place kitten.com" ?
Thanks
Adrien