Автоматизуємо використання адаптивних зображень для вебсайтів за допомогою Node.js

Привіт! Мене звати Артем, я Full Stack Developer, працюю зазвичай на фрілансі. Основна спеціалізація — високопродуктивні видавничі системи, але деколи займаюсь й іншими проєктами. Нещодавно змінив стек технологій з PHP/MySQL, по яких в мене вже є публікації, на Node.js/MongoDB/React.js. Хочу створити нові статті для портфоліо, обмінятись досвідом з колегами та знайти нових замовників.

Використання адаптивних зображень на вебсайтах пришвидшує їх завантаження та заощаджує трафік на пристроях з малою роздільною здатністю екрана, водночас забезпечує максимальну якість зображень при перегляді на екранах з великою кількістю пікселів. При цьому процес створення та використання адаптивних зображень можливо повністю автоматизувати з використанням JavaScript та сучасних інструментів.

Стандартні способи використання

В HTML існує кілька стандартних способів використання адаптивних зображень. Один з основних — використання старого, але всім відомого елемента <img>. Для цього достатньо вказати різні варіанти зображень в атрибуті srcset разом із їх реальними розмірами, а в атрибуті sizes — правила їх використання. Тоді браузер, після завантаження вебсторінки, завантажить зображення зі зазначеного переліку згідно з прописаними правилами.


<img src="/images/image.jpg"
    srcset="
        /images/image-480.jpg 480w,
        /images/image-800.jpg 800w,
        /images/image-1280.jpg 1280w,
        /images/image-1920.jpg 1920w,
        /images/image-2560.jpg 2560w,
        /images/image-3840.jpg 3840w,
        /images/image-7680.jpg 7680w
    "
    sizes="
        (max-width: 640px) 480px,
        (max-width: 1040px) 800px,
        (max-width: 1600px) 1280px,
        (max-width: 2240px) 1920px,
        (max-width: 3200px) 2560px,
        (max-width: 5760px) 3840px,
        7680px
    "
    alt="Адаптивне зображення"
/>

Альтернативою для цих потреб є використання відносно нового елемента <picture>, який дозволяє вказувати не лише різні розміри, але й різні формати зображень за допомогою додаткового дочірнього елемента <source>. Значення атрибутів srcset і sizes аналогічні тим, що використовуються в елементі <img>, тому для економії місця я їх наводити не буду.


<picture>
    <source type="image/webp" srcset="..." sizes="...">
    <source type="image/png" srcset="..."  sizes="...">
    <img src="/images/image.jpg" srcset="..." sizes="..."
        alt="Адаптивне зображення">
</picture> 

Можна також за допомогою CSS використовувати адаптивні зображення як фонові для блоків, які змінюють свій розмір залежно від медіазапитів. Але це екзотичний та незручний спосіб, який використовується доволі рідко, тільки коли є нагальна необхідність.

Окрім адаптації зображень за розміром екрана також існує адаптація за орієнтацією зображення. Однак ця тема не розглядається в статті з двох причин.

По-перше, орієнтація зображення зазвичай залежить від його вмісту. Наприклад, створення альбомних зображень для вертикальних об’єктів, таких як Ейфелева вежа, може бути складним завданням. По-друге, змінювати орієнтацію зображення необхідно вручну для правильного обрізання з врахуванням його вмісту, що може бути трудомістким процесом. Можливо, в майбутньому штучний інтелект дозволить автоматизувати й цей процес, але наразі це є додатковими витратами часу.

Саме тому одночасне використання зображень з різними орієнтаціями є рідкісним для адаптивних сайтів. Найчастіше вони зустрічаються на маленьких сайтах, де розробник заздалегідь вручну підготував зображення зі всіма можливими пропорціями.

В будь-якому випадку, при використанні на вебсторінці багатьох зображень та їх адаптивних копій доведеться заповнювати атрибути srcset та sizes великою кількістю даних. А якщо розміри всіх зменшених зображень на вебсайті уніфікувати, ці дані можуть бути зайвими, оскільки процес створення та використання адаптивних зображень можна легко автоматизувати за допомогою JavaScript.

Загальні налаштування

Почнемо зі збереження в одному файлі всіх налаштувань для зручного редагування. Окрім типових для Node.js параметрів ip, port, root, public та log також додамо в нього записи для redis, cache, image. З додатковою інформацією про параметри налаштування пакунків redis та lru-cache ви можете ознайомитись на їх офіційних сторінках, а про налаштування зображень я розповім детальніше.


export default {
    ip: '127.0.0.1',
    port: 8888,
    root: import.meta.dirname,
    public: import.meta.dirname + '/public',
    log: import.meta.dirname + '/log',
    redis: {
        socket: {
            host: 'localhost',
            port: 6379,
            connectTimeout: 3_000,
            keepAlive: 3_000
        },
        database: 1
    },
    cache: {
        max: 10_000,
        maxSize: 512 * 1_024 ** 2,
        maxEntrySize: 1 * 1_024 ** 2
    },
    image: {
        path: import.meta.dirname + '/images',
        widths: [480, 800, 1280, 1920, 2560, 3840, 7680],
        types: {
            'image/jpeg': 'jpg',
            'image/png': 'png',
            'image/gif': 'gif'
        },
        maxSize: 16 * 1024 ** 2,
        multiples: false,
        quality: 0.8,
        hash: 'md5'
    }
}

Властивість path зберігає шлях до збережених зображень, widths — масив з уніфікованими розмірами зменшених зображень, types — об’єкт з дозволеними MIME-типами та їх відповідними розширеннями для файлів, maxSize — максимальний дозволений розмір оригінального файлу зображення, multiples — ознака дозволу завантажень декількох зображень одночасно, quality — якість стиснення зменшеного зображення, hash — назва алгоритму хешування завантаженого файлу зображення.


import redis from 'redis';
import config from './config.js';

const client = redis.createClient(config.redis).on('connect', () => {
    const server = `${config.redis.socket.host}:${config.redis.socket.port}`;
    console.log(`Redis connected to redis://${server}`);
});
await client.connect();

export default client;

Також для зручності використання можна створити два окремих модулі для ініціалізації пакунків redis та lru-cache. Зверніть увагу, що у вашій системі вже повинен бути встановлений та налаштований сервер Redis. Опис цього процесу виходить за межі даної статті.


import { LRUCache } from 'lru-cache';
import config from './config.js';

export default new LRUCache({
    ...config.cache,
    sizeCalculation: value => {
            return Buffer.byteLength(value);
    }
});

Робота безпосередньо зі зображенням

Для роботи зі зображеннями створимо окремий модуль image.js з трьома основними порожніми функціями fetch(), save() та remove() для отримання, збереження та видалення зображення відповідно. Додатково створимо об’єкт types з описаного раніше config.image.types, інвертуючи назви властивостей та їх значення для зручності в подальшому використанні.

Також додамо спільну допоміжну функцію hashToPath(), яка створює реальний шлях до файлу зображення на основі його хешу. Цей шлях складається також зі вкладених тек, необхідних для рівномірного розподілу великої кількості файлів. Їх використання дає можливість уникати обмеження на кількість файлів в одній папці. Ще вони позбавляють незручностей при завантаженні переліку великої кількості файлів з директорії під час віддаленого доступу на сервер менеджером файлів.


import fsa from 'fs/promises';
import fse from 'fs-extra';
import formidable from 'formidable';
import canvas from 'canvas';
import redis from './redis.js';
import cache from './cache.js';
import config from './config.js';

const types = {};
Object.entries(config.image.types)
.map(type => types[type[1]] = type[0])

const hashToPath = hash => {
    let path = '/';
    for (const i of [0, 1, 2]) {
        path += hash[i] + '/'
    }
    path += hash + '/';
    return path
}

const fetch = async (request, response) => {
    // ...
}

const save = async (request, response, next) => {
    // ...
}

const remove = async (request, response) => {
    // ...
}

export default { fetch, save, remove }

Опишу детальніше код функцій, починаючи з fetch(). Спочатку отримуємо з БД Redis запис з максимальним розміром зображення за його назвою в запиті. Він зберігається у вигляді рядка з шириною та висотою, розділеною латинською літерою x («1920×1080»). Потім ініціалізуємо деякі змінні та константи необхідні для роботи функції (hash, extension, file) та визначимо шлях до файлу за допомогою виклику згаданою раніше спільною функцією hashToPath().

Якщо разом зі зображенням передаються параметри ширини чи висоти його використання у вебсторінці, то за допомогою простого алгоритму підберемо оптимальний розмір зменшеного зображення з наявних уніфікованих. При відсутності цих параметрів в запиті, що відповідає типовому вибору, повертається найменше зменшене зображення. Зрештою відправляємо у відповідь необхідний файл з кешу, якщо він там присутній, або зчитаємо його з накопичувача та додатково збережемо в кеш після його відправлення.


const fetch = async (request, response) => {
    const value = await redis.get(request.params.name);
    if (!value) return response.sendStatus(404);
    const [hash, extension] = request.params.name.split('.');
    let path = hashToPath(hash);
    let file = `${config.image.widths[0]}.${extension}`;
    const query = {}
    if ('width' in request.query) {
        query.width = parseInt(request.query.width)
    };
    if ('height' in request.query) {
        query.height = parseInt(request.query.height);
    }
    if (query?.width) {
        const size = value.split('x').map(Number);
        const image = { width: size[0], height: size[1] };
        if (query.width < image.width) {
            const delta = { old: Infinity, new: 0 }; 
            const width = config.image.widths.reduce((closest, width) => {
                if (query?.height && (query.height > query.width)) {
                    const height = width / (image.width / image.height);
                    if (height > image.height) return closest;
                    delta.new = Math.abs(query.height - height);
                } else {
                    if (width > image.width) return closest;
                    delta.new = Math.abs(query.width - width);
                }
                if (delta.new <= delta.old) {
                    delta.old = delta.new;
                    return width;
                }
                return closest;
            }, config.image.widths[0]);
            file = `${width}.${extension}`;
         }
    }
    path += file;
    response.set('Content-Type', types[extension]);
    if (cache.has(path)) {
        const blob = cache.get(path)
        response.set('Content-Length', blob.length);
        response.end(blob)
    } else {
        const blob = await fsa.readFile(config.image.path + path);
        response.set('Content-Length', blob.length);
        response.end(blob);
        cache.set(path, blob);
    }
}

Збереження зображення у функції save() відбувається приблизно у зворотньому порядку. Спочатку створюємо об`єкт form за допомогою модуля formidable із зазначеннями налаштувань та отримуємо дані файлу за допомогою функції form.parse(). Перевіряємо наявність помилок, чи файл не порожній, чи відповідає тип файлу переліченим раніше типам в загальних налаштуваннях.

Якщо все добре, перевіряємо наявність запису про нього в БД Redis за його хешем та розширенням. При позитивній відповіді видаляємо завантажений тимчасовий файл для уникнення засмічення файлової системи та повертаємо відповідь, ніби ми його вже зберегли. Ця перевірка необхідна для запобігання дублювання зображень, а унікальний хеш файлу, згенерований автоматично під час його отримання, нам дуже в цьому допомагає.

Після цього зберігаємо оригінал зображення та створюємо за допомогою модуля canvas його зменшені копії в індивідуальній теці на накопичувачі згідно з уніфікованими ширинами зі збереженням оригінальних пропорцій.


const save = async (request, response, next) => {
    const form = formidable({
        maxFileSize: config.image.maxSize,
        multiples: config.image.multiples,
        hashAlgorithm: config.image.hash
    });
    form.parse(request, async (error, fields, files) => {
        let image = { width: 0, height: 0 };
        if (error) return next(
            new Error(`Помилка завантаження файлу (${error})`)
        );
        const file = files.image;
        try {
            if (!file.size) {
                throw Error('Порожній файл зображення')
            };
            if (!(file.mimetype in config.image.types)) {
                throw Error('Заборонений тип зображення ' + file.mimetype)
            };
            const extension = config.image.types[file.mimetype];
            const key = file.hash + '.' + extension;
            const value = await redis.get(key);
            if (value) {
                [image.width, image.height] = value.split('x').map(Number);
                await fse.remove(file.filepath);
            } else {
                let path = hashToPath(file.hash);
                const original = path + 'original.' + extension;
                await fse.move(
                    file.filepath,
                    config.image.path + original,
                    { overwrite: true }
                );
                image = await canvas.loadImage(config.image.path + original);
                await redis.set(key, image.width + 'x' + image.height);
                const ratio = image.width / image.height;
                for (const width of config.image.widths) {
                    if (width > image.width) break;
                    const name = path + width + '.' + extension;
                    const height = Math.round(width / ratio);
                    const c = canvas.createCanvas(width, height);
                    const ctx = c.getContext('2d');
                    ctx.drawImage(image, 0, 0, width, height);
                    const buffer = c.toBuffer(
                        file.mimetype, { quality: config.image.quality }
                    )
                    if (buffer) {
                        await fsa.writeFile(config.image.path + name, buffer);
                    } else {
                        if (file.mimetype !== 'image/gif') {
                            throw Error('Відсутній буфер зображення')
                        }
                    }
                }
            }
            response.json({
                name: key, width: image.width, height: image.height
            });
        } catch (error) {
            console.error(key, image, files.image);
            delete files.image._writeStream;
            fse.remove(file.filepath);
            return next(error)
        }
    })
}

Функція видалення remove() найпростіша. В першу чергу через те, що всі зменшені зображення зберігаються в одній теці разом з оригіналом. І, відповідно, видалити рекурсивно одну теку та запис з БД Redis не викликає складнощів. Але додатково реалізований алгоритм видалення порожніх батьківських тек з метою уникнення засмічення файлової системи.


const remove = async (request, response) => {
    const value = await redis.get(request.params.name);
    if (!value) return response.sendStatus(404);
    let path = hashToPath(request.params.name.substring(0, 32));
    await fsa.rm(config.image.path + path, { recursive: true, force: true });
    let parent = path.substring(0, 6);
    for (let i = 0; i < 3; i ++) {
        const items = await fsa.readdir(config.image.path + parent);
        if (items.length > 0) break;
        await fsa.rmdir(config.image.path + parent);
        parent = parent.substring(0, parent.length - 2);
    }
    redis.del(request.params.name);
    response.end();
}

Після опису трьох функцій, на яких покладена вся основна робота при адаптації зображень, залишилось описати лише приклад їх використання.

Приклад використання

Описаний вище скрипт можливо використовувати різними способами у власному проєкті. Особисто я полюбляю створювати для цих потреб окремий сервер зображень на окремому піддомені на кшталт images.example.com. Для цього достатньо, наприклад, запустити сервер на Node.js з фреймворком Express.js та прописати лише декілька рядків коду для маршрутизатора.


...
import image from './image.js';
...
app.get('/:name', image.fetch);
app.post('/', image.save);
app.delete('/:name', image.remove);
...

Нижче наведено приклад фрагмента коду мовою JavaScript, який виконується на стороні клієнта. Він визначає ширину та висоту зображення згідно з розмірами вже завантаженого найменшого зменшеного зображення. Також він оновлює адресу зображення з новими додатковими даними. При цьому для реалізації завантаження зображення на вимогу та асинхронного відслідковування прокрутки сторінки використовується об’єкт IntersectionObserver.


const imagesObserver = new IntersectionObserver((entries, self) => {
    for (const entrie of entries) {
        if (!entrie.isIntersecting) continue
        const match = entrie.target.src.match(/([^?]*)(\?width=)?/)
        const source = match ? match[1] : entrie.target.src
        const width = entrie.target.offsetWidth * window.devicePixelRatio
        const height = entrie.target.offsetHeight * window.devicePixelRatio
        entrie.target.src = source + `?width=${width}&height=${height}`
        self.unobserve(entrie.target)
    }
}, {
    root: null, rootMargin: '50px', threshold: 0
})

window.onload = () => {
    document.querySelectorAll('#script img')
    .forEach(image => imagesObserver.observe(image))
}

Зверніть увагу на використання маловідомої властивості window.devicePixelRatio. Не всі знають, що вебоглядачі на дисплеях з великою роздільною здатністю для зручності відображення вебсторінки самостійно визначають віртуальні розміри вікна та блоків, які можуть бути менші за реальні. Тому визначати реальний розмір зображення для завантаження можливо тільки якщо перемножити віртуальний розмір на зазначений коефіцієнт.

Щоб правильно визначити оптимальні розміри зменшеного зображення для кожного конкретного місця на вебсайті, необхідно, щоб первинне зображення зайняло всю площу батьківського блоку зі збереженням пропорцій. Для цього потрібно прописати в стилях один рядок.


img { width: 100%; height: auto; }

Нарешті наведу приклад мінімалістичного використання безпосередньо тегу <img> для адаптивного зображення в HTML-коді вашого вебсайту. Зверніть увагу, наскільки він простіший від адаптивного запису, наведеного в прикладі на початку статті. По-перше, через винесення в код JavaScript на стороні клієнта алгоритму визначення розміру використання зображення в вебсторінці. По-друге, через винесення в код JavaScript на стороні сервера алгоритму підбору оптимального зменшеного зображення під необхідні розміри.


<div id="script">
    <h2>Адаптація JavaScript</h2>
    <p>
       <img alt="Адаптивне зображення"
         src="/708e6273bf6d522ba97672b55754db14.jpg" />
    </p>
</div>

В результаті запис елемента в HTML-коді для використання адаптивного зображення нічим не відрізняється від звичайної інтеграції зображення. Завантаження зображення на сервер здійснюється стандартним HTTP-запитом з методом POST та назвою image для параметра файлу. А для видалення зображення достатньо використати метод DELETE та вказати назву файлу в адресі.

Автоматизовувати процес створення, використання та видалення адаптивних зображень на вебсайтах можливо різноманітними способами. В статті наведено лише один з багатьох можливих прикладів реалізації, оснований як на вимогах до конкретного проєкту, так і на моїх особистих вподобаннях. А кожен розробник повинен реалізовувати ту модель, яку вважає за потрібне, спираючись на власну політику роботи з адаптивними зображеннями.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось2
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі