Skip to content

Справочник ​

Совместимость с SSR ​

  • Серверный рендер — <VImage> — рендерит один-единственный <img loading="lazy"> с src/alt/нативными width/height; без IO, без canvas, без обёртывающего элемента — вся плейсхолдер-механика живёт только в клиентской ветке.
  • Резервирование места — достигается нативными HTML-атрибутами width/height на самом <img>, а не отдельным <div> с CSS aspect-ratio.
  • Blurhash на сервере — код декодирования (canvas в памяти) не выполняется вообще; SSR-ветка ничего не знает про hazehash/blurhash/thumbhash/placeholder.
  • IntersectionObserver на сервере — не используется; сервер рендерит обычный <img>.
  • Гидратация — после монтирования onMounted настраивает IO (если lazy: true) либо сразу начинает загрузку (если lazy: false).
  • v-lazy-img на сервере — хуки директивы (mounted, unmounted) не вызываются во время SSR — IO не создаётся.
  • useLazyLoad на сервере — немедленно возвращает { isIntersecting: true } — вызывающий код действует так, будто уже во вьюпорте.

Использование в Nuxt:

Особая настройка не требуется. Компонент корректно рендерится и в SSR, и в клиентском режиме. Если нужно узнать, смонтировался ли клиент, используйте onMounted из Vue:

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const mounted = ref(false)
onMounted(() => {
  mounted.value = true
})
</script>

<template>
  <VImage v-if="mounted" src="/photo.jpg" alt="Photo" blurhash="..." />
  <div v-else style="aspect-ratio: 16/9; background: #e5e7eb;" />
</template>

Архитектура ​

VImage.vue
│  props: src, image, alt, width, height,
│         hazehash, blurhash, thumbhash, placeholder, ssrPlaceholder,
│         placeholderMode, placeholderColor,
│         widths, densities, sizes, sources, breakpoints,
│         lazy, rootMargin, threshold, fit, focal,
│         maxRetries, retryDelay,
│         fetchpriority, decoding, priority, respectSaveData,
│         layout, cdn, loader, loaderRoute, fadeIn
│
├──▶ useImage(options)
│         │
│         ├── useLazyLoad({ rootMargin, threshold })
│         │      IntersectionObserver (SSR-safe)
│         │      isIntersecting: Ref<boolean>
│         │      observe(elRef) → starts watching
│         │
│         ├── State machine
│         │      idle → loading → loaded
│         │                    → error (retryCount >= maxRetries)
│         │                    → idle → loading  (retry, exponential backoff)
│         │      lazy=true  → watch(isIntersecting) → loading
│         │      lazy=false → onMounted → loading
│         │      respectSaveData + isSaveDataEnabled() → neutralizes priority,
│         │        downgrades src via pickSmallestSrcsetUrl
│         │
│         └── imgAttrs: ComputedRef
│                src    = fallback URL (or cdn/loader-rewritten URL)
│                srcset = generateSrcset(src, widths) / adapter.srcset() / handler URLs
│                sizes  = generateSizes(sizes)
│                style  = { objectFit: fit }
│
├──▶ blurhashToDataUrl(hash, width, height)   ← local helper, NOT useBlurhash()
│         decodeBlurhash(hash, w, h) → pixels
│         offscreen canvas (created via document.createElement, never mounted)
│           → ctx.putImageData(...) → canvas.toDataURL()
│         returns: string | undefined (data: URL, or undefined on decode
│           failure/SSR — invalid input silently leaves it blank)
│         useBlurhash() (below) is a separate, standalone composable —
│           see use-blurhash.md
│
├──▶ useBreakpoints(breakpoints?)
│         Merges local breakpoints prop with global plugin breakpoints
│         resolveMediaSources(sources) → sorted [{ media, src, type? }]
│
├──▶ cdn / loader resolution
│         cdn    → autoLoader()/autoSrcset() (vue-image-kit/cdn)
│         loader → useServerRoute() + buildImageUrl() (vue-image-kit/server)
│         cdn wins if both are set
│
├──▶ effectivePlaceholder: ComputedRef<string | undefined>
│         placeholder prop  → used as-is (LQIP base64)
│         thumbhash prop    → decodeThumbHash(hash) → PNG data URL
│         placeholderMode='color'   → thumbHashToAverageColor(thumbhash) / placeholderColor
│         placeholderMode='shimmer' → CSS-only skeleton, no hash needed
│         hazehash / blurhash renders → undefined (they win, see priority below)
│         neither            → undefined (no blur-up placeholder)
│
├──▶ placeholderBackgroundStyle: ComputedRef<CSSProperties>
│         Same priority every placeholder always had (color → shimmer →
│         hazehash → blurhash → LQIP/ThumbHash), just expressed as CSS now instead of
│         separate DOM nodes:
│         isLoaded                       → {}  (the photo itself is the content)
│         placeholderColor / mode=color  → { backgroundColor }
│         mode='shimmer'                 → {}  (the .vik-shimmer CSS class
│                                               below does the gradient+animation)
│         hazehashUrl / blurhashToDataUrl(...) result → { backgroundImage: url(...),
│                                            backgroundSize: 'cover',
│                                            backgroundPosition }
│         effectivePlaceholder (LQIP/ThumbHash) → same shape, as background-image
│         none of the above              → { backgroundColor: '#f3f4f6' }  (neutral fallback)
│
├──▶ Template structure (client) — exactly ONE of these renders at any
│      given moment, never nested inside another (see "Компоновка без
│      обёртки" in responsive-images.md):
│
│      isIdle
│        <img ref="observeTargetRef" :src="blankSrc" alt="" aria-hidden="true"
│              :width="mergedWidth" :height="mergedHeight"
│              :style="{ aspectRatio, ...fixedSizeStyle, ...placeholderBackgroundStyle }"
│              :class="[loadedBoxClasses, { 'vik-fit': usesDefaultFit, 'vik-shimmer': showShimmerClass }]" />
│                                              ← IntersectionObserver target; carries
│                                                 whatever placeholderBackgroundStyle resolved to
│
│      isError                              ← retries exhausted
│        <span :style="{ ...boxStyle, display: 'flex', ... }">
│          <slot name="error"><svg .../></slot>
│        </span>
│
│      needsPicture                         ← art-direction/format sources needed
│        <picture :style="{ display: boxStyle.display }">
│          <source v-for media/srcset />       ← responsive art direction
│          <source type="image/avif" />
│          <source type="image/webp" />
│          <img v-bind="imgAttrs" :style="{ ...boxStyle, objectFit,
│               ...placeholderBackgroundStyle }"
│               :class="{ 'vik-shimmer': showShimmerClass }"
│               :decoding :fetchpriority @load @error />
│        </picture>
│
│      otherwise (loading | loaded, no picture needed)
│        <img v-bind="imgAttrs" :style="{ ...boxStyle, objectFit,
│             ...placeholderBackgroundStyle }"
│             :class="{ 'vik-shimmer': showShimmerClass }"
│             :decoding :fetchpriority @load @error />
│                                              ← simple img (no picture)
│
└──▶ Template structure (SSR)
       <img :src :alt :width :height :decoding :fetchpriority
            :loading="lazy ? 'lazy' : 'eager'" />

vLazyImg (Directive)
│  mounted(el, binding)
│    resolveOptions(binding) → { src, placeholder, rootMargin, ... }
│    getPooledObserver(rootMargin, threshold)   ← shared observer-pool.ts, same pool
│                                                   useLazyLoad/useImage/VImage/useBackgroundImage use
│      IntersectionObserver → on intersect:
│        if placeholder: el.style.backgroundImage = url(placeholder)
│        new Image()
│          onload  → el.style.backgroundImage = url(src); onLoad()
│          onerror → onError(e)
│  updated  → disconnect old observer, create new one
│  unmounted → observer.disconnect()

useBackgroundImage(src, options)   ← srcset-capable counterpart of vLazyImg
│  useLazyLoad({ rootMargin, threshold })
│  style: ComputedRef<{ backgroundImage, backgroundSize, ... }>
│    plain src            → url(src)
│    densities            → image-set(url(src) 1x, url(src) 2x, ...)

useNetworkAware() / isSaveDataEnabled()
│  navigator.connection.saveData / .effectiveType (Chromium-only API)
│  Used by: VImage's respectSaveData prop, useImagePreloader's preload()

useImagePreloader()
│  preload(urls) → Promise<void>, skipped entirely when isSaveDataEnabled()
│  loaded / total / progress / isComplete / errors

useServerLoader — useServerRoute(localOverride?) / SERVER_ROUTE_KEY
│  Resolves the on-demand server route: localOverride → plugin/module
│  provide()'d default → '/_vik/image' (DEFAULT_SERVER_ROUTE)

vue-image-kit/cdn (12 adapters, zero deps)
│  cloudinary / imgix / bunny / sanity / storyblok / contentful /
│  vercel / cloudflare / imagekit / twicpics / netlify / gumlet
│  Shared interface: adapter.url(path, options?) / adapter.srcset(path, widths, options?)
│  autoLoader() / autoSrcset() — hostname fingerprinting for 8 of the 12

vue-image-kit/server
│  createImageHandler(options) → (req, res) => void — framework-agnostic
│  buildImageUrl(src, options) → request URL string
│  Disk-cached, sharp-based, one transform per request

Utils (pure functions, zero Vue deps)
│  blurhash-decode.ts
│    decodeBlurhash(hash, width, height) → Uint8ClampedArray  ← RGBA pixels
│
│  thumbhash-decode.ts
│    decodeThumbHash(hash: string | Uint8Array) → string      ← PNG data URL
│    thumbHashToAverageRGBA(hash) / thumbHashToAverageColor(hash)
│
│  encode.ts (browser-only, in-house — no `thumbhash` package dependency)
│    encodeBlurhash(source, options?) / encodeThumbHash(source, options?)
│
└── srcset.ts
    generateSrcset(src, widths) → string
    generateSizes(sizes?) → string
    generateDensitySrcset(src, densities) → string
    buildSizes(map, breakpoints) → string
    generatePreloadLink(href, options) → string
    pickSmallestSrcsetUrl(srcset) → string | undefined

cli/ + vite/plugin.ts (Node-only, sharp-based, not in the browser bundle)
│  bin.ts        — `vue-image-kit generate` command + flag parsing
│  processor.ts  — resize/convert/LQIP/BlurHash/ThumbHash, SVG/GIF special-casing
│  incremental.ts — mtime/hash-based skip cache
│  manifest.ts   — writes the TypeScript images.ts manifest
│  vite/plugin.ts — same generate() on buildStart/handleHotUpdate,
│                    ?vik/?thumbhash resolvers, dev.onDemand handler

nuxt/module.ts (Node-only)
│  Registers VImage/v-lazy-img as global, auto-imports composables/utils,
│  provides breakpoints/serverRoute, optional onDemandServer → addServerHandler

Размер бандла и peer-зависимости ​

Точка входаRawGzipPeer-зависимости
@macrulez/vue-image-kit ESM45.6 kB14.08 kBvue ^3.0
@macrulez/vue-image-kit CJS34.4 kB12.36 kBvue ^3.0
@macrulez/vue-image-kit/cdn ESM11.2 kB2.53 kB—

Измерено по реальному результату сборки (npm run build), не поддерживается вручную — CI падает, если это отклоняется от порогов в .github/workflows/ci.yml. CLI, плагин Vite, модуль Nuxt и самостоятельный сервер — это Node-инструментарий, никогда не бандлится для браузера, поэтому они вне охвата этой таблицы.

Поставляется как tree-shakeable ESM (vue-image-kit.js) и CommonJS (vue-image-kit.cjs). "sideEffects": false в package.json — неиспользуемые экспорты удаляются бандлером. Если вы импортируете только vLazyImg или один composable, бандлер исключит всё остальное (VImage, декодер blurhash и т. д.).

Пример tree-shaking — использование только директивы:

ts
// В бандл попадает только vLazyImg и его логика IO.
// VImage, useBlurhash, decodeBlurhash не импортированы → не бандлятся.
import { vLazyImg } from '@macrulez/vue-image-kit'
app.directive('lazy-img', vLazyImg)

Лицензия ​

MIT