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

28.05.2026
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 Copy
npm install vue-image-kit
ts Copy
// 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 Copy
<VImage src="/photo.jpg" alt="Фото" />

Полный набор пропов:

vue Copy
<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 Copy
<VImage
  src="/photo.jpg"
  alt="Фото"
  thumbhash="1+cNHYI3iHeFh3iPh5d4h7ZwZQl4"
  :width="1200"
  :height="800"
/>

<VImage> сам декодирует хеш и показывает PNG как blur-up плейсхолдер. Ничего импортировать не надо.

Если нужен data URL для кастомной разметки — decodeThumbHash:

ts Copy
import { decodeThumbHash } from 'vue-image-kit'

const dataUrl = decodeThumbHash('1+cNHYI3iHeFh3iPh5d4h7ZwZQl4')
// → 'data:image/png;base64,...'

// Можно использовать как src для обычного <img>
// или как background-image — например с v-lazy-img

Если переданы оба thumbhash и placeholderplaceholder имеет приоритет.

BlurHash — canvas без зависимостей

BlurHash декодируется в <canvas> прямо в браузере. Декодер написан с нуля по спецификации — никакого npm-пакета blurhash в runtime.

vue Copy
<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 Copy
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 Copy
<VImage
  src="/photo.jpg"
  alt="Фото"
  placeholder="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAA..."
  :width="1200"
  :height="800"
/>

Генерируется при обработке через CLI или вручную:

ts Copy
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 Copy
<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 Copy
<VImage src="/photo.jpg" alt="Баннер" placeholder-color="#1e3a8a" />

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

ts Copy
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 Copy
<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 Copy
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 Copy
<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 Copy
// images.ts — сгенерировано CLI
{
  src:    '/images/photo.jpg',
  srcset: '/images/photo-400.jpg 400w, /images/photo-800.jpg 800w, /images/photo.jpg 1200w',
  // ...
}
vue Copy
<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 Copy
<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 Copy
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 нельзя).

Проп принимает две формы. Главная — карта плотность → файл, где каждый / это реально более крупный файл:

vue Copy
<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 Copy
<VImage src="https://cdn.example.com/avatar?dpr=auto" alt="Аватар" :densities="[1, 2, 3]" />

Та же логика доступна утилитой generateDensitySrcset — со строкой или картой:

ts Copy
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 Copy
<!-- { 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 Copy
<VImage
  :src="{
    avif: '/photo.avif',
    webp:  '/photo.webp',
    fallback: '/photo.jpg',
  }"
  alt="Фото"
  :width="1200"
  :height="800"
/>

Рендерится:

html Copy
<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 Copy
// main.ts — один раз глобально
app.use(VImageKitPlugin, {
  breakpoints: {
    sm: '(max-width: 640px)',
    md: '(max-width: 1024px)',
  },
})
vue Copy
<!-- В компоненте — только ключи -->
<VImage
  src="/hero-desktop.jpg"
  alt="Hero"
  :sources="{
    sm: '/hero-mobile.jpg',
    md: '/hero-tablet.jpg',
  }"
/>

Рендерится <picture> с <source media="..."> — порядок выставляется автоматически в правильном ascending max-width порядке (обязательное требование <picture>).

Можно добавить локальные брейкпоинты в конкретный компонент — они мёрджатся с глобальными:

vue Copy
<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 Copy
<VImage
  :src="{ avif: '/hero.avif', webp: '/hero.webp', fallback: '/hero.jpg' }"
  :sources="{ sm: '/hero-mobile.jpg', md: '/hero-tablet.jpg' }"
  alt="Hero"
/>
html Copy
<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 Copy
<!-- Дефолт: начать загрузку за 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 Copy
<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 Copy
<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 Copy
<!-- 3 попытки: 500ms → 1000ms → 2000ms -->
<VImage
  src="/flaky-cdn.jpg"
  alt="Фото"
  :max-retries="3"
  :retry-delay="500"
/>

Логика retry встроена в composable useImage — компонент просто передаёт пропсы.


fetchpriority и decoding

Два пропса для управления приоритетом загрузки — напрямую влияют на LCP:

vue Copy
<!-- 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 Copy
<!-- Простая строка — только 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 Copy
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 Copy
<div v-lazy-img="bgOptions" class="hero-section" />

useBackgroundImage — ленивый и адаптивный фон

Директива v-lazy-img закрывает ленивую загрузку фона, но не умеет srcset. Композабл useBackgroundImage — её продвинутый вариант: ленивая загрузка плюс адаптивность через image-set() (CSS-аналог srcset) плюс blur-up. Возвращает реактивный :style, который вы навешиваете сами.

vue Copy
<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 Copy
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 Copy
<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 Copy
<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>

progressComputedRef<number> от 0 до 1. errors — массив URL, которые не загрузились.


Для SSR: генерирует <link rel="preload"> который браузер видит в <head> ещё до парсинга HTML-тела. Критически важно для LCP.

ts Copy
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 Copy
<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 Copy
import {
  cloudinary, imgix, bunny, sanity, storyblok, contentful, vercel,
  cloudflare, imagekit, twicpics, netlify, gumlet,
} from 'vue-image-kit/cdn'

Cloudinary:

ts Copy
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 Copy
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 Copy
const cdn = bunny('https://myzone.b-cdn.net')
cdn.url('photo.jpg', { width: 800, height: 600, format: 'webp', quality: 85 })

Sanity:

ts Copy
const cdn = sanity({ projectId: 'abc123', dataset: 'production' })
cdn.url('image-abc123-800x600-jpg', { width: 400 })

Storyblok:

ts Copy
const cdn = storyblok()
cdn.url('https://a.storyblok.com/f/12345/photo.jpg', { width: 800 })

Contentful:

ts Copy
const cdn = contentful()
cdn.url('https://images.ctfassets.net/space/token/photo.jpg', { width: 800, format: 'webp' })

Vercel Image Optimization:

ts Copy
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 Copy
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

ImageKit.io:

ts Copy
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 Copy
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 Copy
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 Copy
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 Copy
<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 Copy
npm install sharp --save-dev          # sharp — единственная зависимость CLI
npm install thumbhash --save-dev     # для --thumbhash флага
bash Copy
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 Copy
// 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 Copy
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 Copy
// 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 Copy
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 Copy
<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 Copy
/// <reference types="vue-image-kit/vite/client" />

Nuxt 3 — авто-импорты и SSR из коробки

ts Copy
// 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 Copy
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 Copy
// Только vLazyImg и его IO-логика попадают в бандл
import { vLazyImg } from 'vue-image-kit'
app.directive('lazy-img', vLazyImg)

Сценарии в реальных проектах

Интернет-магазин — каталог товаров с CDN

Несколько тысяч SKU, изображения в Cloudinary. Требования: быстрый первый экран, красивые плейсхолдеры, WebP/AVIF без ручной генерации, retry если CDN периодически лежит, кастомный fallback для товаров без фото.

ts Copy
// 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 Copy
<!-- 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 Copy
<!-- 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 Copy
// 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 Copy
<!-- 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 Copy
<!-- 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 Copy
<!-- 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 Copy
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 Три превью под разные sizescurrentSrc меняется; 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

Читать далее

30.05.2026

Command-палитра для Vue 3, часть 2: вторичные действия, режимы, превью и ещё дюжина фич

Продолжение поста про мою headless-палитру команд (Cmd/Ctrl + K) для Vue 3. Во втором заходе пакет оброс вторичными действиями в стиле Raycast, префиксными режимами, боковой панелью предпросмотра, мультивыбором, frecency-ранжированием и ещё дюжиной вещей — плюс десяток выловленных по дороге багов.

Метки
Vue 3Command PaletteOpen SourceTypeScriptFrontend