Almost every article on a digital publication's website will have an image somewhere. Most of these have multiple images. Sometimes 10, 20... 30... 💤
So be lazy!
Lazily loading images to decrease load time is nothing new. People have come up with different implementations utilizing various concepts, but the goal is the same: load the page as fast as possible while minimally affecting UX.
This walkthrough will (hopefully) give you an understanding of a way to load an image in a performant way, similar to the way Medium loads images. It assumes basic knowledge of React, and that server-rendered components are being provided with a javascript bundle on the client.
Render static component on server ❄️
Let's make a basic component that can render two images, which we will switch between later:
const BlurredImg = styled.img`
filter: blur(6px);
transition: opacity 0.2s ease-in;
`;
const smallImageUrl = `www.images.com/low-quality/${this.props.id}.${this.props.format}`;
const fullImageUrl = `www.images.com/high-quality/${this.props.id}.${this.props.format}/`;
return (
<React.Fragment>
<BlurredImg src={smallImageUrl} data-id={this.props.id} data-format={this.props.format} />
{this.props.pageLoaded && <img src={fullImageUrl} />}
</React.Fragment>
);
If you aren't familiar with styled-components, that styled
syntax is essentially a block of CSS around an img
tag.
The pageLoaded
prop will prevent the higher-quality image from being rendered on the server. We'll set this to true
when hydrating later.
The BlurredImg
element is doing two things:
- 1. Rendering a low-quality version of image, with blur
- 2. Exposing
data
attributes to the DOM*
*Because the server is returning raw HTML, the props we used to render the component will be lost. How you choose to keep props consistent between server and client is up to you; this implementation will rely on data attributes to keep things simple. Alternatively, you could pass a blob of data keyed by image id
, etc.
Hydrate component on client 💧
Hydrating the component allows it to load the higher-quality image to replace the blurred version. As mentioned before, the props are determined by data attributes:
const imageElement = document.querySelector('.lazy-image-wrapper');
ReactDOM.hydrate(
<ProgressiveImageExample
id={imageElement.dataset.id}
format={imageElement.dataset.format}
pageLoaded={true}
/>,
imageElement
);
Note that pageLoaded
is now true
, which enables the real <img>
to load. But the blurred image still shows even though the other is loaded...
Swap the images 🌃🤝🏞
This introduces complexity that requires state
, so we'll need to add it in the constructor:
constructor(props) {
super(props);
this.state = {
imageLoaded: false
};
}
And update our components to be more state
ful:
const BlurredImg = styled.img`
filter: blur(6px);
opacity: ${props => props.shouldBeVisible ? 1 : 0};
transition: opacity 0.2s ease-in;
`;
return (
<React.Fragment>
<BlurredImg
src={smallImageUrl}
data-id={this.props.id}
data-format={this.props.format}
shouldBeVisible={!this.state.imageLoaded}
/>
{this.props.pageLoaded && <img src={fullImageUrl} onLoad={this.handleImageLoaded} />}
</React.Fragment>
);
Note the onLoad
handler - this signals that the real image is ready & we can fade the blurred image out:
handleImageLoaded = () => {
this.setState({
imageLoaded: true
});
}
Great! So we have images loading after initially loading the page. But this only gets us most of the way, since they will all load immediately upon hydration (for all images on the page). So what do we do?
Track visibility 🔎
The react-on-screen library makes for a simple higher-order component that does this, passing an isVisible
prop to the component. But any in-viewport visibility tracker will do. Since this introduces another state
, we'll need to add { imageSeen: false }
to our initial state.
Then, we can watch for updated props and set imageSeen
to true
when the image has been in the viewport, preventing it from being loaded again when scrolling the component off-screen:
componentDidUpdate() {
if (!this.state.imageSeen && this.props.isVisible) {
this.setState(prevState => ({
...prevState,
imageSeen: this.props.isVisible
}));
}
}
And there you have it! An image rendering component that's quick, efficient, and not lame at all!