vue-image-kit — полный toolkit для изображений во Vue 3

В каждом Vue-проекте с изображениями рано или поздно одно и то же: ленивая загрузка через IO, placeholder пока грузится картинка, WebP/AVIF для современных браузеров, srcset для разных плотностей экрана, retry если CDN лежит, graceful fallback при ошибке. Обычно это решается набором разрозненных пакетов — отдельно IO, отдельно blurhash, отдельно что-то для CDN, отдельно sharp-скрипт для предобработки.
vue-image-kit — один пакет, который закрывает весь этот стек. Один компонент <VImage>, пять видов плейсхолдеров, focal point, автоматический srcset (width и density), 12 CDN-провайдеров, build-time импорты, кодирование плейсхолдеров в браузере, CLI для обработки на сборке, Nuxt-модуль с авто-импортами, Vite-плагин. Ноль внешних runtime-зависимостей, tree-shakeable.
Установка
bash
npm install vue-image-kit
ts
// main.ts
import { createApp } from 'vue'
import { VImageKitPlugin } from 'vue-image-kit'
import App from './App.vue'
const app = createApp(App)
app.use(VImageKitPlugin, {
breakpoints: { // глобальные брейкпоинты для art direction
sm: '(max-width: 640px)',
md: '(max-width: 1024px)',
lg: '(min-width: 1025px)',
},
})
app.mount('#app')
После этого <VImage> и v-lazy-img доступны глобально — ничего импортировать в компонентах не нужно.
VImage — все возможности в одном компоненте
Минимум — просто замена <img> с ленивой загрузкой:
vue
<VImage src="/photo.jpg" alt="Фото" />
Полный набор пропов:
vue
<VImage
src="/photo.jpg" <!-- URL или объект { avif, webp, fallback } -->
alt="Описание" <!-- обязательный alt-текст -->
:width="1200" <!-- резервирует место, предотвращает layout shift -->
:height="800"
thumbhash="3OcRJYB4d3h/..." <!-- ThumbHash: декодируется в PNG-плейсхолдер -->
blurhash="LEHV6nWB2yk8..." <!-- BlurHash: рисуется на <canvas> -->
placeholder="data:image/..." <!-- LQIP base64: blur-up плейсхолдер -->
placeholder-mode="blur" <!-- 'blur' | 'color' | 'shimmer' -->
placeholder-color="#1e3a8a" <!-- явный сплошной цвет-плейсхолдер -->
:widths="[400, 800, 1200]" <!-- генерирует srcset-дескрипторы ширин -->
:densities="[1, 2, 3]" <!-- density-дескрипторы 1x/2x/3x (или { 1: …, 2: … } — разные файлы) -->
sizes="(max-width: 768px) 100vw, 50vw" <!-- подсказка браузеру какой размер выбрать -->
:breakpoints="{ xs: '(max-width: 375px)' }" <!-- локальные брейкпоинты (мёрджатся с глобальными) -->
:sources="{ sm: '/photo-mobile.jpg', md: '/photo-tablet.jpg' }" <!-- разные кадры для разных экранов -->
:lazy="true" <!-- ленивая загрузка через IntersectionObserver -->
root-margin="300px" <!-- начать загрузку за 300px до вхождения в viewport -->
:threshold="0" <!-- доля видимости для срабатывания IO -->
fit="cover" <!-- object-fit на <img> -->
:focal="{ x: 0.5, y: 0.3 }" <!-- точка фокуса при fit=cover → object-position -->
fetchpriority="high" <!-- приоритет загрузки (high для LCP-изображений) -->
decoding="async" <!-- не блокировать основной поток при декодировании -->
:max-retries="3" <!-- повторить загрузку N раз при ошибке -->
:retry-delay="500" <!-- начальная задержка перед retry в мс (x2 каждый раз) -->
@load="onLoad" <!-- событие при успешной загрузке -->
@error="onError" <!-- событие при ошибке (после всех retry) -->
>
<template #error> <!-- кастомный UI при неудаче вместо серого прямоугольника -->
<div class="fallback">Не удалось загрузить</div>
</template>
</VImage>
width и height — не просто атрибуты. Они выставляют aspect-ratio на обёртке, так что место под изображение резервируется сразу — никакого layout shift во время загрузки.
Пять видов плейсхолдеров
ThumbHash — современный формат с alpha-каналом
ThumbHash — новый формат плейсхолдера. По сравнению с BlurHash: поддерживает прозрачность (PNG), лучше воспроизводит цвет на фотографиях, чуть короче хеш-строка. Декодируется прямо в браузере в PNG data URL без внешних пакетов.
vue
<VImage
src="/photo.jpg"
alt="Фото"
thumbhash="1+cNHYI3iHeFh3iPh5d4h7ZwZQl4"
:width="1200"
:height="800"
/>
<VImage> сам декодирует хеш и показывает PNG как blur-up плейсхолдер. Ничего импортировать не надо.
Если нужен data URL для кастомной разметки — decodeThumbHash:
ts
import { decodeThumbHash } from 'vue-image-kit'
const dataUrl = decodeThumbHash('1+cNHYI3iHeFh3iPh5d4h7ZwZQl4')
// → 'data:image/png;base64,...'
// Можно использовать как src для обычного <img>
// или как background-image — например с v-lazy-img
Если переданы оба thumbhash и placeholder — placeholder имеет приоритет.
BlurHash — canvas без зависимостей
BlurHash декодируется в <canvas> прямо в браузере. Декодер написан с нуля по спецификации — никакого npm-пакета blurhash в runtime.
vue
<VImage
src="/photo.jpg"
alt="Пейзаж"
blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
:width="1200"
:height="800"
/>
Как работает: на сервере рендерится <div> с aspect-ratio; после монтирования decodeBlurhash(hash, w, h) возвращает Uint8ClampedArray с RGBA-пикселями, который рисуется через ImageData на <canvas>. Canvas плавно затухает когда загружается оригинал.
Если нужны сырые пиксели для чего-то своего:
ts
import { decodeBlurhash } from 'vue-image-kit'
const pixels = decodeBlurhash('LEHV6nWB2yk8pyo0adR*.7kCMdnj', 32, 32)
// pixels: Uint8ClampedArray — RGBA, row-major
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
canvas.getContext('2d')!.putImageData(new ImageData(pixels, 32, 32), 0, 0)
LQIP — base64 blur-up
Классический подход: крошечный JPEG (~20px, ~300 байт) → base64 → filter: blur(20px) → плавный переход к оригиналу. transform: scale(1.05) скрывает размытые края.
vue
<VImage
src="/photo.jpg"
alt="Фото"
placeholder="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAA..."
:width="1200"
:height="800"
/>
Генерируется при обработке через CLI или вручную:
ts
import sharp from 'sharp'
const buffer = await sharp('photo.jpg')
.resize(20)
.jpeg({ quality: 20 })
.toBuffer()
const lqip = `data:image/jpeg;base64,${buffer.toString('base64')}`
Плейсхолдер помечен aria-hidden="true" — экранные читалки его не трогают.
Dominant color — самый дешёвый плейсхолдер
Иногда blur избыточен — достаточно сплошного цвета фона. ThumbHash уже несёт среднюю RGBA в заголовке, так что её можно отдать бесплатно, без декода пикселей и без canvas — 0 байт. Режим placeholderMode="color":
vue
<VImage
src="/photo.jpg"
alt="Фото"
:width="1200"
:height="800"
thumbhash="3OcRJYB4d3h/iIeHeEh3eIhw+j5n"
placeholder-mode="color"
/>
В color-режиме blur/canvas не рендерятся вовсе — VImage показывает один background-color. Если цвет известен заранее (или ThumbHash нет) — передайте его напрямую через placeholder-color, тогда декод не нужен совсем:
vue
<VImage src="/photo.jpg" alt="Баннер" placeholder-color="#1e3a8a" />
Средний цвет можно достать и вручную — прямо из заголовка хеша, без рендера:
ts
import { thumbHashToAverageRGBA, thumbHashToAverageColor } from 'vue-image-kit'
thumbHashToAverageRGBA('3OcRJYB4d3h/iIeHeEh3eIhw+j5n')
// → { r, g, b, a } (каждый канал 0–1)
thumbHashToAverageColor('3OcRJYB4d3h/iIeHeEh3eIhw+j5n')
// → 'rgba(150, 146, 104, 1.000)' — сразу в background-color
Shimmer — анимированный скелетон
Альтернатива blur — бегущий «перелив», как в скелетон-лоадерах. Не требует ни хеша, ни base64 — чистый CSS. Режим placeholderMode="shimmer":
vue
<VImage src="/photo.jpg" alt="Карточка" :width="400" :height="300" placeholder-mode="shimmer" />
Анимация уважает prefers-reduced-motion: reduce — при включённой настройке «уменьшить движение» перелив отключается.
Кодирование плейсхолдеров в браузере
Декодеры — это половина истории. Когда пользователь загружает фото (аватар, вложение, пост), плейсхолдер нужно сгенерировать на лету — прямо в браузере, до отправки на сервер. Тогда blur-up превью показывается мгновенно.
encodeThumbHash и encodeBlurhash принимают File/Blob, HTMLImageElement, HTMLCanvasElement, ImageBitmap или ImageData. Без зависимостей: энкодер ThumbHash — точный порт референса, байт-в-байт совпадает с npm-пакетом thumbhash.
ts
import { encodeThumbHash, encodeBlurhash, decodeThumbHash } from 'vue-image-kit'
async function onFileSelected(file: File) {
const thumbhash = await encodeThumbHash(file)
// → base64-строка; сразу в <VImage :thumbhash="thumbhash">
// или decodeThumbHash(thumbhash) для превью
const blurhash = await encodeBlurhash(file, { componentX: 4, componentY: 3 })
}
Полный сценарий загрузки с мгновенным превью:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { encodeThumbHash } from 'vue-image-kit'
const hash = ref('')
const previewUrl = ref('')
async function handleUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
previewUrl.value = URL.createObjectURL(file)
hash.value = await encodeThumbHash(file) // считается за миллисекунды
// hash отправляем на сервер вместе с файлом — больше его считать не нужно
}
</script>
<template>
<input type="file" accept="image/*" @change="handleUpload" />
<VImage v-if="hash" :src="previewUrl" alt="Превью" :thumbhash="hash" />
</template>
Исходник даунскейлится до maxSize по длинной стороне перед кодированием (для ThumbHash — обязательные ≤100px). Требуется браузер/DOM — на сервере функции бросают исключение.
srcset + sizes
Два способа подключить адаптивные изображения — в зависимости от того, где живут картинки.
Способ 1 — CLI генерирует нарезанные файлы. Манифест содержит готовый srcset с правильными путями:
ts
// images.ts — сгенерировано CLI
{
src: '/images/photo.jpg',
srcset: '/images/photo-400.jpg 400w, /images/photo-800.jpg 800w, /images/photo.jpg 1200w',
// ...
}
vue
<VImage
:src="img.src"
:alt="img.name"
:srcset="img.srcset"
sizes="(max-width: 768px) 100vw, 50vw"
/>
Браузер на мобиле скачает /images/photo-400.jpg (40 kB), на десктопе — /images/photo.jpg (180 kB).
Способ 2 — CDN сам ресайзит по URL-параметрам. widths передаётся CDN-адаптеру, который строит правильные URL:
vue
<script setup lang="ts">
import { imgix } from 'vue-image-kit/cdn'
const cdn = imgix('https://mysite.imgix.net')
</script>
<template>
<VImage
:src="cdn.url('/photo.jpg', { width: 1200 })"
:alt="'Фото'"
:srcset="cdn.srcset('/photo.jpg', [400, 800, 1200])"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<!--
srcset:
https://mysite.imgix.net/photo.jpg?w=400&auto=format 400w,
https://mysite.imgix.net/photo.jpg?w=800&auto=format 800w,
https://mysite.imgix.net/photo.jpg?w=1200&auto=format 1200w
-->
</template>
CDN получает запрос с нужными параметрами и отдаёт файл нужного размера. Никакой предобработки на стороне проекта.
buildSizes — строит строку sizes из брейкпоинтов:
ts
import { buildSizes } from 'vue-image-kit'
const breakpoints = {
sm: '(max-width: 640px)',
md: '(max-width: 1024px)',
}
buildSizes({ sm: '100vw', md: '50vw', default: '33vw' }, breakpoints)
// → '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
Density-дескрипторы — 1x / 2x / 3x
Для изображений фиксированного размера — иконок, аватаров, логотипов — ширины не нужны: важна плотность пикселей экрана. Тут работают density-дескрипторы (1x/2x/3x), и браузер сам выбирает файл под devicePixelRatio. densities имеет приоритет над widths и не использует sizes (смешивать w и x в одном srcset нельзя).
Проп принимает две формы. Главная — карта плотность → файл, где каждый 2×/3× это реально более крупный файл:
vue
<VImage
src="/avatar.png"
alt="Аватар"
:width="64"
:height="64"
:densities="{ 1: '/avatar.png', 2: '/avatar@2x.png', 3: '/avatar@3x.png' }"
/>
<!-- → srcset="/avatar.png 1x, /avatar@2x.png 2x, /avatar@3x.png 3x" -->
Вторая форма — список: повторяет один src для каждой плотности. Полезна только когда URL сам «знает» про DPR (CDN-эндпоинт):
vue
<VImage src="https://cdn.example.com/avatar?dpr=auto" alt="Аватар" :densities="[1, 2, 3]" />
Та же логика доступна утилитой generateDensitySrcset — со строкой или картой:
ts
import { generateDensitySrcset } from 'vue-image-kit'
generateDensitySrcset({ 1: '/a.png', 2: '/a@2x.png' }, [1, 2])
// → '/a.png 1x, /a@2x.png 2x'
Focal point — управление кропом
При fit="cover" изображение обрезается под контейнер — и часто «съедает» главное: лицо, товар, логотип. Проп :focal="{ x, y }" (доли 0–1) задаёт точку, которая должна остаться в кадре, маппясь в CSS object-position:
vue
<!-- { x: 0.5, y: 0.3 } — верхняя середина: типично для портретов -->
<VImage
src="/portrait.jpg"
alt="Сотрудник"
:width="400"
:height="400"
fit="cover"
:focal="{ x: 0.5, y: 0.3 }"
/>
Значения клампятся в диапазон 0–1 и применяются и к основному <img>, и к плейсхолдеру — так что blur-up и финальное изображение выровнены по одной точке. Дёшево по реализации, но критично для e-commerce и редакционных сеток.
WebP / AVIF
Когда src — объект вместо строки, компонент рендерит <picture> с нужными <source>:
vue
<VImage
:src="{
avif: '/photo.avif',
webp: '/photo.webp',
fallback: '/photo.jpg',
}"
alt="Фото"
:width="1200"
:height="800"
/>
Рендерится:
html
<picture>
<source srcset="/photo.avif" type="image/avif" />
<source srcset="/photo.webp" type="image/webp" />
<img src="/photo.jpg" alt="Фото" width="1200" height="800" />
</picture>
Браузер берёт первый поддерживаемый формат. fallback обязателен. Можно передать только webp — тогда один <source>, без AVIF.
Responsive art direction
Нужны принципиально разные кадры для разных экранов — не просто разный размер одного изображения, а другой кроп или другая композиция. Реализовано через именованные брейкпоинты:
ts
// main.ts — один раз глобально
app.use(VImageKitPlugin, {
breakpoints: {
sm: '(max-width: 640px)',
md: '(max-width: 1024px)',
},
})
vue
<!-- В компоненте — только ключи -->
<VImage
src="/hero-desktop.jpg"
alt="Hero"
:sources="{
sm: '/hero-mobile.jpg',
md: '/hero-tablet.jpg',
}"
/>
Рендерится <picture> с <source media="..."> — порядок выставляется автоматически в правильном ascending max-width порядке (обязательное требование <picture>).
Можно добавить локальные брейкпоинты в конкретный компонент — они мёрджатся с глобальными:
vue
<VImage
src="/product-desktop.jpg"
alt="Продукт"
:breakpoints="{
xs: '(max-width: 375px)',
wide: '(min-width: 1600px)',
}"
:sources="{
xs: '/product-xs.jpg',
sm: '/product-mobile.jpg',
md: '/product-tablet.jpg',
wide: '/product-wide.jpg',
}"
/>
Art direction и WebP/AVIF работают вместе — один <picture> содержит и медиа-source, и форматные source:
vue
<VImage
:src="{ avif: '/hero.avif', webp: '/hero.webp', fallback: '/hero.jpg' }"
:sources="{ sm: '/hero-mobile.jpg', md: '/hero-tablet.jpg' }"
alt="Hero"
/>
html
<picture>
<source media="(max-width: 640px)" srcset="/hero-mobile.jpg" />
<source media="(max-width: 1024px)" srcset="/hero-tablet.jpg" />
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero" />
</picture>
Ленивая загрузка через IntersectionObserver
Вместо loading="lazy" — собственный IntersectionObserver. Это даёт точный контроль: когда начинать загрузку, какой порог видимости использовать.
vue
<!-- Дефолт: начать загрузку за 200px до вхождения в viewport -->
<VImage src="/photo.jpg" alt="Фото" />
<!-- Начать за 500px — изображения успеют загрузиться к скроллу -->
<VImage src="/photo.jpg" alt="Фото" root-margin="500px" />
<!-- Загружать только когда 50% изображения в viewport -->
<VImage src="/photo.jpg" alt="Фото" :threshold="0.5" />
<!-- Отключить lazy — загрузить сразу (above the fold) -->
<VImage src="/hero.jpg" alt="Hero" :lazy="false" />
IO pooling — компоненты с одинаковой конфигурацией rootMargin + threshold делят один IntersectionObserver. При 50+ изображениях на странице это важно: не 50 отдельных observer-ов, а один.
SSR: на сервере IO недоступен. Рендерится нативный <img loading="lazy"> с src и alt. После гидрации onMounted настраивает IO как обычно.
Error state и retry с exponential backoff
Изображение не загрузилось — по умолчанию показывается серый прямоугольник с SVG-иконкой сломанного изображения. Кастомный fallback — через слот #error:
vue
<VImage src="/missing.jpg" alt="Фото" :width="400" :height="300">
<template #error>
<div class="error-placeholder">
<img src="/no-image.svg" alt="" />
<p>Изображение недоступно</p>
</div>
</template>
</VImage>
Событие @error для логирования или переключения на резервный URL:
vue
<script setup lang="ts">
function handleError(e: Event) {
analytics.track('image_load_failed', { src: currentSrc })
}
</script>
<template>
<VImage src="/photo.jpg" alt="Фото" @error="handleError" />
</template>
Автоматический retry — через maxRetries и retryDelay. Каждая следующая попытка ждёт вдвое дольше:
vue
<!-- 3 попытки: 500ms → 1000ms → 2000ms -->
<VImage
src="/flaky-cdn.jpg"
alt="Фото"
:max-retries="3"
:retry-delay="500"
/>
Логика retry встроена в composable useImage — компонент просто передаёт пропсы.
fetchpriority и decoding
Два пропса для управления приоритетом загрузки — напрямую влияют на LCP:
vue
<!-- Hero-изображение: загружать первым, не блокировать рендер -->
<VImage
src="/hero.jpg"
alt="Hero"
:lazy="false"
fetchpriority="high"
decoding="async"
/>
<!-- Изображения в подвале: минимальный приоритет -->
<VImage
src="/footer-banner.jpg"
alt="Баннер"
fetchpriority="low"
/>
fetchpriority="high" на LCP-изображении сигнализирует браузеру: грузить это раньше всего, не ждать анализа остального HTML. В связке с decoding="async" — декодирование не блокирует основной поток.
v-lazy-img — ленивые фоновые изображения
Когда нельзя использовать <VImage> — CSS-фоны, кастомные обёртки — директива v-lazy-img добавляет ленивую загрузку background-image на любой элемент.
vue
<!-- Простая строка — только src -->
<div v-lazy-img="'/background.jpg'" class="hero" />
<!-- Объект с плейсхолдером и колбеками -->
<div
v-lazy-img="{
src: '/background.jpg',
placeholder: 'data:image/jpeg;base64,...',
rootMargin: '100px',
onLoad: () => console.log('loaded'),
onError: (e) => console.error('failed', e),
}"
class="hero"
/>
Поведение: при монтировании создаётся IO; при пересечении с viewport placeholder сразу ставится как background-image; параллельно в фоне грузится оригинал через new Image(); при загрузке — замена. При обновлении директивы — observer пересоздаётся с новыми опциями. При размонтировании — observer.disconnect().
Типизированные опции:
ts
import type { LazyImgOptions } from 'vue-image-kit'
const bgOptions: LazyImgOptions = {
src: '/hero.jpg',
placeholder: 'data:image/jpeg;base64,...',
rootMargin: '150px',
onLoad: () => analytics.track('hero_loaded'),
onError: (e) => logger.error('hero_failed', e),
}
vue
<div v-lazy-img="bgOptions" class="hero-section" />
useBackgroundImage — ленивый и адаптивный фон
Директива v-lazy-img закрывает ленивую загрузку фона, но не умеет srcset. Композабл useBackgroundImage — её продвинутый вариант: ленивая загрузка плюс адаптивность через image-set() (CSS-аналог srcset) плюс blur-up. Возвращает реактивный :style, который вы навешиваете сами.
vue
<script setup lang="ts">
import { useBackgroundImage } from 'vue-image-kit'
const { target, style, isLoaded } = useBackgroundImage('/hero.jpg', {
placeholder: 'data:image/jpeg;base64,/9j/...', // блюрится, пока грузится оригинал
densities: [1, 2], // → image-set(url(...) 1x, url(...) 2x)
rootMargin: '300px',
})
</script>
<template>
<section ref="target" :style="style" class="hero">
<h1 v-show="isLoaded">Заголовок</h1>
</section>
</template>
<style scoped>
.hero { width: 100%; height: 60vh; }
</style>
Возвращает { target, style, status, isLoaded, isLoading, load }. target вешается через template ref, style — через :style; load() запускает загрузку вручную при lazy: false. SSR-safe — загрузка откладывается до клиента. Опции: placeholder, densities, type (MIME-подсказка для image-set()), lazy, rootMargin, threshold, transition, backgroundSize, backgroundPosition.
useImage — headless composable
Нужна полная свобода разметки — useImage даёт весь state machine без компонента:
ts
const {
status, // Ref<'idle' | 'loading' | 'loaded' | 'error'>
isLoaded, // ComputedRef<boolean>
isError, // ComputedRef<boolean>
imgAttrs, // ComputedRef<{ src, srcset?, sizes?, style }> — v-bind на <img>
observe, // (el: Ref<HTMLElement | null>) => void
onImgLoad, // () => void — вызвать из @load
onImgError, // () => void — вызвать из @error
} = useImage({
src: '/photo.jpg',
widths: [400, 800, 1200],
sizes: '(max-width: 768px) 100vw, 50vw',
lazy: true,
rootMargin: '200px',
maxRetries: 3,
retryDelay: 500,
})
Переходы: idle → loading → loaded или idle → loading → error. При lazy: true — переход в loading когда элемент входит в viewport. При lazy: false — сразу в onMounted.
Пример с полной кастомной разметкой:
vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useImage } from 'vue-image-kit'
const containerRef = ref<HTMLElement | null>(null)
const { status, isLoaded, isError, imgAttrs, observe, onImgLoad, onImgError } = useImage({
src: '/photo.jpg',
widths: [400, 800, 1200],
sizes: '(max-width: 768px) 100vw, 50vw',
})
onMounted(() => observe(containerRef))
</script>
<template>
<div ref="containerRef" class="image-wrapper">
<!-- Skeleton пока idle -->
<div v-if="status === 'idle'" class="skeleton" />
<!-- Изображение появляется с transition -->
<img
v-if="status === 'loading' || isLoaded"
v-bind="imgAttrs"
alt="Фото"
:class="{ 'is-visible': isLoaded }"
@load="onImgLoad"
@error="onImgError"
/>
<!-- Кастомный error state -->
<div v-if="isError" class="error-state">
<IconBrokenImage />
<button @click="retry">Повторить</button>
</div>
</div>
</template>
<style scoped>
img { opacity: 0; transition: opacity 0.4s ease; }
img.is-visible { opacity: 1; }
</style>
useImagePreloader
Предзагрузка пакета изображений перед навигацией — для галерей и слайдеров:
vue
<script setup lang="ts">
import { useImagePreloader } from 'vue-image-kit'
const { preload, progress, isComplete, errors } = useImagePreloader()
// При hover на следующий слайд — начинаем грузить заранее
async function onNextHover() {
await preload(['/slide-2.jpg', '/slide-3.jpg', '/slide-4.jpg'])
// Все изображения закешированы — переход мгновенный
}
</script>
<template>
<div class="slider">
<!-- Прогресс предзагрузки -->
<div v-if="!isComplete" class="preload-progress">
Загрузка {{ Math.round(progress * 100) }}%
</div>
<button
@click="goNext"
@mouseover="onNextHover"
>
Следующий →
</button>
</div>
</template>
progress — ComputedRef<number> от 0 до 1. errors — массив URL, которые не загрузились.
generatePreloadLink — LCP через preload hint
Для SSR: генерирует <link rel="preload"> который браузер видит в <head> ещё до парсинга HTML-тела. Критически важно для LCP.
ts
import { generatePreloadLink, generateSrcset } from 'vue-image-kit'
const srcset = generateSrcset('/hero.jpg', [640, 1024, 1920])
const linkTag = generatePreloadLink('/hero.jpg', {
srcset,
sizes: '100vw',
})
// → '<link rel="preload" as="image" href="/hero.jpg" imagesrcset="..." imagesizes="100vw">'
В Nuxt — через useHead:
vue
<script setup lang="ts">
import { generatePreloadLink } from 'vue-image-kit'
useHead({
link: [{
innerHTML: generatePreloadLink('/hero.jpg', {
srcset: generateSrcset('/hero.jpg', [640, 1024, 1920]),
sizes: '100vw',
}),
}],
})
</script>
CDN-адаптеры
vue-image-kit/cdn — URL-билдеры для 12 провайдеров. Без зависимостей, чистые функции, единый интерфейс .url() / .srcset(). Tree-shakeable — в бандл попадает только то что импортируешь.
ts
import {
cloudinary, imgix, bunny, sanity, storyblok, contentful, vercel,
cloudflare, imagekit, twicpics, netlify, gumlet,
} from 'vue-image-kit/cdn'
Cloudinary:
ts
const cdn = cloudinary({ cloudName: 'my-cloud' })
cdn.url('photo.jpg', { width: 800, format: 'webp', quality: 'auto' })
// → https://res.cloudinary.com/my-cloud/w_800,q_auto,f_webp/image/upload/photo.jpg
cdn.srcset('photo.jpg', [400, 800, 1200])
// → 'https://...w_400,... 400w, https://...w_800,... 800w, ...'
imgix:
ts
const cdn = imgix('https://mysite.imgix.net')
cdn.url('photo.jpg', { width: 800, dpr: 2 })
// → https://mysite.imgix.net/photo.jpg?w=800&dpr=2&auto=format
cdn.srcset('photo.jpg', [400, 800, 1200])
Bunny CDN:
ts
const cdn = bunny('https://myzone.b-cdn.net')
cdn.url('photo.jpg', { width: 800, height: 600, format: 'webp', quality: 85 })
Sanity:
ts
const cdn = sanity({ projectId: 'abc123', dataset: 'production' })
cdn.url('image-abc123-800x600-jpg', { width: 400 })
Storyblok:
ts
const cdn = storyblok()
cdn.url('https://a.storyblok.com/f/12345/photo.jpg', { width: 800 })
Contentful:
ts
const cdn = contentful()
cdn.url('https://images.ctfassets.net/space/token/photo.jpg', { width: 800, format: 'webp' })
Vercel Image Optimization:
ts
const cdn = vercel({ origin: 'https://myapp.vercel.app' })
cdn.url('/photo.jpg', { width: 800, quality: 75 })
// → https://myapp.vercel.app/_vercel/image?url=%2Fphoto.jpg&w=800&q=75
Cloudflare Images:
ts
const cdn = cloudflare('https://example.com')
cdn.url('/photo.jpg', { width: 800, format: 'webp' })
// → https://example.com/cdn-cgi/image/width=800,format=webp/photo.jpg
ts
const cdn = imagekit('https://ik.imagekit.io/your_id')
cdn.url('photo.jpg', { width: 800, format: 'webp' })
// → https://ik.imagekit.io/your_id/photo.jpg?tr=w-800,f-webp
TwicPics:
ts
const cdn = twicpics('https://demo.twic.pics')
cdn.url('photo.jpg', { width: 800, format: 'webp' })
// → https://demo.twic.pics/photo.jpg?twic=v1/resize=800/output=webp
Netlify Image CDN:
ts
const cdn = netlify({ origin: 'https://myapp.netlify.app' })
cdn.url('/photo.jpg', { width: 800, format: 'webp', quality: 75 })
// → https://myapp.netlify.app/.netlify/images?url=%2Fphoto.jpg&w=800&fm=webp&q=75
Gumlet:
ts
const cdn = gumlet('https://demo.gumlet.io')
cdn.url('photo.jpg', { width: 800, format: 'webp' })
// → https://demo.gumlet.io/photo.jpg?w=800&format=webp
Использование с VImage — подставляешь готовый srcset:
vue
<script setup lang="ts">
import { cloudinary } from 'vue-image-kit/cdn'
const cdn = cloudinary({ cloudName: 'my-cloud' })
</script>
<template>
<VImage
src="/photo.jpg"
alt="Фото"
:srcset="cdn.srcset('/photo.jpg', [400, 800, 1200])"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</template>
CLI — обработка изображений на сборке
Полный pipeline: resize → WebP/AVIF → LQIP → BlurHash → ThumbHash → TypeScript-манифест. Одна команда.
bash
npm install sharp --save-dev # sharp — единственная зависимость CLI
npm install thumbhash --save-dev # для --thumbhash флага
bash
npx vue-image-kit generate \
--input ./src/images \
--output ./public/images \
--widths 400,800,1200 \
--formats jpg,webp,avif \
--quality '{"jpg":85,"webp":80,"avif":65}' \
--lqip \
--blurhash \
--thumbhash \
--manifest ./src/assets/images.ts \
--public-path /images \
--concurrency 8
Все флаги:
| Флаг | Дефолт | Описание |
|---|---|---|
--input <dir> |
./src/images |
Исходная директория |
--output <dir> |
./public/images |
Выходная директория |
--widths <list> |
400,800,1200 |
Ширины через запятую |
--formats <list> |
jpg,webp,avif |
Форматы |
--quality <json> |
{"jpg":85,...} |
Качество по форматам |
--manifest <path> |
— | Путь для TypeScript-манифеста |
--public-path <str> |
/images |
URL-префикс в манифесте |
--lqip / --no-lqip |
включён | Генерировать base64 LQIP |
--blurhash / --no-blurhash |
включён | Генерировать BlurHash |
--thumbhash / --no-thumbhash |
выключен | Генерировать ThumbHash |
--clean |
— | Очистить output перед генерацией |
--dry-run |
— | Превью без записи файлов |
--skip-existing |
— | Пропустить уже обработанные |
--concurrency <n> |
4 |
Параллельных воркеров |
--watch |
— | Следить за input, регенерировать при изменении |
Config file вместо флагов — vue-image-kit.config.js в корне проекта:
js
// vue-image-kit.config.js
export default {
input: './photos',
output: './public/images',
widths: [480, 960, 1440],
formats: ['jpg', 'webp', 'avif'],
quality: { jpg: 85, webp: 80, avif: 65 },
manifest: './src/assets/images.ts',
publicPath: '/images',
blurhash: true,
thumbhash: true,
}
На выходе — TypeScript-манифест с полными данными по каждому файлу:
ts
export interface ImageData {
name: string
src: string
webp: string
avif: string
srcset: string
src400: string
src800: string
src1200: string
width: number
height: number
placeholder: string // base64 LQIP
blurhash: string
thumbhash: string
}
export const images: ImageData[] = [
{
name: 'photo-1',
src: '/images/photo-1.jpg',
webp: '/images/photo-1.webp',
avif: '/images/photo-1.avif',
srcset: '/images/photo-1-400.jpg 400w, /images/photo-1-800.jpg 800w, /images/photo-1.jpg 1200w',
width: 1200,
height: 800,
placeholder: 'data:image/jpeg;base64,/9j/...',
blurhash: 'LRB|jIkEDhj[EAozngay?wo#Riof',
thumbhash: '1+cNHYI3iHeFh3iPh5d4h7ZwZQl4',
// ...
},
]
Всё предвычислено на сборке. Никаких запросов к API при рендере.
Vite plugin
Альтернатива CLI — интеграция прямо в Vite lifecycle. Запускается на buildStart, в dev-режиме — при изменении исходников.
ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { vueImageKit } from 'vue-image-kit/vite'
export default defineConfig({
plugins: [
vue(),
vueImageKit({
input: './src/images',
output: './public/images',
widths: [400, 800, 1200],
formats: ['jpg', 'webp', 'avif'],
manifest: './src/assets/images.ts',
blurhash: true,
thumbhash: true,
}),
],
})
Все опции CLI поддерживаются. sharp должен быть установлен как dev dependency.
Build-time импорты — метаданные прямо из import
Самое интересное в Vite-плагине — он резолвит импорты с query-суффиксами. Не нужно вручную прокидывать пути, размеры и хеши: метаданные приходят прямо в JS на этапе сборки. Это связывает CLI, компонент и плагин в единый бесшовный пайплайн:
ts
import meta from './photo.jpg?vik'
// → { src, srcset, webp, avif, width, height, placeholder, blurhash, thumbhash, name, src400, … }
import hash from './photo.jpg?thumbhash'
// → 'base64string'
Спред прямо на <VImage>:
vue
<script setup lang="ts">
import meta from './hero.jpg?vik'
</script>
<template>
<VImage
:src="meta.src"
:srcset="meta.srcset"
:width="meta.width"
:height="meta.height"
:thumbhash="meta.thumbhash"
alt="Hero"
/>
</template>
?vik ресайзит/кодирует картинку в output (URL по publicPath, как в манифесте) и возвращает полный объект метаданных — тот же, что в CLI-манифесте. ?thumbhash считает только хеш и не пишет файлов. Оба пере-исполняются в dev при изменении исходника.
Для типизации импортов — одна строка в env.d.ts:
ts
/// <reference types="vue-image-kit/vite/client" />
Nuxt 3 — авто-импорты и SSR из коробки
ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['vue-image-kit/nuxt'],
vueImageKit: {
breakpoints: {
sm: '(max-width: 640px)',
md: '(max-width: 1024px)',
},
},
})
После подключения:
<VImage>иv-lazy-imgдоступны глобально без импортов- Все composables автоматически доступны:
useImage,useImagePreloader,useBackgroundImage - Все утилиты автоматически доступны:
generateSrcset,generateDensitySrcset,buildSizes,generatePreloadLink,decodeThumbHash,decodeBlurhash,thumbHashToAverageColor,encodeThumbHash,encodeBlurhash - Canvas и IntersectionObserver активируются только на клиенте — без hydration mismatch
SSR: первый рендер — <img loading="lazy">. После гидрации onMounted включает IO и canvas.
TypeScript — полная типизация
ts
import type {
ImageStatus, // 'idle' | 'loading' | 'loaded' | 'error'
SrcSet, // { avif?: string; webp?: string; fallback: string }
ResponsiveSrc, // Record<string, string> — брейкпоинт → URL
BreakpointMap, // Record<string, string> — брейкпоинт → media query
VImageKitOptions, // { breakpoints?: BreakpointMap }
LazyImgOptions, // { src, placeholder?, rootMargin?, threshold?, onLoad?, onError? }
ObjectFit, // 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
FocalPoint, // { x: number; y: number } — доли 0–1
Densities, // number[] | Record<number, string>
} from 'vue-image-kit'
Bundle size и tree-shaking
| Entry point | Raw | Gzip | Peer deps |
|---|---|---|---|
vue-image-kit ESM |
30.7 kB | 9.8 kB | vue ^3.0 |
vue-image-kit CJS |
22.9 kB | 8.6 kB | vue ^3.0 |
vue-image-kit/cdn ESM |
9.0 kB | 1.9 kB | — |
"sideEffects": false — бандлер исключает всё что не импортируется. Используешь только директиву — BlurHash-декодер и VImage в бандл не попадают:
ts
// Только vLazyImg и его IO-логика попадают в бандл
import { vLazyImg } from 'vue-image-kit'
app.directive('lazy-img', vLazyImg)
Сценарии в реальных проектах
Интернет-магазин — каталог товаров с CDN
Несколько тысяч SKU, изображения в Cloudinary. Требования: быстрый первый экран, красивые плейсхолдеры, WebP/AVIF без ручной генерации, retry если CDN периодически лежит, кастомный fallback для товаров без фото.
ts
// src/cdn.ts — конфигурация провайдера в одном месте
import { cloudinary } from 'vue-image-kit/cdn'
const _cdn = cloudinary({ cloudName: 'myshop' })
export const cdn = {
product(publicId: string) {
return {
fallback: _cdn.url(publicId, { width: 800, format: 'jpg', quality: 85 }),
webp: _cdn.url(publicId, { width: 800, format: 'webp', quality: 80 }),
avif: _cdn.url(publicId, { width: 800, format: 'avif', quality: 65 }),
}
},
productSrcset(publicId: string) {
return _cdn.srcset(publicId, [400, 800, 1200])
},
thumb(publicId: string) {
return _cdn.url(publicId, { width: 20, format: 'jpg', quality: 20 })
},
}
vue
<!-- ProductCard.vue -->
<script setup lang="ts">
import { cdn } from '@/cdn'
const props = defineProps<{
product: {
id: string
name: string
imageId: string | null
thumbhash: string | null
}
}>()
</script>
<template>
<article class="product-card">
<VImage
v-if="product.imageId"
:src="cdn.product(product.imageId)"
:alt="product.name"
:width="800"
:height="800"
:thumbhash="product.thumbhash ?? undefined"
:widths="[400, 800]"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
fit="contain"
:max-retries="3"
:retry-delay="800"
>
<template #error>
<div class="no-photo">
<IconPhoto class="no-photo__icon" />
<span>Фото не найдено</span>
</div>
</template>
</VImage>
<!-- Товар без изображения -->
<div v-else class="no-photo">
<IconPhoto class="no-photo__icon" />
</div>
<h3 class="product-card__name">{{ product.name }}</h3>
</article>
</template>
При смене CDN-провайдера — правим только cdn.ts. Все карточки остаются без изменений. ThumbHash-плейсхолдеры хранятся в БД рядом с imageId — вычисляются один раз при загрузке товара в CMS.
Портфолио фотографа — максимальное визуальное качество
Галерея из крупных снимков 3000×2000. Приоритеты: отличный LCP на первом изображении, плавный переход от ThumbHash-плейсхолдера к оригиналу, предзагрузка при hover для мгновенного перехода между работами, преload-хинт в head для SEO и Core Web Vitals.
vue
<!-- pages/gallery/[slug].vue -->
<script setup lang="ts">
import { generatePreloadLink, generateSrcset, useImagePreloader } from 'vue-image-kit'
import { images } from '@/assets/images'
const { slug } = useRoute().params
const gallery = images.filter(img => img.category === slug)
const hero = gallery[0]
const { preload } = useImagePreloader()
// Preload hint для первого изображения — браузер загрузит ещё до парсинга body
useHead({
link: [{
innerHTML: generatePreloadLink(hero.src, {
srcset: hero.srcset,
sizes: '100vw',
}),
}],
})
// При hover на миниатюру — предзагрузить полный размер
async function onThumbHover(img: typeof hero) {
await preload([img.src])
}
</script>
<template>
<section class="gallery">
<!-- Первый снимок — высокий приоритет, без lazy -->
<VImage
:src="{ avif: hero.avif, webp: hero.webp, fallback: hero.src }"
:alt="hero.name"
:width="hero.width"
:height="hero.height"
:thumbhash="hero.thumbhash"
:widths="[640, 1024, 1920]"
sizes="100vw"
:lazy="false"
fetchpriority="high"
decoding="async"
class="gallery__hero"
/>
<!-- Остальные — ленивая загрузка с предзагрузкой -->
<div class="gallery__grid">
<div
v-for="img in gallery.slice(1)"
:key="img.name"
class="gallery__thumb"
@mouseover="onThumbHover(img)"
>
<VImage
:src="{ avif: img.avif, webp: img.webp, fallback: img.src }"
:alt="img.name"
:width="img.width"
:height="img.height"
:thumbhash="img.thumbhash"
:widths="[400, 800]"
sizes="(max-width: 640px) 50vw, 33vw"
root-margin="400px"
class="gallery__thumb-image"
/>
</div>
</div>
</section>
</template>
root-margin="400px" — изображения начинают загружаться за 400px до вхождения в viewport. При быстром скролле картинки успевают загрузиться к моменту когда пользователь их видит.
SaaS-дашборд — пользовательский контент, директива, headless
Приложение, где пользователи загружают аватары, баннеры разделов, вложения к задачам. Изображения в Bunny CDN. Баннеры — CSS-фоны карточек, не <img>. Для некоторых блоков — кастомная разметка с собственными анимациями.
ts
// src/cdn.ts
import { bunny } from 'vue-image-kit/cdn'
const _bunny = bunny('https://myapp.b-cdn.net')
export const cdn = {
avatar: (path: string, size: number) =>
_bunny.url(path, { width: size, height: size, format: 'webp' }),
banner: (path: string) =>
_bunny.url(path, { width: 1200, format: 'webp', quality: 80 }),
attachment: (path: string, w: number) =>
_bunny.url(path, { width: w, format: 'webp', quality: 85 }),
attachmentSrcset: (path: string) =>
_bunny.srcset(path, [400, 800, 1200]),
}
Аватары — через <VImage> напрямую:
vue
<!-- UserAvatar.vue -->
<template>
<VImage
:src="cdn.avatar(user.avatarPath, size)"
:alt="user.displayName"
:width="size"
:height="size"
:thumbhash="user.avatarThumbhash"
:lazy="false"
fetchpriority="high"
fit="cover"
class="avatar"
/>
</template>
Баннеры разделов — через v-lazy-img (CSS-фоны):
vue
<!-- SectionCard.vue -->
<script setup lang="ts">
import type { LazyImgOptions } from 'vue-image-kit'
import { cdn } from '@/cdn'
const props = defineProps<{
section: { title: string; bannerPath: string; bannerLqip: string }
}>()
const bgOptions = computed<LazyImgOptions>(() => ({
src: cdn.banner(props.section.bannerPath),
placeholder: props.section.bannerLqip,
rootMargin: '200px',
onLoad: () => emit('banner-loaded'),
}))
const emit = defineEmits<{ 'banner-loaded': [] }>()
</script>
<template>
<div
v-lazy-img="bgOptions"
class="section-card"
>
<div class="section-card__overlay">
<h2>{{ section.title }}</h2>
</div>
</div>
</template>
<style scoped>
.section-card {
aspect-ratio: 16/9;
border-radius: 12px;
background-size: cover;
background-position: center;
}
</style>
Вложения к задачам с кастомными анимациями — через useImage:
vue
<!-- TaskAttachment.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useImage } from 'vue-image-kit'
import { cdn } from '@/cdn'
const props = defineProps<{
attachment: { path: string; width: number; height: number; thumbhash: string }
}>()
const containerRef = ref<HTMLElement | null>(null)
const { status, isLoaded, isError, imgAttrs, observe, onImgLoad, onImgError } = useImage({
src: cdn.attachment(props.attachment.path, 800),
widths: [400, 800, 1200],
sizes: '(max-width: 768px) 100vw, 600px',
maxRetries: 2,
})
onMounted(() => observe(containerRef))
</script>
<template>
<div ref="containerRef" class="attachment">
<!-- ThumbHash пока idle и loading -->
<img
v-if="!isLoaded && !isError"
:src="decodeThumbHash(attachment.thumbhash)"
alt=""
aria-hidden="true"
class="attachment__placeholder"
/>
<!-- Основное изображение -->
<img
v-if="status === 'loading' || isLoaded"
v-bind="imgAttrs"
:alt="`Вложение`"
:class="['attachment__img', { 'attachment__img--loaded': isLoaded }]"
@load="onImgLoad"
@error="onImgError"
/>
<!-- Error state с retry -->
<button v-if="isError" class="attachment__retry" @click="() => status = 'idle'">
Попробовать снова
</button>
</div>
</template>
<style scoped>
.attachment__placeholder {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(12px);
transform: scale(1.05);
}
.attachment__img {
opacity: 0;
transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.attachment__img--loaded {
opacity: 1;
}
</style>
В одном проекте используются все три способа: <VImage> для стандартных случаев, v-lazy-img для CSS-фонов, useImage когда нужна кастомная анимация. Все три работают через один CDN-адаптер — cdn.ts.
Demo — все фичи вживую

В репозитории есть интерактивное demo-приложение: 16 вкладок с живыми контролами, где можно потрогать каждую возможность пакета. Запуск — одной командой, без отдельной установки (demo подхватывает src/ напрямую через Vite alias):
bash
git clone https://github.com/macrulezru/vue-image-kit.git
cd vue-image-kit
npm run demo
Dev-сервер поднимется на http://localhost:5173. Что внутри:
| Вкладка | Что показывает |
|---|---|
| Basic | Песочница пропов <VImage> — live-контролы для всех опций и все стадии загрузки |
| Blurhash & LQIP | Canvas-blurhash против base64 blur-up бок о бок; ввод произвольной hash-строки |
| Color & Shimmer | Сравнение placeholderMode — blur / ThumbHash / сплошной средний цвет / анимированный shimmer; сырой плейсхолдер рядом с живой загрузкой |
| AVIF / WebP | Переключение форматов через <picture>, определение поддержки браузером, сравнение размеров |
| srcset | Три превью под разные sizes — currentSrc меняется; live-редактор sizes |
| Density 1x/2x/3x | Density-дескрипторы для фиксированных размеров: map-форма с разными файлами, реальный currentSrc под текущий DPR |
| Responsive sources | Art direction по именованным брейкпоинтам — переключение <source media="..."> |
| Focal point | :focal="{ x, y }" → object-position с перетаскиваемым маркером над обрезанным кадром |
| Lazy Load | 20+ изображений со статус-бейджами; настройка rootMargin и threshold |
| v-lazy-img | Сетка из 36 карточек с ленивыми фонами; тогл LQIP; лог событий |
| Background image | useBackgroundImage() — ленивый + адаптивный image-set() фон с blur-up |
| Encode (upload) | Кодирование encodeThumbHash / encodeBlurhash из загруженного файла прямо в браузере, с декод-превью |
| Error State | Дефолтный SVG-fallback против кастомного слота #error; лог @error; demo retry с backoff |
| Headless | useImage() с полностью кастомной разметкой и реактивным состоянием |
| CDN adapters | Live-билдер URL / srcset для всех 12 провайдеров |
| Build-time imports | Воркфлоу ?vik / ?thumbhash с разбором формы метаданных |
Production-сборка demo — npm run demo:build.
NPM: https://www.npmjs.com/package/@macrulez/vue-image-kit
GitHub: https://github.com/macrulezru/vue-image-kit