vue-image-kit 1.1.0: от art direction до собственного image-сервера

vue-image-kit начинался как один компонент <VImage> с ленивой загрузкой и плейсхолдерами. В релизе 1.1.0 пакет прошёл путь от «набора удобных пропов» до «стека с собственной серверной частью». Список того, что попало в этот релиз, получился длиннее, чем казалось на старте:
- 6 новых пропов
<VImage>:image,priority,layout,respectSaveData,cdn,loader="server" - поддержка SVG и анимированного GIF в CLI/Vite-пайплайне
- art direction и переключение форматов (AVIF/WebP) на одном брейкпоинте одновременно
- новый composable
useNetworkAware()и уважение кSave-Data - авто-детект 8 CDN-провайдеров по URL + прямая интеграция с
<VImage> - собственный self-hosted image-сервер на замену CDN — обработчик, Vite dev-middleware, Nuxt Nitro-роут
- инкрементальная генерация в CLI (пропуск неизменившихся исходников по mtime/хешу)
- бюджет размера бандла, зашитый в CI
- сводка экономии в CLI-отчёте, dev-предупреждение об
alt, e2e-покрытие новых пропов - отдельное Nuxt demo-приложение для живой проверки всего перечисленного
Ниже — подробный разбор по темам.
image — проп для прямого потребления вывода CLI
Раньше манифест CLI или ?vik-импорт нужно было руками расписывать по пропам <VImage>: src, srcset, width, height, blurhash… Теперь весь объект можно скормить компоненту одним пропом:
vue
<script setup lang="ts">
import meta from './hero.jpg?vik'
</script>
<template>
<VImage :image="meta" alt="Hero" />
</template>
Поля image заполняют src/width/height/blurhash/thumbhash/placeholder/sizes, но только там, где соответствующий явный проп не задан — явный проп всегда побеждает. webp/avif из манифеста автоматически превращаются в <picture>. Заодно useImage() получил опцию rawSrcset, чтобы уже готовая строка srcset из манифеста использовалась как есть, а не пересчитывалась заново из widths.
priority — честный шорткат для LCP-изображения
Аналог priority из Next.js: один булев проп вместо трёх отдельных.
vue
<VImage src="/hero.jpg" alt="Hero" priority />
<!-- эквивалент :lazy="false" fetchpriority="high" decoding="sync" -->
Важный момент по дизайну: это не автоматическое определение LCP-изображения — надёжно определить его на клиенте до первой отрисовки в лёгкой библиотеке нереально. Поэтому priority — явный маркер, который проставляет разработчик, а не магия. В README это прямо написано, а не обещано больше, чем есть на самом деле.
SVG и анимированный GIF в CLI/Vite-пайплайне
Раньше эти два формата в --formats тихо игнорировались. Теперь:
- SVG копируется как есть (он и так resolution-independent),
sharpиспользуется только чтобы вытащить width/height для манифеста. - GIF копируется как fallback-вариант, и если
webpесть в--formats— дополнительно перекодируется в анимированный WebP (sharp(path, { animated: true }).webp({ quality, loop: 0 })). AVIF для анимации сознательно пропущен — поддержка анимации в libavif пока слишком нестабильна, чтобы на неё полагаться. LQIP/BlurHash/ThumbHash по-прежнему считаются с первого кадра.
Заодно устранён технический долг: SUPPORTED_EXTS был продублирован между CLI и Vite-плагином — теперь экспортируется один раз из processor.ts.
Network-aware loading — уважение к Save-Data
Новый useNetworkAware() читает navigator.connection (Chromium-only API), реактивно следит за событием change, безопасен на SSR (saveData=false на сервере):
ts
const { saveData, effectiveType } = useNetworkAware()
useImagePreloader().preload() теперь ничего не делает, если у пользователя включён Save-Data — предзагрузка становится no-op вместо того, чтобы жечь трафик человеку, который явно попросил браузер экономить.
На уровне компонента — новый опциональный проп respectSaveData:
vue
<VImage
src="/hero.jpg"
alt="Hero"
priority
respect-save-data
/>
Когда он включён и обнаружен Save-Data: priority полностью нейтрализуется (как будто его не было), а src понижается до наименьшего доступного варианта — либо из карты densities, либо из srcset в image. Обычный widths тут намеренно не трогается: generateSrcset переиспользует один и тот же URL для каждого w-дескриптора, так что уменьшать там физически нечего — это задокументировано, а не тихо не работает.
Layout-пресеты + авто-sizes
Новый тип Layout = 'fixed' | 'responsive' | 'fill' и проп layout (по умолчанию undefined — старое поведение не меняется ни для одного существующего использования):
vue
<!-- Точный бокс, без aspect-ratio -->
<VImage src="/icon.png" alt="" :width="64" :height="64" layout="fixed" />
<!-- Заполняет контейнер, sizes генерируется автоматически из width -->
<VImage src="/card.jpg" alt="Card" :width="400" :height="300" layout="responsive" />
<!-- position: absolute; inset: 0 — заполняет спозиционированного родителя -->
<div style="position: relative; aspect-ratio: 16/9;">
<VImage src="/hero.jpg" alt="Hero" layout="fill" />
</div>
Для responsive авто-sizes генерируется как (min-width: {width}px) {width}px, 100vw и включается только тогда, когда ни явный проп sizes, ни image.sizes не заданы — явное всегда побеждает автоматическое.
Art direction и переключение форматов вместе
До 1.1.0 на одном брейкпоинте можно было задать либо другой кроп, либо другой формат — но не оба сразу. Тип ResponsiveSrc расширен до Record<string, string | SrcSet>, так что значение брейкпоинта может быть и строкой (как раньше), и объектом { avif?, webp?, fallback }:
vue
<VImage
:src="{ avif: '/hero.avif', webp: '/hero.webp', fallback: '/hero.jpg' }"
:sources="{
sm: { avif: '/hero-mobile.avif', webp: '/hero-mobile.webp', fallback: '/hero-mobile.jpg' },
md: '/hero-tablet.jpg',
}"
alt="Hero"
/>
Один брейкпоинт с SrcSet-объектом теперь разворачивается в несколько <source> под одним и тем же media — AVIF, потом WebP, потом fallback. Сортировка по-прежнему стабильна (Array.prototype.sort гарантированно stable по спеке), так что порядок внутри группы не ломается.
CDN: автоопределение провайдера и прямая интеграция с VImage
Новая функция autoLoader() из vue-image-kit/cdn смотрит на hostname и сама выбирает нужный из 12 адаптеров — не нужно руками указывать, какой это CDN:
ts
import { autoLoader } from 'vue-image-kit/cdn'
autoLoader('https://res.cloudinary.com/demo/image/upload/photo.jpg', { width: 800 })
// → автоматически распознан Cloudinary, URL перестроен с w_800
8 провайдеров распознаются прямо по hostname-паттерну (Cloudinary, imgix, Bunny, ImageKit, Sanity, Storyblok, Contentful, Gumlet). Ещё 4 — Netlify, Vercel, Cloudflare, TwicPics — «свой домен», по URL их не отличить, поэтому они подключаются через config.hosts. На нераспознанном хосте autoLoader просто возвращает URL без изменений — никогда не бросает исключение.
То же самое подключено и прямо к компоненту — новый проп cdn:
vue
<VImage
src="https://res.cloudinary.com/demo/image/upload/photo.jpg"
alt="Photo"
:widths="[400, 800, 1200]"
cdn
/>
cdn принимает true (детект только по hostname) или объект с hosts (для «своего домена»). Новый autoSrcset() строит настоящий per-width srcset через .srcset() распознанного адаптера — так что каждый width-кандидат получает реально другой CDN-URL, а не один и тот же URL с разными w-дескрипторами, как это происходит с обычным widths. Цена — импорт всех 8 адаптеров теперь всегда попадает в основной бандл: ESM-бандл вырос на ~2.2 кБ гзип (10.8 → 13.0 кБ). Это поймал check:size — реальная цифра оказалась больше первоначальной оценки, бюджет подняли до 15/13 кБ осознанно, ради удобства использования.
Собственный image-сервер вместо CDN
Главное архитектурное дополнение релиза: пакет теперь умеет ресайзить изображения по запросу самостоятельно, без стороннего CDN.
ts
// server.ts — plain Node
import { createServer } from 'node:http'
import { createImageHandler } from 'vue-image-kit/server'
const handleImage = createImageHandler({ root: './public' })
createServer((req, res) => {
if (req.url?.startsWith('/_vik/image')) return handleImage(req, res)
// ...остальная маршрутизация
}).listen(3000)
Как это устроено:
src=/w=/format=/q=парсятся из query-строки запроса.srcрезолвится строго внутриrootс защитой от path traversal — путь проверяется на то, что он реально остался внутриroot, ещё до любого обращения к файловой системе.- Чистый passthrough (без
w/format) стримит оригинальные байты без вызоваsharpи без записи в кэш — работает для любого типа файла. - Иначе результат кэшируется на диске (по умолчанию
<root>/.vik-cache) по sha256-хешу от(путь, ширина, формат, качество); повторные запросы отдаются прямо из кэша. wклэмпится поmaxWidth(по умолчанию 4000), либо, если заданallowedWidths, запрос с несовпадающей шириной отклоняется с 400.- 400 на некорректные параметры, 404 если файла нет, 403 при попытке traversal.
Тот же обработчик подключается прямо в Vite dev-сервер как middleware — картинки ресайзятся на лету во время vite dev, без отдельного шага сборки:
ts
vueImageKit({
dev: { onDemand: true }, // мостится на /_vik/image, работает только в dev
})
Живая проверка, не только моки: реальный vite dev был поднят и опрошен через curl — проверены 400/403/404, byte-identical passthrough, и настоящий ресайз (ширина выходного файла подтверждена через sharp().metadata() на файле, реально лежащем в кэше на диске). Именно так была поймана реальная ошибка: mimeFor() не отрезал ведущую точку от extname() перед поиском MIME-типа, из-за чего passthrough-ветка всегда отдавала application/octet-stream вместо настоящего Content-Type.
Компонентный аналог cdn, но для собственного сервера — новый проп loader="server":
vue
<VImage src="/photo.jpg" alt="Photo" :widths="[400, 800]" loader="server" />
serverSrc/serverSrcset зеркалят cdnSrc/cdnSrcset — только строковый src, widths апгрейдится до настоящих per-candidate URL через buildImageUrl(). Импортируется отдельно из ../server/url.js, а не из общего ../server/index.js — последний реэкспортирует и handler.ts, который тянет node:http/node:fs/node:crypto. url.ts сам по себе не имеет Node-зависимостей и безопасен для браузерного бандла — проверено grep-ом по собранному dist/vue-image-kit.js на отсутствие этих модулей.
На стороне Nuxt — новая опция onDemandServer, регистрирующая реальный Nitro-роут через addServerHandler:
ts
export default defineNuxtConfig({
modules: ['vue-image-kit/nuxt'],
vueImageKit: {
onDemandServer: { root: 'public' },
},
})
Ещё один реальный баг, пойманный по пути: сборка Nuxt-модуля (vite.nuxt.config.ts) не помечала sharp и node:* как внешние зависимости. Как только в модуль добавился Node-only импорт, sharp со всем своим detect-libc целиком утянулся в бандл — 197 кБ лишнего веса в том, что должно быть тонкой обёрткой вокруг addServerHandler. Поймано не тестами, а внимательным чтением вывода сборки; исправлено добавлением sharp и /^node:/ во external-список, как уже было сделано в двух других Node-таргетных конфигах пакета.
Бюджет размера бандла в CI
README до этого хранил захардкоженные цифры размера бандла, которые быстро устарели. Теперь есть единственный источник правды — таблица, и скрипт scripts/check-bundle-size.cjs, который гзипует реальный dist/* и падает, если он превышает бюджет (сейчас 15/13/4 кБ для ESM/CJS/cdn). Подключен в CI сразу после npm run build как npm run check:size.
Сводка в CLI-отчёте
printBatchSummary() теперь печатает не только список файлов, но и итог: суммарный размер входа/выхода и «самый лёгкий доступный формат экономит ~N% относительно оригинала, в среднем». Специально не «весь output против всего input» — при нескольких ширинах и форматах на одно изображение output естественно в разы больше input, и это выглядело бы так, будто обработка сделала файлы хуже. Вместо этого каждое изображение сравнивается само с собой: самый лёгкий сгенерированный вариант против оригинала — именно то число, которое отвечает на реальный вопрос «насколько это можно ужать».
Dev-предупреждение об alt
Новый checkAltText() в dev-режиме предупреждает в консоли, если alt выглядит как ошибка — пустой, из одних пробелов, или похож на имя файла ("photo.jpg"). Осознанный alt="" (стандартный способ пометить декоративное изображение) предупреждение не триггерит.
Технический нюанс: проверка использует process.env.NODE_ENV !== 'production', а не import.meta.env.DEV. Причина — import.meta.env.DEV статически подставляется на этапе сборки самого пакета и навсегда застывает как false в опубликованном dist. process.env.NODE_ENV, наоборот, доживает до бандла приложения, которое пакет подключает, и корректно подменяется уже его бандлером. Vue core по той же причине делает так же.
E2E-покрытие новых фич
Появилась новая вкладка demo-приложения «Layout & priority» и два новых Playwright-спека: layout-fill.spec.ts (bounding box fill реально совпадает с родителем — то, что в принципе нельзя проверить в jsdom, чей layout-движок — no-op) и priority.spec.ts (настоящие атрибуты fetchpriority/decoding в браузере). respectSaveData и cdn пока без e2e — честно оставлено как todo, а не тихо пропущено.
Инкрементальная генерация в CLI
Раньше --watch и vite dev пересчитывали все исходники при каждом изменении хотя бы одного файла. Теперь состояние сохраняется в <output>/.vik-incremental.json: неизменившийся по mtime файл пропускается сразу, а если mtime разошёлся (например, после git checkout) — на помощь приходит content-hash. Изменение конфигурации (widths, formats, quality и т.д.) инвалидирует всё разом.
bash
npx vue-image-kit generate --incremental
Автоматически включается под --watch и в vite dev (но не в vite build — прод-артефакт не должен рисковать устаревшим кэшем), если не задано явно ни флагом, ни конфигом.
Живая проверка на собранном CLI, не только юнит-тесты: чистый запуск обрабатывает всё; повторный запуск без изменений — пропускает оба изображения и репортит это; правка одного исходника — пересчитывается только он; смена --widths — «Config changed since last run» и пересчёт всего; реальный --watch-запуск без явного флага записал .vik-incremental.json, подтвердив, что авто-включение действительно срабатывает.
Nuxt demo-приложение
По словам автора задачи, «самый вкусный» пункт. Новая папка demo-nuxt/, отдельный package.json с nuxt как настоящей dev-зависимостью (не заглушкой), модуль зарегистрирован по относительному пути, потому что modules в nuxt.config.ts резолвится Nuxt/jiti ещё до того, как Vite (и его алиасы) вообще существуют.
Страница демо покрывает: компонент без единого явного импорта, art direction через breakpoints из конфига модуля, три авто-импортированных API с выводом их результата прямо на странице (чтобы curl серверного HTML доказывал, что addImports реально их подключил, а не просто типы совпали), loader="server" с кнопкой, дёргающей on-demand роут напрямую, и v-lazy-img.
Проверено вживую и в dev, и в проде: nuxt dev + curl по SSR HTML — все auto-import секции с реальными данными на месте; прямой запрос /_vik/image?src=...&w=300&format=webp — реальные 200, image/webp и (проверено через sharp по факту байт) настоящие 300×200 на выходе; .vik-cache/ появился под настроенным root. Затем nuxt build → .output/, запуск собранного прод-сервера и повтор тех же проверок против него — потому что «работает в dev» не значит «работает в проде», и это два разных факта, которые нужно проверять отдельно.
Demo сразу настроен на полноценный DX, а не только «запускается»:
- Полная типизация в IDE.
demo-nuxt/tsconfig.jsonрасширяет автосгенерированный.nuxt/tsconfig.json, так что и редактор, иtscсразу видят типы всех auto-import composables и утилит —generateSrcset,useImagePreloader,buildSizesи остальные подсвечиваются с автодополнением, а не как неизвестные имена. Дляvue-image-kitв исходниках модуля используется нативный Nuxtalias— единственный вариант, который пробрасывается сразу и в Vite, и в генерируемыйtsconfig.json. - Тихий консольный вывод. При старте
nuxt devничего не сыпется в консоль — прогрев Vite-клиента настроен так, чтобы не гоняться наперегонки с регистрацией внутренних Nuxt-алиасов, через хукvite:extendConfig.
Итог
- 6 новых пропов
<VImage>, новая серверная часть (vue-image-kit/server), Nuxt-модуль с реальным Nitro-роутом, авто-детект 8 CDN, инкрементальный CLI — и всё это с живой, а не только мокированной проверкой на каждом шаге. - 406 unit/component-тестов, 7/7 e2e, чистые
lint/typecheck/build/check:size. - Бандл:
vue-image-kitESM 13.4 кБ gzip, CJS 11.6 кБ,vue-image-kit/cdn2.4 кБ.
NPM: https://www.npmjs.com/package/@macrulez/vue-image-kit
GitHub: https://github.com/macrulezru/vue-image-kit