Files
Portfolio/blog-posts/77-image-optimization-web.md
damjan_savicandClaude Opus 4.5 43484c5023 Add blog posts, cleanup unused files, update components
- Add 100 blog posts covering AI, development, and tech topics
- Add .env.example for environment configuration
- Add accessibility and lighthouse audit scripts
- Remove obsolete SEO reports and temporary files
- Remove dev-dist build artifacts and backup files
- Remove unused portrait images (moved/consolidated elsewhere)
- Update contact form and component improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:42:11 +01:00

757 lines
20 KiB
Markdown

# Image Optimization für Web
**Meta-Description:** Bilder für Web optimieren. Next.js Image, AVIF/WebP, Responsive Images und Lazy Loading für beste Performance.
**Keywords:** Image Optimization, WebP, AVIF, Next.js Image, Responsive Images, Lazy Loading, srcset, Performance
---
## Einführung
**Bilder** machen oft 50-70% der Seitengröße aus. Mit **Next.js Image Component**, **modernen Formaten** (AVIF, WebP) und **Responsive Images** kann die Ladezeit drastisch reduziert werden. Dieser Guide zeigt Best Practices für optimale Bildperformance.
---
## Image Optimization Overview
```
┌─────────────────────────────────────────────────────────────┐
│ IMAGE OPTIMIZATION PIPELINE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Format Comparison: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Original PNG (1200x800): 2.4 MB │ │
│ │ ├── JPEG (quality 80): 180 KB (-92%) │ │
│ │ ├── WebP (quality 80): 120 KB (-95%) │ │
│ │ └── AVIF (quality 70): 80 KB (-97%) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Browser Support (2026): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ AVIF: Chrome ✓ Firefox ✓ Safari ✓ Edge ✓ │ │
│ │ WebP: Chrome ✓ Firefox ✓ Safari ✓ Edge ✓ │ │
│ │ JPEG: Universal ✓ │ │
│ │ PNG: Universal ✓ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Responsive Strategy: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Mobile (< 640px): 640w - 40KB │ │
│ │ Tablet (< 1024px): 1024w - 80KB │ │
│ │ Desktop (< 1920px): 1920w - 150KB │ │
│ │ Retina (2x): 2x scaling │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Loading Strategies: │
│ ├── eager: Above-the-fold, LCP images │
│ ├── lazy: Below-the-fold, defer loading │
│ └── blur: Placeholder während Loading │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Next.js Image Component
```tsx
// components/OptimizedImage.tsx
import Image from 'next/image';
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
priority?: boolean;
fill?: boolean;
sizes?: string;
className?: string;
quality?: number;
}
export function OptimizedImage({
src,
alt,
width,
height,
priority = false,
fill = false,
sizes,
className,
quality = 80
}: OptimizedImageProps) {
// Blur Placeholder generieren (optional)
const blurDataURL = `data:image/svg+xml;base64,${toBase64(shimmer(width || 700, height || 475))}`;
return (
<Image
src={src}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
fill={fill}
priority={priority}
quality={quality}
sizes={sizes || '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'}
className={className}
placeholder="blur"
blurDataURL={blurDataURL}
style={fill ? { objectFit: 'cover' } : undefined}
/>
);
}
// Shimmer Placeholder Generator
const shimmer = (w: number, h: number) => `
<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g">
<stop stop-color="#f6f7f8" offset="0%"/>
<stop stop-color="#edeef1" offset="20%"/>
<stop stop-color="#f6f7f8" offset="40%"/>
<stop stop-color="#f6f7f8" offset="100%"/>
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="#f6f7f8"/>
<rect width="${w}" height="${h}" fill="url(#g)">
<animate attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite"/>
</rect>
</svg>`;
const toBase64 = (str: string) =>
typeof window === 'undefined'
? Buffer.from(str).toString('base64')
: window.btoa(str);
```
```tsx
// components/ResponsiveHero.tsx - Hero Image mit verschiedenen Breakpoints
import Image from 'next/image';
interface ResponsiveHeroProps {
desktopSrc: string;
mobileSrc: string;
alt: string;
}
export function ResponsiveHero({ desktopSrc, mobileSrc, alt }: ResponsiveHeroProps) {
return (
<div className="relative w-full h-[50vh] md:h-[70vh]">
{/* Mobile Image */}
<Image
src={mobileSrc}
alt={alt}
fill
priority
sizes="100vw"
className="object-cover md:hidden"
/>
{/* Desktop Image */}
<Image
src={desktopSrc}
alt={alt}
fill
priority
sizes="100vw"
className="hidden md:block object-cover"
/>
</div>
);
}
// components/ArtDirectedImage.tsx - Art Direction mit Picture Element
export function ArtDirectedImage({
sources,
fallback,
alt
}: {
sources: Array<{ srcSet: string; media: string }>;
fallback: string;
alt: string;
}) {
return (
<picture>
{sources.map((source, i) => (
<source key={i} srcSet={source.srcSet} media={source.media} />
))}
<img src={fallback} alt={alt} loading="lazy" />
</picture>
);
}
```
---
## Next.js Image Configuration
```javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
// Moderne Formate (AVIF hat Priorität)
formats: ['image/avif', 'image/webp'],
// Device Sizes für srcset
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// Image Sizes für kleinere Bilder (Icons, Thumbnails)
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Remote Patterns für externe Bilder
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '/**'
},
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/images/**'
},
{
protocol: 'https',
hostname: '*.cloudinary.com'
}
],
// Minimum Cache Time (30 Tage)
minimumCacheTTL: 60 * 60 * 24 * 30,
// Deaktiviert static imports für Images
disableStaticImages: false,
// Content-Disposition Header
contentDispositionType: 'inline',
// Dangerously Allow SVG (Vorsicht!)
dangerouslyAllowSVG: false,
// Unoptimized für Static Export
// unoptimized: true
}
};
module.exports = nextConfig;
```
---
## Image Processing with Sharp
```typescript
// scripts/optimize-images.ts
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';
import fs from 'fs/promises';
interface OptimizationOptions {
quality: number;
formats: ('webp' | 'avif' | 'jpeg')[];
sizes: number[];
outputDir: string;
}
async function optimizeImage(
inputPath: string,
options: OptimizationOptions
) {
const image = sharp(inputPath);
const metadata = await image.metadata();
const baseName = path.basename(inputPath, path.extname(inputPath));
const results: string[] = [];
for (const format of options.formats) {
for (const width of options.sizes) {
// Skip wenn größer als Original
if (metadata.width && width > metadata.width) continue;
const outputName = `${baseName}-${width}w.${format}`;
const outputPath = path.join(options.outputDir, outputName);
let pipeline = image.clone().resize(width, null, {
withoutEnlargement: true,
fit: 'inside'
});
switch (format) {
case 'webp':
pipeline = pipeline.webp({
quality: options.quality,
effort: 6 // 0-6, higher = slower + better
});
break;
case 'avif':
pipeline = pipeline.avif({
quality: options.quality,
effort: 6 // 0-9
});
break;
case 'jpeg':
pipeline = pipeline.jpeg({
quality: options.quality,
mozjpeg: true
});
break;
}
await pipeline.toFile(outputPath);
results.push(outputPath);
}
}
return results;
}
// Batch Processing
async function batchOptimize(inputDir: string, outputDir: string) {
const files = await glob(`${inputDir}/**/*.{jpg,jpeg,png,webp}`);
await fs.mkdir(outputDir, { recursive: true });
const options: OptimizationOptions = {
quality: 80,
formats: ['avif', 'webp', 'jpeg'],
sizes: [640, 1024, 1920, 2560],
outputDir
};
console.log(`Processing ${files.length} images...`);
for (const file of files) {
const results = await optimizeImage(file, options);
console.log(`${path.basename(file)}: Generated ${results.length} variants`);
}
}
// Blur Placeholder Generation
async function generateBlurPlaceholder(imagePath: string): Promise<string> {
const buffer = await sharp(imagePath)
.resize(10, 10, { fit: 'inside' })
.blur()
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}
// LQIP (Low Quality Image Placeholder)
async function generateLQIP(imagePath: string): Promise<{
base64: string;
width: number;
height: number;
}> {
const metadata = await sharp(imagePath).metadata();
const buffer = await sharp(imagePath)
.resize(20, null, { fit: 'inside' })
.jpeg({ quality: 20 })
.toBuffer();
return {
base64: `data:image/jpeg;base64,${buffer.toString('base64')}`,
width: metadata.width || 0,
height: metadata.height || 0
};
}
```
---
## Responsive Images Pattern
```tsx
// components/ResponsiveImage.tsx
'use client';
import { useState } from 'react';
interface ImageSource {
src: string;
width: number;
format: 'avif' | 'webp' | 'jpeg';
}
interface ResponsiveImageProps {
sources: ImageSource[];
alt: string;
sizes: string;
priority?: boolean;
className?: string;
blurDataURL?: string;
}
export function ResponsiveImage({
sources,
alt,
sizes,
priority = false,
className,
blurDataURL
}: ResponsiveImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
// Gruppiere nach Format
const avifSources = sources.filter(s => s.format === 'avif');
const webpSources = sources.filter(s => s.format === 'webp');
const jpegSources = sources.filter(s => s.format === 'jpeg');
const generateSrcSet = (sources: ImageSource[]) =>
sources.map(s => `${s.src} ${s.width}w`).join(', ');
const fallbackSrc = jpegSources[jpegSources.length - 1]?.src || '';
return (
<div className={`relative ${className}`}>
{/* Blur Placeholder */}
{blurDataURL && !isLoaded && (
<img
src={blurDataURL}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover blur-lg scale-110"
/>
)}
<picture>
{/* AVIF Sources */}
{avifSources.length > 0 && (
<source
type="image/avif"
srcSet={generateSrcSet(avifSources)}
sizes={sizes}
/>
)}
{/* WebP Sources */}
{webpSources.length > 0 && (
<source
type="image/webp"
srcSet={generateSrcSet(webpSources)}
sizes={sizes}
/>
)}
{/* JPEG Fallback */}
<img
src={fallbackSrc}
srcSet={generateSrcSet(jpegSources)}
sizes={sizes}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
onLoad={() => setIsLoaded(true)}
className={`w-full h-full object-cover transition-opacity duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
/>
</picture>
</div>
);
}
```
---
## Lazy Loading Strategies
```tsx
// components/LazyImage.tsx - Intersection Observer
'use client';
import { useState, useRef, useEffect } from 'react';
interface LazyImageProps {
src: string;
alt: string;
width: number;
height: number;
threshold?: number;
rootMargin?: string;
}
export function LazyImage({
src,
alt,
width,
height,
threshold = 0.1,
rootMargin = '100px'
}: LazyImageProps) {
const [isInView, setIsInView] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ threshold, rootMargin }
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, [threshold, rootMargin]);
const aspectRatio = (height / width) * 100;
return (
<div
ref={imgRef}
className="relative overflow-hidden bg-gray-100"
style={{ paddingBottom: `${aspectRatio}%` }}
>
{isInView && (
<img
src={src}
alt={alt}
onLoad={() => setIsLoaded(true)}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-500 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
/>
)}
{!isLoaded && (
<div className="absolute inset-0 animate-pulse bg-gray-200" />
)}
</div>
);
}
// hooks/useLazyImages.ts - Batch Lazy Loading
export function useLazyImages() {
useEffect(() => {
if ('loading' in HTMLImageElement.prototype) {
// Native lazy loading supported
const images = document.querySelectorAll<HTMLImageElement>('img[data-src]');
images.forEach(img => {
img.src = img.dataset.src!;
img.removeAttribute('data-src');
});
} else {
// Fallback to Intersection Observer
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!;
img.removeAttribute('data-src');
observer.unobserve(img);
}
});
},
{ rootMargin: '50px' }
);
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
}
}, []);
}
```
---
## Image Gallery with Lightbox
```tsx
// components/ImageGallery.tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
interface GalleryImage {
src: string;
alt: string;
width: number;
height: number;
thumbnail?: string;
}
interface ImageGalleryProps {
images: GalleryImage[];
columns?: 2 | 3 | 4;
}
export function ImageGallery({ images, columns = 3 }: ImageGalleryProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const columnClass = {
2: 'grid-cols-2',
3: 'grid-cols-2 md:grid-cols-3',
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
}[columns];
return (
<>
<div className={`grid ${columnClass} gap-4`}>
{images.map((image, index) => (
<button
key={index}
onClick={() => setSelectedIndex(index)}
className="relative aspect-square overflow-hidden rounded-lg group"
>
<Image
src={image.thumbnail || image.src}
alt={image.alt}
fill
sizes="(max-width: 768px) 50vw, 33vw"
className="object-cover transition-transform group-hover:scale-105"
/>
</button>
))}
</div>
{/* Lightbox */}
{selectedIndex !== null && (
<Lightbox
images={images}
currentIndex={selectedIndex}
onClose={() => setSelectedIndex(null)}
onNavigate={setSelectedIndex}
/>
)}
</>
);
}
// Lightbox Component
function Lightbox({
images,
currentIndex,
onClose,
onNavigate
}: {
images: GalleryImage[];
currentIndex: number;
onClose: () => void;
onNavigate: (index: number) => void;
}) {
const current = images[currentIndex];
const handlePrev = () => {
onNavigate(currentIndex === 0 ? images.length - 1 : currentIndex - 1);
};
const handleNext = () => {
onNavigate(currentIndex === images.length - 1 ? 0 : currentIndex + 1);
};
// Keyboard Navigation
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') handlePrev();
if (e.key === 'ArrowRight') handleNext();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [currentIndex]);
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
onClick={onClose}
>
<button
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
className="absolute left-4 p-2 text-white hover:bg-white/10 rounded"
>
</button>
<div
className="relative max-w-4xl max-h-[80vh]"
onClick={(e) => e.stopPropagation()}
>
<Image
src={current.src}
alt={current.alt}
width={current.width}
height={current.height}
className="max-h-[80vh] w-auto"
priority
/>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleNext(); }}
className="absolute right-4 p-2 text-white hover:bg-white/10 rounded"
>
</button>
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-white hover:bg-white/10 rounded"
>
</button>
<div className="absolute bottom-4 text-white">
{currentIndex + 1} / {images.length}
</div>
</div>
);
}
```
---
## Format Comparison
| Format | Compression | Quality | Browser Support | Best For |
|--------|-------------|---------|-----------------|----------|
| **AVIF** | Beste | Excellent | Modern browsers | Photos, Complex |
| **WebP** | Sehr gut | Very Good | All modern | General use |
| **JPEG** | Gut | Good | Universal | Photos fallback |
| **PNG** | Keine | Lossless | Universal | Transparenz |
| **SVG** | N/A | Vector | Universal | Icons, Logos |
---
## Fazit
Image Optimization umfasst:
1. **Moderne Formate**: AVIF/WebP mit Fallbacks
2. **Responsive Images**: srcset und sizes
3. **Lazy Loading**: Native oder Intersection Observer
4. **Blur Placeholders**: Bessere UX während Loading
Optimierte Bilder sind der Schlüssel zu schnellen Websites.
---
## Bildprompts
1. "Image optimization pipeline diagram, format conversion flow"
2. "Before and after image compression comparison, file size reduction"
3. "Responsive images loading on different devices, mobile to desktop"
---
## Quellen
- [Next.js Image Optimization](https://nextjs.org/docs/app/building-your-application/optimizing/images)
- [Web.dev Image Optimization](https://web.dev/fast/use-imagemin-to-compress-images)
- [Sharp Documentation](https://sharp.pixelplumbing.com/)
- [MDN Responsive Images](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images)