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 !)
Top comments (4)
Bonjour David, et déjà merci pour tes vidéos qui en tant que dev en formation m'ont appris pas mal de choses, j'apprécie pas mal le format !
Du coup après avoir vu la vidéo de ce sujet, j'ai décidé de le tester sur un projet en cours, car j'aime beaucoup l'effet. Néanmoins, ça ne veut pas marcher de mon côté, malgré plusieurs relectures du code.
J'ai l'impression que le script ne se lance pas, car je ne vois pas de nouvelle image lqip.jpg apparaître, et dans le navigateur, quand je regarde dans la console les éléments html, la balise pour l'image lqip apparaît, mais quand je survole l'url de l'image il est indiqué qu'elle est introuvable.
Bref je me demande ou ça bug. Ton dossier script est bien à la racine du projet ? (Même niveau que les dossiers src ou public ? Ce que j'en déduis par rapport aux chemins de tes fichiers) Ou y a t'il quelque chose à faire pour lancer le script, qui n'est peut-être pas indiqué dans ton tuto, mais qui serait évident pour un dev confirmé ?
Merci d'avance pour ta réponse et à bientôt !
Thomas S
Maybe with the repo it's better :)
github.com/Ngc1987/Mediation-equine
Oui pardon, il faut executer le script qui génère les fichiers. Il ne se lance pas automatiquement parce qu'il y a seulement besoin de l'exécuter quand il y a des nouveaux fichiers. Dans ton cas, avec ton code, essaie de faire ts-node ./LowQualPics.ts dans ton dossier scripts. Il y aura peut etre des erreurs liés aux chemins, notamment, en fonction de la conf de ton repo, j'ai pas exécuté ton code mais le chemin avait l'air bon à vue d'oeil :)
Yes nickel, j'ai lancé la commande mais il me sortait une erreur car le fichier était en typescript, j'ai pas cherché plus loin et ai modifié en js classique pour tester, j'ai relancé la commande, et les fichiers ont bien été générés en lqip ! Merci beaucoup, et en plus j'ai aussi appris à lancer un script maintenant super :)