DEV Community

Constantine Lobkov
Constantine Lobkov

Posted on

Automated Image Compression: A Vite Plugin Using Sharp

Optimizing Images with Vite and the Sharp Library

I'd like to discuss a method that leverages the sharp library to create a simple Vite plugin. The purpose of this plugin is to:

  1. Compress original images.
  2. Generate featherweight versions in .webp and .avif formats.
  3. Provide a minimum of manual work during development.

TL;DR:

  • Github Repository: Link

Compression Details: Original image (~3.1 MB) -> After compression (~746 KB), webp (~92 kB), .avif (~66 kB).

Why Opt for This?

  • Modern image formats like .webp and .avif offer a significant reduction in size while maintaining transparency.
  • Oftentimes, provided images in projects aren't compressed, leading to unnecessary bloat.
  • Manual image compression, using tools like tinypng, is tedious. Automating this process is the way forward.

Using the sharp library will be pivotal to this endeavor.

  • Sharp Documentation: Link

The Concept:
When we import our image as import Image from './images/hero.png?optimized';, the plugin should not merely return an image link. Instead, it should return an object containing multiple links. This object can then be fed into a <picture> HTML tag to be rendered accordingly.

Here's a Svelte component (Picture.svelte) to demonstrate this:

<script lang="ts">
 type OptimizedSrc = {
  avif?: string;
  webp?: string;
  fallback: string;
 };

 export let src: OptimizedSrc | string;

 function getSource(src: OptimizedSrc | string): OptimizedSrc {
  if (typeof src === 'string') return { fallback: src };
  return src;
 }

 $: sources = getSource(src);
</script>

<picture>
 {#if sources.avif}
  <source srcset={sources.avif} type="image/avif" />
 {/if}
 {#if sources.webp}
  <source srcset={sources.webp} type="image/webp" />
 {/if}
 <img src={sources.fallback} alt="" />
</picture>
Enter fullscreen mode Exit fullscreen mode

Usage:

<script lang="ts">
 import Image from '../images/source.png?optimized';
 import Picture from './Picture.svelte';
</script>

<div>
 <Picture src={Image} />
</div>
Enter fullscreen mode Exit fullscreen mode

Crafting the Plugin:
I recommend referring to the thorough documentation provided by Vite and Rollup:

Upon setting up your application, ensure you register the plugin in the Vite config. It should be prioritized first.

// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { imageOptimizerPlugin } from './imageOptimizerPlugin';

export default defineConfig({
 plugins: [imageOptimizerPlugin(), sveltekit()]
});
Enter fullscreen mode Exit fullscreen mode

Actually, the entire code of the plugin itself.

// imageOptimizerPlugin.ts

import path, { basename, extname } from 'node:path';

import { Plugin } from 'vite';
import sharp from 'sharp';

const pattern = '?optimized';

const isProd = process.env.NODE_ENV === 'production';

function isIdForOptimization(id: string | undefined) {
 return id?.includes(pattern);
}

const forSharpPattern = /\?(webp|avif|fallback)/;

function isIdForSharp(id: string | undefined) {
 return forSharpPattern.test(id || '');
}

function resolveId(id: string, importer: string) {
 return path.resolve(path.dirname(importer), id);
}

export const imageOptimizerPlugin = (): Plugin[] => {

 return [
  {
   name: '?sharp-handler',
   enforce: 'pre',
   async resolveId(id, importer) {
    if (!isIdForSharp(id)) return;

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return resolveId(id, importer!);
   },
   async load(id) {
    if (!isIdForSharp(id)) return;

    const unwrappedId = id.replace(forSharpPattern, '');
    let [, extension] = id.split('?');

    let buffer: Uint8Array;

    if (extension === 'fallback') {
     buffer = await sharp(unwrappedId)
      .png({ quality: 70, effort: 7, compressionLevel: 6 })
      .toBuffer();
    } else if (extension === 'webp') {
     buffer = await sharp(unwrappedId).webp({ quality: 80 }).toBuffer();
    } else {
     buffer = await sharp(unwrappedId).avif({ quality: 60 }).toBuffer();
    }

    if (extension === 'fallback') extension = extname(unwrappedId).replace('.', '');

    const name = basename(unwrappedId, extname(unwrappedId)) + `.${extension}`;

    const referenceId = this.emitFile({
     type: 'asset',
     name: name,
     needsCodeReference: true,
     source: buffer
    });

    return `export default import.meta.ROLLUP_FILE_URL_${referenceId};`;
   }
  },
  {
   name: '?optimized-handler',
   enforce: 'pre',
   async resolveId(id, importer) {
    if (!isIdForOptimization(id)) return;

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return resolveId(id, importer!);
   },
   async load(id) {
    if (!isIdForOptimization(id)) return;

    const unwrappedId = id.replace(pattern, '');

    if (!isProd) {
     return {
      code: `import fallback from "${unwrappedId}";` + `export default { fallback };`,
      map: null
     };
    }

    const webp = JSON.stringify(unwrappedId + '?webp');
    const avif = JSON.stringify(unwrappedId + '?avif');
    const fallback = JSON.stringify(unwrappedId + '?fallback');

    return (
     `import webp from ${webp};` +
     `import avif from ${avif};` +
     `import fallback from ${fallback};` +
     `export default {webp, avif, fallback};`
    );
   }
  }
 ];
};
Enter fullscreen mode Exit fullscreen mode

Let's discuss how it works
Your main Vite plugin is actually composed of two sub-plugins:

  • The Sharp Handler (?sharp-handler):

    • Responsibilities: Processes actual image formats.
    • Resolution Logic: When resolving image IDs, it filters out images that don't need processing through Sharp.
    • Loading Logic: Based on the specific image format to be generated, Sharp parameters are adjusted. Each image format (.webp, .avif, or the compressed original) is generated with its respective settings. The processed image buffer is then emitted as an asset with a reference ID.
  • The Optimized Handler (?optimized-handler):

    • Responsibilities: Handles custom image imports.
    • Resolution Logic: Targets only the images flagged for optimization.
    • Loading Logic: In development mode, it returns the original image for faster performance. In production mode, it processes the image to generate the optimized versions and constructs the export statement with all available formats.

Dev vs. Prod Mode:
The plugin behaves differently based on the environment:

  • In development mode, image optimization is bypassed to prioritize speed, returning the original image.

  • In production mode, images are fully processed, generating both .webp and .avif formats along with a compressed original.

Lastly, to prevent linting errors due to our custom import, notify TypeScript about the expected return format from our tailored module:

// optimized-module.d.ts
declare module '*?optimized' {
 const value: {
  webp?: string;
  avif?: string;
  fallback: string;
 };
 export default value;
}
Enter fullscreen mode Exit fullscreen mode

In conclusion, with this plugin, your images will be automatically optimized and converted. For example, in my test repo, a 3.1 MB image was efficiently reduced to 746 KB, with .webp and .avif versions weighing only 92 kB and 66 kB, respectively.

What it will look like in the end:
Image description

Thanks for reading! I trust you'll find this method handy. ๐Ÿ˜„

Top comments (2)

Collapse
 
imvitalya profile image
Vitalya

Great article! The author described the work of the plugin very clearly and accurately. Maybe I'll try it on my pet project!

Collapse
 
abc_wendsss profile image
Wendy Wong

Hi Constantine, great article with code examples for the plugin and welcome to the DEV Community!