DEV Community

Hardi
Hardi

Posted on

Vue.js Image Optimization: Beyond the Basics

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"
  }
};
Enter fullscreen mode Exit fullscreen mode

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(', ')
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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
    }))
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
      )
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

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()}`
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Start with composables for reusable image optimization logic
  2. Implement progressive enhancement to improve perceived performance
  3. Use centralized state management for caching and configuration
  4. Monitor performance metrics to validate optimization effectiveness
  5. 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)