Node.js + sharp + chokidarで画像の軽量化とWebP変換を自動化する
2026/03/03

Node.js + sharp + chokidarで画像の軽量化とWebP変換を自動化する

5 mins to read

Next.jsをSSG構成でCloudflare Pagesにデプロイする場合など、next/imageによる画像最適化が利用できない環境では、画像の軽量化やWebP変換をビルド前に自前で行う必要があります。

この記事では、Node.js + sharp + chokidar を使って、

  • 画像のWebP変換
  • ディレクトリ構造の維持
  • 開発時の差分監視
  • 削除ファイルの同期
  • ビルド前のクリーン処理

まで対応した、フレームワーク非依存の画像軽量化 + WebP変換スクリプトをご紹介します。Next.js以外のSSGや、単純な静的サイトでも利用できます。

使用環境・必要なpackage

Node.js 22系を想定しています。

使用パッケージ

  • sharp
    高速な画像処理ライブラリ。WebP変換やリサイズを担当します。

  • chokidar
    ファイル変更を監視するためのライブラリ。開発中の差分変換に使用します。

  • concurrently
    devサーバーと画像監視を同時に起動するために使用します。

pnpm add -D sharp chokidar concurrently

ディレクトリ構成

imagesに配置した画像が、public/imagesに同じ階層構造のまま出力されます。

project-root/
├─ images/ # 元画像を配置
├─ public/
│ └─ images/ # 変換後画像の出力先
├─ convert-images.mjs
├─ watch-images.mjs
└─ package.json

convert-images.mjsをpackage.jsonと同じ階層に設置

このスクリプトでは、以下のように画像を最適化しています。

横幅は最大2000pxまでに制限
オリジナルがそれ以上に大きい場合のみ縮小します(withoutEnlargement: true)。
不要に巨大な画像がそのまま配信されるのを防ぐためです。

webpの品質は80に設定
画質とファイルサイズのバランスが比較的よい値として、80を採用しています。
必要に応じて調整してください。

convert-images.mjs
import sharp from "sharp";
import fs from "fs";
import path from "path";

const inputDir = path.join(process.cwd(), "images");
const outputDir = path.join(process.cwd(), "public", "images");

const shouldClean = process.argv.includes("--clean");

// クリーン(build時のみ)
export function cleanOutput() {
  try {
    if (fs.existsSync(outputDir)) {
      fs.rmSync(outputDir, { recursive: true, force: true });
    }
  } catch (err) {
    console.error("❌ クリーン処理でエラー:", err);
  }
}

// 単一ファイル処理
export async function processFile(inputPath) {
  if (!fs.existsSync(inputPath)) return;

  const relativePath = path.relative(inputDir, inputPath);
  const ext = path.extname(inputPath).toLowerCase();
  const fileName = path.parse(relativePath).name;
  const baseName = path.parse(relativePath).base;
  const dirName = path.dirname(relativePath);

  const targetDir = path.join(outputDir, dirName);
  fs.mkdirSync(targetDir, { recursive: true });

  const outputPath = path.join(targetDir, baseName);

  try {
    // 純粋コピー(SVG, ICO, GIF)
    if ([".svg", ".ico", ".gif"].includes(ext)) {
      fs.copyFileSync(inputPath, outputPath);
      return;
    }

    // 最適化コピー(WebP, AVIF)
    if ([".webp", ".avif"].includes(ext)) {
      await sharp(inputPath)
        .resize({ width: 2000, withoutEnlargement: true })
        .toFormat(ext.replace(".", ""), { quality: 80, effort: 4 })
        .toFile(outputPath);
      return;
    }

    // jpg, png → webp変換
    if ([".jpg", ".jpeg", ".png"].includes(ext)) {
      await sharp(inputPath)
        .resize({ width: 2000, withoutEnlargement: true })
        .webp({ quality: 80, effort: 6, smartSubsample: true })
        .toFile(path.join(targetDir, `${fileName}.webp`));
    }
  } catch (err) {
    console.error(`❌ 処理失敗: ${inputPath}`, err);
  }
}

// 全件処理
export async function convertAll() {
  async function walk(dir) {
    const entries = fs.readdirSync(dir);
    const tasks = entries.map(async (file) => {
      const fullPath = path.join(dir, file);
      if (fs.statSync(fullPath).isDirectory()) {
        await walk(fullPath);
      } else {
        await processFile(fullPath);
      }
    });
    await Promise.all(tasks);
  }
  await walk(inputDir);
}

// CLI実行用
if (process.argv[1] && process.argv[1].includes("convert-images.mjs")) {
  if (shouldClean) {
    cleanOutput();
  }
  await convertAll();
}

watch-images.mjsをpackage.jsonと同じ階層に設置

watch-images.mjs
import chokidar from "chokidar";
import path from "path";
import fs from "fs";
import { processFile, convertAll, cleanOutput } from "./convert-images.mjs";

const inputDir = path.join(process.cwd(), "images");
const outputDir = path.join(process.cwd(), "public", "images");

// dev起動時に一回だけクリーン&全変換
cleanOutput();
await convertAll();

console.log("🧹 初期変換完了 → 監視開始");

// 監視開始
const watcher = chokidar.watch("./images", {
  ignoreInitial: true,
});

watcher.on("add", processFile);
watcher.on("change", processFile);

// 削除時は対応するpublic側も削除
watcher.on("unlink", (inputPath) => {
  const relativePath = path.relative(inputDir, inputPath);
  const ext = path.extname(inputPath).toLowerCase();
  const fileName = path.parse(relativePath).name;
  const dirName = path.dirname(relativePath);

  const targetDir = path.join(outputDir, dirName);

  let targetFile;

  if ([".svg", ".gif", ".webp", ".avif", ".ico"].includes(ext)) {
    targetFile = path.join(targetDir, path.basename(inputPath));
  } else {
    targetFile = path.join(targetDir, `${fileName}.webp`);
  }

  if (fs.existsSync(targetFile)) {
    fs.unlinkSync(targetFile);
    console.log(`🗑 削除: ${targetFile}`);
  }
});

package.jsonのscriptsを以下のように修正

"scripts": {
  "prebuild": "node convert-images.mjs --clean",
  "build": "rm -rf out && npx next build",
  "cnvimg": "node convert-images.mjs --clean",
  "dev": "concurrently \"next dev -p 3001\" \"node watch-images.mjs\""
}

動作の流れについて

build時

  • --clean 付きで convert-images.mjs を実行
  • public/imagesを一度削除
  • すべての画像を再生成

dev時

  • 起動時に一度だけクリーン&全変換
  • その後はchokidarで差分監視
    • 追加 → 変換
    • 変更 → 上書き
    • 削除 → public側も削除

まとめ

Node.jsの環境さえあれば動くシンプルなスクリプトです。

sharpやchokidarなどのパッケージは必要になりますが、Next.jsなどのフレームワークには依存していませんので、Next.js以外のSSGでも単純な静的サイトでもそのまま使えます!

ぜひお試しください✨