L’image d’un blog est un element clef des premières impressions que donne un article. Mais les images sont parfois lourdes à charger, et le temps de chargement est aussi intégral à la première impression. Comment concilier ces deux impératifs qui semblent antinomiques ?
Le site Medium semble réussir à le faire, avec un effet sympa de zoom ou de flou et l’image qui apparaît (sans que du coup la mise en page ne saute). Comment est-ce qu’ils font ça ? Et surtout comment est-ce que nous on peut faire ça ?
Voici une vidéo d'explication, pour le code détaillée, continuez à lire plus bas :)
Le principe de base
Pour faire cet effet-là, il y a trois choses à faire :
- créer une version basse qualité de l’image, un “Low Quality Image Placeholder” ou LQIP
- afficher cette image LQIP avec un filtre de flou
- sous cette image LQIP, avec exactement la même position et les mêmes dimensions, afficher l’image définitive
- mettre un évent listener qui se déclenche quand l’image finit de charger
- quand l’évent de déclenche changer l’état pour stocker le fait que le chargement est fait, et avec cet état, faire transitionner l’opacité de l’image basse qualité a 0
Créer l’image basse qualité
Pour ce genre de manipulation, je crée en général un dossier scripts/ dans lequel je met ... mes scripts.
Pour ce script ci, qui va lister tous les fichiers jpg et en faire des versions basse définition, on a besoin de deux librairies : glob, qui permet de faire le listing des fichiers d’un dossier qui suivent une structure de nom de fichier (genre *.jpg) et sharp qui permet de faire de la manipulation d’image :
yarn add glob sharp -D
Les images que je veux manipuler sont dans le dossier /public/blog (puisque je suis sous NextJS), donc je les liste en ne prenant pas en compte ceux qui finissent en lqip.jpg, et je les redimensionne en 128 pixels de large. Pour ça je crée le script suivant dans scripts/updateImg.ts :
import glob from "glob";
import sharp from "sharp";
glob('../public/blog/**/*.jpg', {}, function (er, files) {
for (let file of files) {
if (file.endsWith('lqip.jpg')) {
continue;
}
sharp(file)
.resize({width: 128})
.toFile(`${file}.lqip.jpg`)
.then( (_data) => {
// console.log(_data);
})
.catch( (err) => { console.error(err); });
}
});
Je rajoute un fichier package.json dans le dossier script pour pas qu'il me fasse d'erreur d’import
{
"name": "lqipmaker",
"type": "module"
}
À présent passons a l’affichage des deux images
Positionnement des deux images
Pour positionner les deux images l’une sur l’autre il nous faut les positionner chacune en absolu dans la même div.
On positionne l'image initiale en top=0, left=0 avec un width de 100%;
import { CSSProperties } from 'react';
const main:CSSProperties = {
position: 'absolute',
width: '100%',
top: 0,
left: 0
};
On pourrait en faire de même pour l’image placeholder mais on veut faire un petit effet de zoom, et du coup on va agrandir l'image placeholder un peu. Pour être certain que l'image soit bien centrée, on va du coup la positionner non plus par rapport à son coin (ce que fait le top / left) mais par rapport à son centre, qu'on place au centre de la div parente, en lui faisant un transform. Pour finir, on applique une transition sur l'opacité pour permettre à l'image de disparaitre de manière animée (et donc faire apparaître l'image en dessous). :
const lqip:CSSProperties = {
position: 'absolute',
width: '100%',
filter: 'blur(10px)',
zIndex: 10,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%) scale(1.1)',
transitionDuration: '500ms',
transitionProperty: 'opacity',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)',
objectFit: 'cover'
};
Le zoom nous permet aussi d'éviter le problème du blur qui fait des bords blancs bizarres. A présent il nous faut mettre des deux images dans une div parente. On va fixer l'aspect ratio de cette div en 16/9e (en lui mettant un paddingBottom à '56.25%',) et cacher ce qui en déborde (avec un overflow: 'hiddden'). Et évidemment un position: 'relative' pour permettre le positionnement absolu des enfants.
const parent:CSSProperties = {
position: 'relative',
paddingBottom:'56.25%',
marginBottom: '2rem',
overflow: 'hidden'
}
Nous avons le chemin vers image dans une variable img, et allons donc également chercher l'image dont le chemin est : img + .lqip.jpg
<div style={parent}>
<img
src={`/blog/${img}.lqip.jpg`}
style={lqip}
/>
<img
style={main}
src={`/blog/${img}`}
/>
</div>
Gestion de l'état de chargement
A présent nous allons créer dans notre composant un état pour stocker le fait ou non que l'image soit chargée :
const [imageLoaded, setImageLoaded] = useState(false);
On va changer cet état quand l'image ce charge, et changer l'opacité de la LQIP en fonction de si l'image est chargée ou non :
<div style={parent}>
<img
src={`/blog/${img}.lqip.jpg`}
style={{...lqip, opacity: imageLoaded? 0: 100 }}
/>
<img
style={main}
src={`/blog/${img}`}
onLoad={() => setImageLoaded(true)}
/>
</div>
Mais quand fait un console.log de l'état de imageLoaded, celui-ci revient toujours false. Pourquoi ? Parce que l'image est en cache et que le chargement a lieu trop tôt.
Comment résoudre cela ? Et bien nous allons déclencher le chargement de l'image après coup.
On va commencer par créer un état qui correspond au chemin vers l'image :
const [imgurl, setImgUrl] = useState<string>(undefined);
Ensuite nous utilisons le chemin de l'image pour mettre à jour ce chemin, une fois le composant chargé :
useEffect(() => {
setImgUrl(`/blog/${img}`)
}, [img]);
Pour finir, on remplace l'attribut src de l'image principale, pour qu'il soit donc affecté plus tard et que l'image charge plus tard :
<img
style={main}
src={imgurl}
onLoad={() => setImageLoaded(true)}
/>
Et voila, un effet style Medium ou Gatsby sur une image dans NextJS / React !
(Si vous avez des questions ou des remarques, n'hésitez pas !)