Зачем оно вообще нужно
Для начала поговорим про цифры. Если вы когда-нибудь делали аналитику сайта - то вы в курсе, что изображения весьма прожорливые. Скорее всего даже более прожорливые, чем ваш бандл. Перед тем, как пытаться оптимизировать что-либо еще - лучше начать именно с картинок.
Однако даже если использовать WEBP
или AVIF
- картинки все равно будут весить неприлично много.
Особенно если их много.
При этом всегда стоит держать в голове, что среднестатистический пользователь вашего сайта скорее всего зайдет с мобильного телефона.
И у него вряд ли будет быстрый мобильный интернет. А медленный интернет накладывает свои ограничения:
- UI может прыгать, пока все картинки не загрузятся (сделаем допущение, что вы не хотите или не можете привести все картинки к одному размеру)
- При медленном интернете или если сервер слишком далеко от пользователя, он будет видеть огрызки картинок, что прямо скажем не красиво.
А еще пользователь у нас очень привередливый и если мы не покажем ему хоть что-то в течении первых пары секунд - скорее всего мы его потеряем. Именно проблему показать хоть что-то и призвана решить прогрессивная загрузка изображений.
Идея заключается в том, что мы показываем пользователю миниатюру в низком разрешении, пока грузим оригинальную картинку.
Для реализации такого подхода я выбрал либу thumbhash, она весит < 4kb и позволяет хранить хеш весом всего 30-40 байт.
Еще примеры можно найти на официальном сайте: https://evanw.github.io/thumbhash/
Генерация thumbhash на клиенте
Рассмотрим кейс, когда пользователь создает новый пост и сам загружает изображение. В этом случае сгенерировать thumb можно прямо на клиенте.
Для начала загрузка изображения - тут все стандартно, подписываемся на onChange
у инпута и достаем оттуда файл
import { useCallback, useState } from "react";const loadImage = (imageUrl: string) => {return new Promise<HTMLImageElement>((resolve) => {const img = new Image();img.src = imageUrl;img.onload = () => {resolve(img);};});};const getImageUrl = (file: File) => {// You can implement your own upload logic here// For example we just create url on client-sideconst url = URL.createObjectURL(file);return url;}export default function App() {const [originalImageSrc, setOriginalImageSrc] = useState("");const handleFileUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {if (!event.target.files?.length) {return;}const file = event.target.files[0];const url = await getImageUrl(file);setOriginalImageSrc(url);},[]);return (<div className="App"><input type="file" onChange={handleFileUpload} /><img src={originalImageSrc} /></div>);}
Теперь нам необходимо считать файл как картинку, чтобы создать миниатюру.
Для этого напишем небольшой хелпер, который загружает картинку по переданному url и возвращает HTMLImageElement
const loadImage = (imageUrl: string) => {return new Promise<HTMLImageElement>((resolve) => {const img = new Image();img.src = imageUrl;img.onload = () => {resolve(img);};});};
Теперь необходимо создать миниатюру изображения, для этого воспользуемся canvas
:
const size = Math.max(img.width, img.height);const w = img.width = Math.round(100 * img.width / size);const h = img.height = Math.round(100 * img.height / size);// Create image thumb (100x100 maximum size)const canvas = document.createElement('canvas');const c = canvas.getContext('2d');canvas.width = w;canvas.height = h;c.drawImage(img, 0, 0, w, h);
После чего достаем из полученной миниатюры ImageData
и скармливаем полученный массив пикселей методу rgbaToThumbHash
.
Полученный Uint8Array я конвертирую в base64 строку, которую мы затем можем сохранить на сервере.
import { rgbaToThumbHash } from 'thumbhash';async function generateImageThumb(img: HTMLImageElement) {const size = Math.max(img.width, img.height);const w = img.width = Math.round(100 * img.width / size);const h = img.height = Math.round(100 * img.height / size);// // Create image thumb (100x100 maximum size)const canvas = document.createElement('canvas');const c = canvas.getContext('2d');canvas.width = w;canvas.height = h;c.drawImage(img, 0, 0, w, h);// Get pixels arrayconst pixels = c.getImageData(0, 0, w, h);// Get thumbhash (Uint8Array)const hash = rgbaToThumbHash(w, h, pixels.data);// convert Uint8Array to base64 stringconst binString = String.fromCodePoint(...hash);return btoa(binString);}
В результате этих махинаций мы получаем такую строку
0ucJJgoHiHeId5eYV4d3hneYiHiPiPc=
Весит она всего 32 байта
new Blob(["0ucJJgoHiHeId5eYV4d3hneYiHiPiPc="]).size // 32
Ее можно сохранить на сервере и присылать вместе с объектом поста
[{id: '1',title: 'Post title',image: '/path/to/image1.png',thumbhash: '0ucJJgoHiHeId5eYV4d3hneYiHiPiPc=',},// ...]
Далее на клиенте нужно преобразовать ее сначала в Uint8Array, а затем в base64 картинку
const thumbToImageSrc = (thumb: string) => {const binString = atob(thumb);const data = Uint8Array.from(binString, (m) => m.codePointAt(0));const src = thumbHashToDataURL(data);return src;};
Наконец нам нужен компонент, который будет запускать загрузку оригинальной картинки и отображать thumbhash миниатюру на время загрузки
type ProgressiveImageProps = {src: string;thumbHash: string;}const ProgressiveImage: React.FC<ProgressiveImageProps> = ({ src: originalImageSrc, thumbHash }) => {const thumb = useMemo(() => thumbHash ? thumbToImageSrc(thumbHash) : null, [thumbHash]);const [imgSrc, setImgSrc] = useState(thumb ? thumb : originalImageSrc);const loadOriginalImage = useCallback(async () => {await loadImage(originalImageSrc);setImgSrc(originalImageSrc);}, [originalImageSrc]);useEffect(() => {if (thumbHash ) {loadOriginalImage();};}, [loadOriginalImage, thumbHash]);return (<img src={imgSrc} />);};
Генерация thumbhash на сервере
Скорее всего будет использоваться FormData
, чтобы загрузить файл на сервер.
export async function POST(request: NextRequest) {const formData = await request.formData();const file = formData.get('file') as File;if (!file) {return NextResponse.json({code: 'FILE_NOT_FOUND'}, {status: 404});}const arrayBuffer = await file.arrayBuffer();const buffer = Buffer.from(arrayBuffer);const url = await uploadFile(buffer); // save file on your server somehowconst thumbhash = await generateThumbhash(buffer); // We need to implement thisreturn NextResponse.json({url,thumbhash})}
При помощи canvas
Чтобы сгенерировать thumbhash, нужно считать данные изображения, только вот сервере у нас нет нативной поддержки canvas и нужно будет воспользоваться сторонней библиотекой, например https://www.npmjs.com/package/canvas
import { createCanvas, loadImage } from "canvas";async function loadImageAndConvertToHashWithCanvas(buffer: Buffer) {const maxSize = 100;// load imageconst image = await loadImage(buffer);const width = image.width;const height = image.height;// calculate new sizeconst scale = Math.min(maxSize / width, maxSize / height);const resizedWidth = Math.floor(width * scale);const resizedHeight = Math.floor(height * scale);// create thumb with new sizeconst canvas = createCanvas(resizedWidth, resizedHeight);const ctx = canvas.getContext("2d");ctx.drawImage(image, 0, 0, resizedWidth, resizedHeight);// get pixels arrayconst imageData = ctx.getImageData(0, 0, resizedWidth, resizedHeight);const rgba = new Uint8Array(imageData.data.buffer);// generate thumbhashconst hash = rgbaToThumbHash(resizedWidth, resizedHeight, rgba);// convert it to base64const base64 = Buffer.from(hash).toString('base64');return base64;}
При помощи sharp
Так же можно воспользоваться библиотекой sharp
import sharp from "sharp";const loadImageAndConvertToHashWithSharp = async (buffer: Buffer) => {// load imageconst sharpImage = sharp(buffer);const imageMetadata = await sharpImage.metadata();// calculate new sizeconst size = Math.max(imageMetadata.width || 1, imageMetadata.height || 1);const w = Math.round(100 * (imageMetadata.width || 1) / size);const h = Math.round(100 * (imageMetadata.height || 1) / size);// create thumb with new sizeconst { data } = await sharpImage.resize({withoutEnlargement: true,width: w,height: h,}).ensureAlpha() // rgbaToThumbHash require 4 channals.raw().toBuffer({ resolveWithObject: true })// generate thumbhashconst hash = rgbaToThumbHash(w, h, data);// convert it to base64const base64 = Buffer.from(hash).toString('base64');return base64;}