Vue.js applications often struggle with image performance, especially as they scale. While basic <img>
tags work for simple cases, modern Vue applications need sophisticated image optimization strategies that handle responsive images, lazy loading, format selection, and performance monitoring.
This guide explores advanced Vue.js image optimization techniques that go far beyond the basics, covering everything from custom composables to performance monitoring and automated optimization pipelines.
The Vue.js Image Challenge
Modern Vue applications face unique image optimization challenges:
// Common Vue.js image performance issues
const imagePerformanceChallenges = {
spa_navigation: {
issue: "Images reload unnecessarily during route changes",
impact: "Poor perceived performance, wasted bandwidth",
solution: "Smart caching and preloading strategies"
},
reactive_images: {
issue: "Image sources change based on reactive data",
impact: "Multiple unnecessary requests, layout shifts",
solution: "Debounced reactive image loading"
},
component_reuse: {
issue: "Same images loaded multiple times across components",
impact: "Memory bloat, slow performance",
solution: "Global image caching and state management"
},
ssr_hydration: {
issue: "Image loading conflicts between server and client",
impact: "Hydration mismatches, poor LCP scores",
solution: "SSR-aware image optimization"
}
};
Advanced Image Composables
Smart Lazy Loading Composable
// composables/useSmartImage.js
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
export function useSmartImage(options = {}) {
const {
src,
srcset,
sizes,
alt,
loading = 'lazy',
placeholder = null,
formats = ['avif', 'webp', 'jpg'],
quality = 80,
onLoad = () => {},
onError = () => {}
} = options
const imageRef = ref(null)
const isLoaded = ref(false)
const isLoading = ref(false)
const hasError = ref(false)
const currentSrc = ref(placeholder)
const formatSupport = ref({})
// Detect format support
const detectFormatSupport = async () => {
const support = {}
for (const format of formats) {
support[format] = await testFormatSupport(format)
}
formatSupport.value = support
}
// Get optimal image source based on format support
const optimalSrc = computed(() => {
if (!src.value) return null
// Find the best supported format
for (const format of formats) {
if (formatSupport.value[format]) {
return generateOptimalUrl(src.value, format, quality)
}
}
return src.value
})
// Generate responsive srcset
const responsiveSrcset = computed(() => {
if (!srcset.value && optimalSrc.value) {
return generateResponsiveSrcset(optimalSrc.value, formats, formatSupport.value)
}
return srcset.value
})
// Intersection Observer for lazy loading
let observer = null
const setupLazyLoading = () => {
if (loading !== 'lazy' || !imageRef.value) return
observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage()
observer.unobserve(entry.target)
}
})
},
{
rootMargin: '50px',
threshold: 0.1
}
)
observer.observe(imageRef.value)
}
// Load image with error handling
const loadImage = async () => {
if (isLoading.value || isLoaded.value) return
isLoading.value = true
hasError.value = false
try {
await preloadImage(optimalSrc.value)
currentSrc.value = optimalSrc.value
isLoaded.value = true
onLoad()
} catch (error) {
console.warn('Image load failed:', error)
hasError.value = true
currentSrc.value = placeholder
onError(error)
} finally {
isLoading.value = false
}
}
// Preload image function
const preloadImage = (url) => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = resolve
img.onerror = reject
img.src = url
})
}
// Watch for src changes
watch(src, () => {
if (loading === 'eager') {
loadImage()
}
}, { immediate: true })
onMounted(async () => {
await detectFormatSupport()
if (loading === 'eager') {
loadImage()
} else {
setupLazyLoading()
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
return {
imageRef,
currentSrc,
responsiveSrcset,
isLoaded,
isLoading,
hasError,
loadImage,
formatSupport
}
}
// Utility functions
async function testFormatSupport(format) {
const testImages = {
webp: 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==',
avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEDQgMgkQAAAAB8dSLfI='
}
if (!testImages[format]) return false
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
img.src = testImages[format]
})
}
function generateOptimalUrl(baseSrc, format, quality) {
// This would integrate with your image service
// Example: return `/api/images?src=${encodeURIComponent(baseSrc)}&format=${format}&quality=${quality}`
return baseSrc
}
function generateResponsiveSrcset(src, formats, support) {
const sizes = [375, 768, 1024, 1440, 1920]
const bestFormat = formats.find(f => support[f]) || 'jpg'
return sizes
.map(size => `${generateOptimalUrl(src, bestFormat, 80, size)} ${size}w`)
.join(', ')
}
Responsive Image Composable
// composables/useResponsiveImage.js
import { ref, computed, onMounted, onUnmounted } from 'vue'
export function useResponsiveImage(imageSets, options = {}) {
const {
breakpoints = {
mobile: 768,
tablet: 1024,
desktop: 1440
},
defaultSize = 'mobile',
retinaSupport = true
} = options
const currentBreakpoint = ref(defaultSize)
const devicePixelRatio = ref(1)
const viewportWidth = ref(0)
// Calculate current breakpoint
const updateBreakpoint = () => {
viewportWidth.value = window.innerWidth
devicePixelRatio.value = window.devicePixelRatio || 1
if (viewportWidth.value >= breakpoints.desktop) {
currentBreakpoint.value = 'desktop'
} else if (viewportWidth.value >= breakpoints.tablet) {
currentBreakpoint.value = 'tablet'
} else {
currentBreakpoint.value = 'mobile'
}
}
// Get optimal image for current conditions
const optimalImage = computed(() => {
const imageSet = imageSets[currentBreakpoint.value] || imageSets[defaultSize]
if (!imageSet) return null
// Select retina version if available and needed
if (retinaSupport && devicePixelRatio.value >= 2 && imageSet.retina) {
return imageSet.retina
}
return imageSet.normal || imageSet
})
// Generate sizes attribute for responsive images
const sizesAttribute = computed(() => {
const conditions = []
if (breakpoints.desktop && viewportWidth.value >= breakpoints.desktop) {
conditions.push(`(min-width: ${breakpoints.desktop}px) 50vw`)
}
if (breakpoints.tablet && viewportWidth.value >= breakpoints.tablet) {
conditions.push(`(min-width: ${breakpoints.tablet}px) 75vw`)
}
conditions.push('100vw')
return conditions.join(', ')
})
// Preload critical images
const preloadCriticalImages = () => {
const criticalBreakpoints = ['mobile', currentBreakpoint.value]
criticalBreakpoints.forEach(bp => {
const imageSet = imageSets[bp]
if (imageSet) {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'image'
link.href = imageSet.normal || imageSet
document.head.appendChild(link)
}
})
}
let resizeTimeout
const handleResize = () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(updateBreakpoint, 150)
}
onMounted(() => {
updateBreakpoint()
window.addEventListener('resize', handleResize)
// Preload critical images after a short delay
setTimeout(preloadCriticalImages, 100)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
clearTimeout(resizeTimeout)
})
return {
currentBreakpoint,
optimalImage,
sizesAttribute,
viewportWidth,
devicePixelRatio,
preloadCriticalImages
}
}
Advanced Vue Image Components
Smart Image Component
<!-- components/SmartImage.vue -->
<template>
<div class="smart-image-container" :style="containerStyle">
<img
ref="imageRef"
:src="currentSrc"
:srcset="responsiveSrcset"
:sizes="sizes"
:alt="alt"
:loading="loading"
:width="width"
:height="height"
:class="imageClasses"
@load="handleLoad"
@error="handleError"
/>
<!-- Loading placeholder -->
<div
v-if="isLoading && showPlaceholder"
class="image-placeholder"
:style="placeholderStyle"
>
<slot name="loading">
<div class="loading-spinner"></div>
</slot>
</div>
<!-- Error fallback -->
<div
v-if="hasError && showError"
class="image-error"
:style="errorStyle"
>
<slot name="error">
<div class="error-message">Failed to load image</div>
</slot>
</div>
<!-- Progressive enhancement overlay -->
<div
v-if="enableProgressiveEnhancement && !isLoaded"
class="progressive-overlay"
:style="progressiveStyle"
></div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useSmartImage } from '@/composables/useSmartImage'
const props = defineProps({
src: String,
srcset: String,
sizes: String,
alt: String,
width: [Number, String],
height: [Number, String],
loading: {
type: String,
default: 'lazy',
validator: value => ['lazy', 'eager'].includes(value)
},
placeholder: String,
formats: {
type: Array,
default: () => ['avif', 'webp', 'jpg']
},
quality: {
type: Number,
default: 80
},
showPlaceholder: {
type: Boolean,
default: true
},
showError: {
type: Boolean,
default: true
},
enableProgressiveEnhancement: {
type: Boolean,
default: false
},
aspectRatio: String,
objectFit: {
type: String,
default: 'cover'
}
})
const emit = defineEmits(['load', 'error', 'loading-start'])
// Use smart image composable
const {
imageRef,
currentSrc,
responsiveSrcset,
isLoaded,
isLoading,
hasError,
formatSupport
} = useSmartImage({
src: computed(() => props.src),
srcset: computed(() => props.srcset),
sizes: computed(() => props.sizes),
alt: computed(() => props.alt),
loading: props.loading,
placeholder: props.placeholder,
formats: props.formats,
quality: props.quality,
onLoad: () => emit('load'),
onError: (error) => emit('error', error)
})
// Computed styles
const containerStyle = computed(() => ({
position: 'relative',
display: 'inline-block',
aspectRatio: props.aspectRatio,
width: props.width ? `${props.width}px` : undefined,
height: props.height ? `${props.height}px` : undefined
}))
const imageClasses = computed(() => ({
'smart-image': true,
'is-loaded': isLoaded.value,
'is-loading': isLoading.value,
'has-error': hasError.value
}))
const placeholderStyle = computed(() => ({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f0f0f0',
opacity: isLoading.value ? 1 : 0,
transition: 'opacity 0.3s ease'
}))
const errorStyle = computed(() => ({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fee',
color: '#c53030'
}))
const progressiveStyle = computed(() => ({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(45deg, #f0f0f0 25%, transparent 25%)',
backgroundSize: '20px 20px',
animation: 'progressive-loading 1s linear infinite'
}))
// Handle events
const handleLoad = (event) => {
emit('load', event)
}
const handleError = (event) => {
emit('error', event)
}
// Watch loading state
watch(isLoading, (loading) => {
if (loading) {
emit('loading-start')
}
})
</script>
<style scoped>
.smart-image-container {
overflow: hidden;
}
.smart-image {
width: 100%;
height: 100%;
object-fit: v-bind(objectFit);
transition: opacity 0.3s ease;
}
.smart-image.is-loading {
opacity: 0;
}
.smart-image.is-loaded {
opacity: 1;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes progressive-loading {
0% { background-position: 0 0; }
100% { background-position: 40px 40px; }
}
</style>
Gallery Component with Virtual Scrolling
<!-- components/VirtualImageGallery.vue -->
<template>
<div class="virtual-gallery" ref="containerRef" @scroll="handleScroll">
<div class="gallery-viewport" :style="viewportStyle">
<div
v-for="item in visibleItems"
:key="item.id"
class="gallery-item"
:style="getItemStyle(item)"
>
<SmartImage
:src="item.src"
:alt="item.alt"
:width="itemWidth"
:height="itemHeight"
:loading="getLoadingStrategy(item)"
@load="handleItemLoad(item)"
@error="handleItemError(item)"
/>
<div v-if="showCaptions" class="item-caption">
{{ item.caption }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import SmartImage from './SmartImage.vue'
const props = defineProps({
items: Array,
itemWidth: {
type: Number,
default: 200
},
itemHeight: {
type: Number,
default: 200
},
itemsPerRow: {
type: Number,
default: 5
},
gap: {
type: Number,
default: 10
},
showCaptions: {
type: Boolean,
default: false
},
preloadRange: {
type: Number,
default: 5
}
})
const emit = defineEmits(['item-load', 'item-error', 'scroll'])
const containerRef = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(0)
const loadedItems = ref(new Set())
const errorItems = ref(new Set())
// Calculate layout
const rowHeight = computed(() =>
props.itemHeight + props.gap + (props.showCaptions ? 30 : 0)
)
const totalRows = computed(() =>
Math.ceil(props.items.length / props.itemsPerRow)
)
const totalHeight = computed(() =>
totalRows.value * rowHeight.value
)
const viewportStyle = computed(() => ({
height: `${totalHeight.value}px`,
position: 'relative'
}))
// Virtual scrolling calculations
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / rowHeight.value)
const end = Math.min(
totalRows.value,
Math.ceil((scrollTop.value + containerHeight.value) / rowHeight.value) + 1
)
return { start, end }
})
const visibleItems = computed(() => {
const { start, end } = visibleRange.value
const items = []
for (let row = start; row < end; row++) {
for (let col = 0; col < props.itemsPerRow; col++) {
const index = row * props.itemsPerRow + col
if (index < props.items.length) {
items.push({
...props.items[index],
index,
row,
col
})
}
}
}
return items
})
// Item positioning
const getItemStyle = (item) => ({
position: 'absolute',
left: `${item.col * (props.itemWidth + props.gap)}px`,
top: `${item.row * rowHeight.value}px`,
width: `${props.itemWidth}px`,
height: `${props.itemHeight}px`
})
// Loading strategy based on visibility
const getLoadingStrategy = (item) => {
const { start, end } = visibleRange.value
const preloadStart = Math.max(0, start - props.preloadRange)
const preloadEnd = Math.min(totalRows.value, end + props.preloadRange)
if (item.row >= preloadStart && item.row < preloadEnd) {
return 'eager'
}
return 'lazy'
}
// Event handlers
const handleScroll = () => {
scrollTop.value = containerRef.value.scrollTop
emit('scroll', {
scrollTop: scrollTop.value,
visibleRange: visibleRange.value
})
}
const handleItemLoad = (item) => {
loadedItems.value.add(item.id)
emit('item-load', item)
}
const handleItemError = (item) => {
errorItems.value.add(item.id)
emit('item-error', item)
}
// Lifecycle
onMounted(async () => {
await nextTick()
containerHeight.value = containerRef.value.clientHeight
// Setup resize observer
const resizeObserver = new ResizeObserver(entries => {
containerHeight.value = entries[0].contentRect.height
})
resizeObserver.observe(containerRef.value)
onUnmounted(() => {
resizeObserver.disconnect()
})
})
</script>
<style scoped>
.virtual-gallery {
height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
}
.gallery-viewport {
position: relative;
}
.gallery-item {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.gallery-item:hover {
transform: scale(1.05);
}
.item-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px;
font-size: 12px;
text-align: center;
}
</style>
Performance Monitoring and Analytics
// composables/useImagePerformance.js
import { ref, onMounted } from 'vue'
export function useImagePerformance() {
const metrics = ref({
totalImages: 0,
loadedImages: 0,
failedImages: 0,
averageLoadTime: 0,
largestContentfulPaint: 0,
cumulativeLayoutShift: 0
})
const imageLoadTimes = ref(new Map())
const formatUsage = ref(new Map())
// Track image loading performance
const trackImageLoad = (src, startTime, format = 'unknown') => {
const loadTime = performance.now() - startTime
imageLoadTimes.value.set(src, loadTime)
formatUsage.value.set(format, (formatUsage.value.get(format) || 0) + 1)
metrics.value.loadedImages++
updateAverageLoadTime()
}
const trackImageError = (src, error) => {
metrics.value.failedImages++
console.warn(`Image failed to load: ${src}`, error)
}
const updateAverageLoadTime = () => {
const times = Array.from(imageLoadTimes.value.values())
metrics.value.averageLoadTime = times.reduce((a, b) => a + b, 0) / times.length
}
// Monitor Core Web Vitals
const monitorWebVitals = () => {
// Largest Contentful Paint
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries()
const lastEntry = entries[entries.length - 1]
metrics.value.largestContentfulPaint = lastEntry.startTime
}).observe({ entryTypes: ['largest-contentful-paint'] })
// Cumulative Layout Shift
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
metrics.value.cumulativeLayoutShift += entry.value
}
}
}).observe({ entryTypes: ['layout-shift'] })
}
// Get performance report
const getPerformanceReport = () => {
return {
...metrics.value,
formatBreakdown: Object.fromEntries(formatUsage.value),
recommendations: generateRecommendations()
}
}
const generateRecommendations = () => {
const recommendations = []
if (metrics.value.averageLoadTime > 2000) {
recommendations.push('Consider using more aggressive image compression')
}
if (metrics.value.failedImages / metrics.value.totalImages > 0.05) {
recommendations.push('High image failure rate detected - check image URLs')
}
if (metrics.value.cumulativeLayoutShift > 0.1) {
recommendations.push('Add explicit width/height to prevent layout shifts')
}
return recommendations
}
onMounted(() => {
monitorWebVitals()
})
return {
metrics,
trackImageLoad,
trackImageError,
getPerformanceReport
}
}
Image Optimization with Pinia Store
// stores/imageStore.js
import { defineStore } from 'pinia'
export const useImageStore = defineStore('images', {
state: () => ({
cache: new Map(),
preloadQueue: [],
formatSupport: {},
config: {
maxCacheSize: 100,
defaultQuality: 80,
enableWebP: true,
enableAVIF: true,
preloadDistance: 200
}
}),
getters: {
getCachedImage: (state) => (src) => {
return state.cache.get(src)
},
isCached: (state) => (src) => {
return state.cache.has(src)
},
cacheSize: (state) => {
return state.cache.size
},
supportedFormats: (state) => {
return Object.keys(state.formatSupport).filter(
format => state.formatSupport[format]
)
}
},
actions: {
async detectFormatSupport() {
const formats = ['webp', 'avif']
for (const format of formats) {
this.formatSupport[format] = await this.testFormat(format)
}
},
async testFormat(format) {
const testImages = {
webp: 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==',
avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEDQgMgkQAAAAB8dSLfI='
}
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
img.src = testImages[format]
})
},
cacheImage(src, imageData) {
// Implement LRU cache
if (this.cache.size >= this.config.maxCacheSize) {
const firstKey = this.cache.keys().next().value
this.cache.delete(firstKey)
}
this.cache.set(src, {
...imageData,
cachedAt: Date.now()
})
},
generateOptimizedUrl(src, options = {}) {
const {
width,
height,
quality = this.config.defaultQuality,
format = 'auto'
} = options
// Determine best format
let targetFormat = format
if (format === 'auto') {
if (this.formatSupport.avif && this.config.enableAVIF) {
targetFormat = 'avif'
} else if (this.formatSupport.webp && this.config.enableWebP) {
targetFormat = 'webp'
} else {
targetFormat = 'jpg'
}
}
// Build optimized URL (integrate with your image service)
const params = new URLSearchParams({
src: encodeURIComponent(src),
f: targetFormat,
q: quality.toString()
})
if (width) params.set('w', width.toString())
if (height) params.set('h', height.toString())
return `/api/images?${params.toString()}`
},
async preloadImage(src, options = {}) {
const optimizedUrl = this.generateOptimizedUrl(src, options)
if (this.isCached(optimizedUrl)) {
return this.getCachedImage(optimizedUrl)
}
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const imageData = {
src: optimizedUrl,
width: img.naturalWidth,
height: img.naturalHeight,
loaded: true
}
this.cacheImage(optimizedUrl, imageData)
resolve(imageData)
}
img.onerror = () => {
reject(new Error(`Failed to preload image: ${optimizedUrl}`))
}
img.src = optimizedUrl
})
},
addToPreloadQueue(src, priority = 0) {
this.preloadQueue.push({ src, priority })
this.preloadQueue.sort((a, b) => b.priority - a.priority)
},
async processPreloadQueue() {
while (this.preloadQueue.length > 0) {
const { src } = this.preloadQueue.shift()
try {
await this.preloadImage(src)
} catch (error) {
console.warn('Preload failed:', error)
}
}
},
clearCache() {
this.cache.clear()
},
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig }
}
}
})
Vue Router Integration for Image Preloading
// router/imagePreloader.js
import { useImageStore } from '@/stores/imageStore'
export function setupImagePreloading(router) {
const imageStore = useImageStore()
// Preload images for route components
router.beforeEach(async (to, from, next) => {
const routeComponent = to.matched[0]?.components?.default
if (routeComponent && routeComponent.preloadImages) {
try {
const imagesToPreload = await routeComponent.preloadImages(to)
// Add high priority preloading for critical images
imagesToPreload.forEach((imageConfig, index) => {
const priority = index < 3 ? 10 : 5 // First 3 images high priority
imageStore.addToPreloadQueue(imageConfig.src, priority)
})
// Start preloading process
imageStore.processPreloadQueue()
} catch (error) {
console.warn('Route image preloading failed:', error)
}
}
next()
})
}
// Example route component with preload definition
// pages/Gallery.vue
export default {
async preloadImages(route) {
// Return images to preload for this route
const galleryId = route.params.id
const response = await fetch(`/api/galleries/${galleryId}/images`)
const images = await response.json()
return images.slice(0, 10).map(img => ({
src: img.url,
width: 400,
height: 300
}))
}
}
Testing Image Optimization
When implementing advanced image optimization in Vue.js applications, thorough testing is crucial to ensure performance gains are realized across different scenarios. I often use tools like ConverterToolsKit during development to generate test images in various formats and sizes, helping validate that the optimization strategies work correctly before deploying to production.
// tests/imageOptimization.test.js
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import SmartImage from '@/components/SmartImage.vue'
import { useImageStore } from '@/stores/imageStore'
describe('Image Optimization', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('SmartImage Component', () => {
it('should lazy load images by default', async () => {
const wrapper = mount(SmartImage, {
props: {
src: 'test-image.jpg',
alt: 'Test image'
}
})
// Should not load immediately
expect(wrapper.find('img').attributes('src')).toBe('')
// Simulate intersection
const intersectionObserver = global.IntersectionObserver
const mockObserver = {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}
global.IntersectionObserver = vi.fn(() => mockObserver)
await wrapper.vm.$nextTick()
expect(mockObserver.observe).toHaveBeenCalled()
})
it('should handle format detection correctly', async () => {
const wrapper = mount(SmartImage, {
props: {
src: 'test-image.jpg',
formats: ['avif', 'webp', 'jpg']
}
})
// Mock format support
wrapper.vm.formatSupport = { avif: true, webp: true }
await wrapper.vm.$nextTick()
// Should prefer AVIF when supported
expect(wrapper.vm.optimalSrc).toContain('avif')
})
it('should handle loading errors gracefully', async () => {
const wrapper = mount(SmartImage, {
props: {
src: 'invalid-image.jpg',
showError: true
}
})
// Simulate error
await wrapper.find('img').trigger('error')
expect(wrapper.find('.image-error').exists()).toBe(true)
expect(wrapper.emitted('error')).toBeTruthy()
})
})
describe('Image Store', () => {
it('should cache images correctly', () => {
const imageStore = useImageStore()
const imageData = {
src: 'test.jpg',
width: 800,
height: 600,
loaded: true
}
imageStore.cacheImage('test.jpg', imageData)
expect(imageStore.isCached('test.jpg')).toBe(true)
expect(imageStore.getCachedImage('test.jpg')).toMatchObject(imageData)
})
it('should generate optimized URLs', () => {
const imageStore = useImageStore()
imageStore.formatSupport = { webp: true, avif: false }
const url = imageStore.generateOptimizedUrl('test.jpg', {
width: 800,
quality: 90
})
expect(url).toContain('f=webp')
expect(url).toContain('w=800')
expect(url).toContain('q=90')
})
it('should implement LRU cache eviction', () => {
const imageStore = useImageStore()
imageStore.config.maxCacheSize = 2
imageStore.cacheImage('image1.jpg', { loaded: true })
imageStore.cacheImage('image2.jpg', { loaded: true })
imageStore.cacheImage('image3.jpg', { loaded: true })
expect(imageStore.cacheSize).toBe(2)
expect(imageStore.isCached('image1.jpg')).toBe(false)
expect(imageStore.isCached('image3.jpg')).toBe(true)
})
})
describe('Performance Monitoring', () => {
it('should track image load times', () => {
const { trackImageLoad, metrics } = useImagePerformance()
trackImageLoad('test.jpg', performance.now() - 1000, 'webp')
expect(metrics.value.loadedImages).toBe(1)
expect(metrics.value.averageLoadTime).toBeGreaterThan(0)
})
it('should generate performance recommendations', () => {
const { getPerformanceReport } = useImagePerformance()
// Simulate poor performance metrics
metrics.value.averageLoadTime = 3000
metrics.value.cumulativeLayoutShift = 0.2
const report = getPerformanceReport()
expect(report.recommendations).toContain(
'Consider using more aggressive image compression'
)
expect(report.recommendations).toContain(
'Add explicit width/height to prevent layout shifts'
)
})
})
})
SSR and Nuxt.js Integration
// plugins/imageOptimization.client.js (Nuxt 3)
export default defineNuxtPlugin(async () => {
const imageStore = useImageStore()
// Initialize format support detection on client side
await imageStore.detectFormatSupport()
// Setup intersection observer polyfill if needed
if (!window.IntersectionObserver) {
const polyfill = await import('intersection-observer')
window.IntersectionObserver = polyfill.default
}
// Setup performance monitoring
if (process.client) {
const { trackImageLoad, trackImageError } = useImagePerformance()
// Global image event listeners
document.addEventListener('load', (event) => {
if (event.target.tagName === 'IMG') {
trackImageLoad(event.target.src, event.target.dataset.startTime)
}
}, true)
document.addEventListener('error', (event) => {
if (event.target.tagName === 'IMG') {
trackImageError(event.target.src, event)
}
}, true)
}
})
// nuxt.config.ts
export default defineNuxtConfig({
// Image optimization configuration
image: {
// Provider configuration
provider: 'custom',
providers: {
custom: {
name: 'custom',
provider: '~/providers/imageProvider.ts',
options: {
baseURL: process.env.IMAGE_BASE_URL
}
}
},
// Format configuration
formats: ['avif', 'webp', 'jpg'],
// Screen sizes for responsive images
screens: {
xs: 375,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
xxl: 1536
},
// Preload critical images
preload: {
// Preload above-the-fold images
criticalImages: true
}
},
// CSS for image optimization
css: ['~/assets/css/images.css'],
// Auto-import composables
imports: {
dirs: ['composables/image']
}
})
// providers/imageProvider.ts
export default defineProvider({
name: 'custom',
supportsAlias: true,
getImage(src, { modifiers = {}, baseURL } = {}) {
const {
width,
height,
format = 'auto',
quality = 80,
fit = 'cover'
} = modifiers
const params = new URLSearchParams({
src: encodeURIComponent(src),
f: format,
q: quality.toString(),
fit
})
if (width) params.set('w', width.toString())
if (height) params.set('h', height.toString())
return {
url: `${baseURL}/optimize?${params.toString()}`
}
}
})
Advanced Image Effects and Filters
<!-- components/ImageWithEffects.vue -->
<template>
<div class="image-effects-container">
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
:style="canvasStyle"
@click="handleCanvasClick"
></canvas>
<div class="effects-controls" v-if="showControls">
<div class="control-group">
<label>Brightness</label>
<input
type="range"
min="0"
max="200"
v-model="effects.brightness"
@input="applyEffects"
/>
</div>
<div class="control-group">
<label>Contrast</label>
<input
type="range"
min="0"
max="200"
v-model="effects.contrast"
@input="applyEffects"
/>
</div>
<div class="control-group">
<label>Saturation</label>
<input
type="range"
min="0"
max="200"
v-model="effects.saturation"
@input="applyEffects"
/>
</div>
<div class="control-group">
<label>Blur</label>
<input
type="range"
min="0"
max="10"
step="0.1"
v-model="effects.blur"
@input="applyEffects"
/>
</div>
<button @click="resetEffects">Reset</button>
<button @click="downloadImage">Download</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
const props = defineProps({
src: String,
width: Number,
height: Number,
showControls: {
type: Boolean,
default: true
},
initialEffects: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['effects-change', 'image-ready'])
const canvasRef = ref(null)
const originalImage = ref(null)
const imageLoaded = ref(false)
const effects = ref({
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
...props.initialEffects
})
const canvasWidth = computed(() => props.width || 800)
const canvasHeight = computed(() => props.height || 600)
const canvasStyle = computed(() => ({
width: `${canvasWidth.value}px`,
height: `${canvasHeight.value}px`,
border: '1px solid #ddd',
borderRadius: '8px'
}))
// Load original image
const loadImage = async () => {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous' // Enable CORS for canvas manipulation
img.onload = () => {
originalImage.value = img
imageLoaded.value = true
applyEffects()
emit('image-ready', img)
resolve(img)
}
img.onerror = reject
img.src = props.src
})
}
// Apply visual effects using canvas
const applyEffects = () => {
if (!imageLoaded.value || !canvasRef.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
const img = originalImage.value
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Calculate scaling to fit canvas
const scale = Math.min(
canvas.width / img.width,
canvas.height / img.height
)
const scaledWidth = img.width * scale
const scaledHeight = img.height * scale
const x = (canvas.width - scaledWidth) / 2
const y = (canvas.height - scaledHeight) / 2
// Apply CSS filter effects
ctx.filter = buildFilterString()
// Draw image with effects
ctx.drawImage(img, x, y, scaledWidth, scaledHeight)
// Reset filter for future operations
ctx.filter = 'none'
emit('effects-change', { ...effects.value })
}
const buildFilterString = () => {
const filters = []
if (effects.value.brightness !== 100) {
filters.push(`brightness(${effects.value.brightness}%)`)
}
if (effects.value.contrast !== 100) {
filters.push(`contrast(${effects.value.contrast}%)`)
}
if (effects.value.saturation !== 100) {
filters.push(`saturate(${effects.value.saturation}%)`)
}
if (effects.value.blur > 0) {
filters.push(`blur(${effects.value.blur}px)`)
}
return filters.join(' ') || 'none'
}
const resetEffects = () => {
effects.value = {
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0
}
applyEffects()
}
const downloadImage = () => {
const canvas = canvasRef.value
const link = document.createElement('a')
link.download = 'processed-image.png'
link.href = canvas.toDataURL()
link.click()
}
const handleCanvasClick = (event) => {
const rect = canvasRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
console.log(`Canvas clicked at: ${x}, ${y}`)
}
// Watch for src changes
watch(() => props.src, () => {
if (props.src) {
loadImage()
}
}, { immediate: true })
onMounted(() => {
if (props.src) {
loadImage()
}
})
</script>
<style scoped>
.image-effects-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.effects-controls {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
min-width: 600px;
}
.control-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.control-group label {
font-size: 12px;
font-weight: 600;
color: #555;
}
.control-group input[type="range"] {
width: 100px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #0056b3;
}
</style>
Performance Optimization Strategies
// composables/useImageOptimization.js
import { ref, computed, watch, onMounted } from 'vue'
import { useImageStore } from '@/stores/imageStore'
export function useImageOptimization() {
const imageStore = useImageStore()
const networkInfo = ref({})
const deviceInfo = ref({})
// Network-aware image optimization
const getOptimalQuality = computed(() => {
const connection = networkInfo.value
if (!connection) return 80
if (connection.saveData) return 60
switch (connection.effectiveType) {
case 'slow-2g': return 50
case '2g': return 60
case '3g': return 75
case '4g': return 85
default: return 80
}
})
// Device-aware sizing
const getOptimalSize = computed(() => {
const dpr = deviceInfo.value.pixelRatio || 1
const memory = deviceInfo.value.memory || 4
// Reduce image sizes on low-memory devices
if (memory < 2) {
return {
maxWidth: 800,
maxHeight: 600,
quality: 70
}
}
// Scale images for high-DPI displays
return {
maxWidth: dpr > 1 ? 1600 : 1200,
maxHeight: dpr > 1 ? 1200 : 900,
quality: getOptimalQuality.value
}
})
// Adaptive image loading strategy
const getLoadingStrategy = (imageIndex, priority = 'normal') => {
const connection = networkInfo.value
const isSlowConnection = ['slow-2g', '2g'].includes(connection.effectiveType)
if (priority === 'critical') {
return 'eager'
}
if (isSlowConnection && imageIndex > 5) {
return 'lazy-aggressive' // Custom loading strategy
}
return imageIndex < 3 ? 'eager' : 'lazy'
}
// Image preloading based on user behavior
const preloadBasedOnBehavior = () => {
let scrollDirection = 'down'
let lastScrollY = 0
const handleScroll = () => {
const currentScrollY = window.scrollY
scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up'
lastScrollY = currentScrollY
// Preload images in scroll direction
if (scrollDirection === 'down') {
preloadImagesBelow()
} else {
preloadImagesAbove()
}
}
const preloadImagesBelow = () => {
const images = document.querySelectorAll('img[data-src]:not([data-preloaded])')
const viewportHeight = window.innerHeight
const scrollY = window.scrollY
images.forEach(img => {
const rect = img.getBoundingClientRect()
const imageTop = rect.top + scrollY
// Preload images within 2 viewport heights below
if (imageTop <= scrollY + viewportHeight * 3) {
preloadImage(img)
}
})
}
const preloadImagesAbove = () => {
// Similar logic for upward scrolling
}
const preloadImage = (img) => {
if (img.dataset.preloaded) return
const src = img.dataset.src
if (src) {
imageStore.preloadImage(src)
img.dataset.preloaded = 'true'
}
}
// Throttled scroll handler
let scrollTimeout
window.addEventListener('scroll', () => {
clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(handleScroll, 100)
})
}
// Progressive image enhancement
const enhanceImagesProgressively = () => {
const images = document.querySelectorAll('img[data-enhance]')
images.forEach(img => {
const enhanceConfig = JSON.parse(img.dataset.enhance || '{}')
// Start with low quality placeholder
if (enhanceConfig.placeholder) {
img.src = enhanceConfig.placeholder
}
// Load higher quality version
const highQualityUrl = imageStore.generateOptimizedUrl(enhanceConfig.src, {
quality: getOptimalQuality.value,
...getOptimalSize.value
})
const highQualityImg = new Image()
highQualityImg.onload = () => {
img.src = highQualityUrl
img.classList.add('enhanced')
}
highQualityImg.src = highQualityUrl
})
}
// Initialize optimization features
const initializeOptimization = () => {
// Detect network and device capabilities
if ('connection' in navigator) {
networkInfo.value = navigator.connection
navigator.connection.addEventListener('change', () => {
networkInfo.value = { ...navigator.connection }
})
}
deviceInfo.value = {
pixelRatio: window.devicePixelRatio,
memory: navigator.deviceMemory,
cores: navigator.hardwareConcurrency
}
// Start behavior-based preloading
preloadBasedOnBehavior()
// Enhance existing images
enhanceImagesProgressively()
}
onMounted(initializeOptimization)
return {
getOptimalQuality,
getOptimalSize,
getLoadingStrategy,
networkInfo,
deviceInfo,
enhanceImagesProgressively
}
}
Conclusion
Vue.js image optimization goes far beyond basic <img>
tags. The advanced techniques covered here enable applications to deliver exceptional image performance through:
Smart Loading Strategies:
- Intersection Observer-based lazy loading with preload hints
- Network-aware quality adjustment and format selection
- Device-capability-based sizing and memory management
- Behavior-driven preloading based on user scroll patterns
Advanced Component Architecture:
- Reusable composables for image optimization logic
- Virtual scrolling for large image galleries
- Progressive enhancement with fallback strategies
- Real-time image effects and filtering capabilities
Performance Monitoring:
- Core Web Vitals tracking for image-related metrics
- Format usage analytics and optimization recommendations
- Load time monitoring and performance regression detection
- Automated testing strategies for image optimization features
State Management Integration:
- Centralized image caching with LRU eviction
- Format support detection and optimal URL generation
- Preload queue management and priority handling
- SSR-aware optimization for Nuxt.js applications
Key Implementation Strategies:
- Start with composables for reusable image optimization logic
- Implement progressive enhancement to improve perceived performance
- Use centralized state management for caching and configuration
- Monitor performance metrics to validate optimization effectiveness
- Test thoroughly across different network and device conditions
The techniques demonstrated here scale from small applications to enterprise-level Vue.js projects. They provide the foundation for delivering optimized image experiences that adapt to user conditions while maintaining excellent developer experience.
Modern users expect fast, responsive applications regardless of their device or network conditions. These advanced Vue.js image optimization strategies ensure your applications meet those expectations while providing the flexibility to evolve with changing requirements and new technologies.
What Vue.js image optimization challenges have you encountered? Have you implemented similar composables or found other creative solutions for image performance? Share your experiences and optimization techniques in the comments!
Top comments (0)