7.7 · difficulty 3/4 · 18 min read
Project 4: Image Optimizer Plugin
Use generateBundle + this.emitFile to implement automatic image compression at build time — integrate sharp, convert PNG/JPEG to WebP/AVIF, and output a build report.
Vite 8.1Stable
Goal: automatic image optimization
bash
pnpm build
# After build completes:
# ✓ logo.png → logo.webp (↓ 65%)
# ✓ hero.jpg → hero.webp (↓ 72%)
# ✓ hero.jpg → hero.avif (↓ 81%)
# ✓ Processed 12 images, saved 3.2 MBStep 1: Scan the bundle for images
ts
import type { Plugin, OutputBundle, OutputAsset } from "vite"
interface ImageOptimizerOptions {
/** Target formats to convert to */
formats?: ("webp" | "avif")[]
/** WebP quality 0-100 */
webpQuality?: number
/** AVIF quality 0-100 */
avifQuality?: number
}
export function imageOptimizer(options: ImageOptimizerOptions = {}): Plugin {
const { formats = ["webp", "avif"], webpQuality = 80, avifQuality = 65 } = options
return {
name: "vite-plugin-image-optimizer",
apply: "build",
async generateBundle(_outputOptions, bundle: OutputBundle) {
// Find all image assets in the bundle
const imageAssets = Object.entries(bundle).filter(([fileName, chunk]): chunk is [string, OutputAsset] => {
return chunk.type === "asset" && /\.(png|jpe?g|gif|webp)$/i.test(fileName) && chunk.source instanceof Uint8Array
})
if (imageAssets.length === 0) return
console.log(`\n🖼 Image optimization: found ${imageAssets.length} image(s)`)
// TODO: Step 2 — convert using sharp
},
}
}Step 2: Convert with sharp
ts
// Add conversion logic inside generateBundle:
async generateBundle(_outputOptions, bundle: OutputBundle) {
// Dynamically import sharp (avoids overhead in dev mode)
let sharp: typeof import("sharp")
try {
sharp = (await import("sharp")).default
} catch {
console.warn("[image-optimizer] sharp is not installed, skipping image optimization")
return
}
const imageAssets = Object.entries(bundle).filter(
([, chunk]) =>
chunk.type === "asset" &&
/\.(png|jpe?g|gif)$/i.test(chunk.fileName) &&
chunk.source instanceof Uint8Array
)
if (imageAssets.length === 0) return
const stats: { file: string; original: number; optimized: number; format: string }[] = []
// Process all images in parallel
await Promise.all(
imageAssets.map(async ([fileName, chunk]) => {
if (chunk.type !== "asset" || !(chunk.source instanceof Uint8Array)) return
const originalSize = chunk.source.byteLength
const baseName = fileName.replace(/\.[^.]+$/, "")
for (const format of formats) {
try {
let optimizedBuffer: Buffer
if (format === "webp") {
optimizedBuffer = await sharp(chunk.source)
.webp({ quality: webpQuality })
.toBuffer()
} else {
optimizedBuffer = await sharp(chunk.source)
.avif({ quality: avifQuality })
.toBuffer()
}
const outputFileName = `${baseName}.${format}`
// Write the new file to the output directory
this.emitFile({
type: "asset",
fileName: outputFileName,
source: new Uint8Array(optimizedBuffer),
})
stats.push({
file: fileName,
original: originalSize,
optimized: optimizedBuffer.byteLength,
format,
})
} catch (e) {
console.warn(`[image-optimizer] Failed to process ${fileName}:`, e)
}
}
})
)
// Output optimization report
if (stats.length > 0) {
console.log("\n Image optimization report:")
let totalSaved = 0
for (const { file, original, optimized, format } of stats) {
const saved = original - optimized
const ratio = ((saved / original) * 100).toFixed(1)
totalSaved += saved
console.log(` ${file} → .${format}: ${formatBytes(original)} → ${formatBytes(optimized)} (↓${ratio}%)`)
}
console.log(` Total saved: ${formatBytes(totalSaved)}\n`)
}
},Step 3: Add hash-based caching
Re-processing all images on every build is slow. Use a hash for caching:
ts
import { createHash } from "node:crypto"
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
import { resolve } from "node:path"
function getCacheKey(buffer: Uint8Array, format: string, quality: number): string {
const hash = createHash("md5")
hash.update(buffer)
hash.update(format)
hash.update(String(quality))
return hash.digest("hex")
}
const CACHE_DIR = resolve("node_modules/.vite/image-cache")
function getCached(key: string): Buffer | null {
const cachePath = resolve(CACHE_DIR, key)
if (existsSync(cachePath)) {
return readFileSync(cachePath)
}
return null
}
function setCache(key: string, data: Buffer): void {
mkdirSync(CACHE_DIR, { recursive: true })
writeFileSync(resolve(CACHE_DIR, key), data)
}Before processing each image, check the cache first:
ts
const cacheKey = getCacheKey(chunk.source, format, quality)
let optimizedBuffer = getCached(cacheKey)
if (!optimizedBuffer) {
// Cache miss: run the conversion
const result = await sharp(chunk.source).webp({ quality }).toBuffer()
optimizedBuffer = result
setCache(cacheKey, optimizedBuffer)
}Complete configuration example
ts
import { defineConfig } from "vite"
import { imageOptimizer } from "./src/plugin"
export default defineConfig({
plugins: [
imageOptimizer({
formats: ["webp", "avif"],
webpQuality: 80,
avifQuality: 65,
}),
],
})Running the example project
bash
cd examples/plugin-image-optimizer
pnpm install # installs sharp (requires node-gyp prebuilds; first run may be slow)
pnpm build # triggers image optimizationCheck the dist/assets/ directory:
- Original images are preserved (for backwards compatibility)
.webpand.avifversions appear alongside them
Using <picture> in HTML to take advantage of optimized images
html
<picture>
<!-- Browser tries in order: AVIF first, then WebP, then JPEG as fallback -->
<source srcset="/assets/hero.avif" type="image/avif" />
<source srcset="/assets/hero.webp" type="image/webp" />
<img src="/assets/hero.jpg" alt="Hero" />
</picture>Self-check
- Why should the sharp import be placed inside
generateBundleas a dynamic import, rather than a static import at the top of the file? - Can
this.emitFilebe called inside thetransformhook? Why or why not? - This plugin sets
apply: "build"— what does that mean? What would happen if you removed it? - What are the benefits of processing all images in parallel (
Promise.all)? What are the potential risks?
ts
// Extend the image optimizer plugin:
// 1. Support SVG optimization (use the svgo library to compress SVG)
// 2. Add a build report file image-report.json
// containing: original size, optimized size, and savings ratio for each image
import type { Plugin } from "vite"
// Assume you already have an optimizeImage function
async function optimizeImage(source: Uint8Array, format: string): Promise<Buffer> {
return Buffer.from(source) // placeholder
}
export function enhancedImageOptimizer(): Plugin {
return {
name: "vite-plugin-enhanced-image",
apply: "build",
async generateBundle(_, bundle) {
// TODO:
// 1. Process .png / .jpg files to webp (same as the example above)
// 2. Process .svg files with svgo compression
// 3. Use this.emitFile to generate image-report.json
},
}
}Utility function
ts
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"
const units = ["B", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`
}