Node.js + sharp + chokidarで画像の軽量化とWebP変換を自動化する
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でも単純な静的サイトでもそのまま使えます!
ぜひお試しください✨