When users visit a web page, they are not necessarily interested in its content. If the page contains a bunch of images and a user navigates away from the first screen, it is essentially a waste of traffic, CPU, time, etc. But it can be optimized.
The idea is to load images only when a user shows an intention to view them. For example, when a blog post is loaded, no images are rendered until the user starts scrolling and is at a distance of half the screen height from the top border of the image. Only then, img
tag gets rendered and the browser starts downloading the image.
In the older times, to detect if an image is near the viewport, we would use scroll listeners with a combination of DOM methods that cause page reflow, such as getBoundingClientRect
. And it was quite rough in terms of performance.
Luckily, this issue was addressed in recent years by the introduction of IntersectionObserver
. If you don't feel like opening links, TL;DR: this new 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. In other words, you simply hook an observer and provide a callback, which will be triggered when the target element will be in (or near from) the viewport.
Let's get to the implementation.
Starting with the ReScript bindings to IntersectionObserver
.
These bindings don't cover the whole IntersectionObserver
API, but only bits that would be required to implement the lazy loading of images in the project. If you want, you can simplify those even more. For example, I know that across the whole project I won't be using any custom root
or threshold
and my rootMargin
must be 50%
. So I reduced it to:
This way, every time IntersectionObserver
gets instantiated in ReScript:
It would result in the following JavaScript code:
The state type of the component consists of three constructors:
We also need a few actions to be able to change the state:
When IntersectionObserver
triggers a callback, StartLoadingImage
action will be dispatched and UI will render an <img />
tag with either spinner or a placeholder. img
tag should also have an onLoad
handler that will dispatch ShowImage
action, which removes the spinner and shows the loaded image. And we're done.
Let's glue it all together into the first iteration of the Image
component.
Not bad for a start, but it can be improved in a few ways:
<img />
tag must be in an HTML) or because they are an important part of the first screen (such as a cover image). So it would require one more input parameter.onLoad
handler might not be triggered. So it needs to be handled as well.The final version with additions:
Another good UX pattern is to ensure that the size of the temporary container exactly matches the size of the loaded image in the UI to avoid layout shifts. I will touch on this topic in the next post. Take care!