🦁

Web制作のための画像圧縮とWebP生成

2022/04/28に公開

4 月が終わり、GW に突入しますね。
季節の変わり目になりクーラーのタイミングを伺ってる、スピッカートの金山(@spicato_kana)です。

今回は、弊社で使用している Web 制作のための画像圧縮と WebP 生成を紹介します!

毎回 Web サービスなどを用いて圧縮すると効率が悪いので、弊社では自動化しています。

圧縮と WebP 生成には、 sharp というライブラリを使用しています。
sharp-cli があり webpack 上で簡単に使用できるのですが、ここ一年以上更新がされていないので今回は使用しません。
直近(2022/12/14 時点)で、更新されて v4 になっていました!
https://sharp.pixelplumbing.com/

かわりに、npm script で実行します。

結論(コード)

最初に結論を書くのがいいと聞くので、最初にコード全体を書きます。

package.json
// ES Modules 対応にする
// Webpack 使用時だといろいろ面倒なので非推奨
"type": "module",
"scripts" : {
  "sharp:watch": "onchange \"src/assets/images/**/*.{png,jpg,jpeg,svg,gif}\" -- node sharp-watch.mjs {{changed}}",
}
sharp-watch.mjs
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';

let dirName = path.dirname(process.argv[2]);
let fileName = path.basename(process.argv[2]);

let outPutDir = `dist${dirName.replace('src', '')}`;

// 拡張子を取得
function getExtension(file) {
  let ext = path.extname(file || '').split('.');
  return ext[ext.length - 1];
}
const fileFormat = getExtension(fileName);

(() => {
  // もしディレクトリがなければ作成
  if (!fs.existsSync('dist/assets/images')) {
    fs.mkdirSync('dist/assets/images');
  }
  // サブディレクトリがなければ作成
  if (!fs.existsSync(outPutDir)) {
    fs.mkdirSync(outPutDir);
  }

  let sh = sharp(`${dirName}/${fileName}`);
  let webp = sharp(`${dirName}/${fileName}`);

  if (fileFormat === 'jpg' || fileFormat === 'jpeg') {
    sh = sh.jpeg({ quality: 70 });
    webp = webp.webp({ quality: 70 });
  } else if (fileFormat === 'png') {
    sh = sh.png({ quality: 70 });
    webp = webp.webp({ quality: 70 });
  } else if (fileFormat === 'gif') {
    sh = sh.gif({ quality: 70 });
    webp = webp.webp({ quality: 70 });
  } else if (fileFormat === 'svg') {
    // svgは複製のみ
    fs.copyFile(process.argv[2], `${outPutDir}/${fileName}`, (err) => {
      if (err) {
        fs.unlinkSync(`${outPutDir}/${fileName}`);
        console.log(
          `\u001b[1;33m ${fileName}${outPutDir}から削除しました。`
        );
        return;
      }
      console.log(
        `\u001b[1;32m ${fileName}${outPutDir}に複製しました。`
      );
    });
    return;
  } else {
    console.log('\u001b[1;31m 対応していないファイル形式です。');
    return;
  }

  sh.toFile(`${outPutDir}/${fileName}`, (err, info) => {
    if (err) {
      // 該当ファイルがない場合はdistから削除
      if (fs.existsSync(`${outPutDir}/${fileName}`)) {
        fs.unlinkSync(`${outPutDir}/${fileName}`);
        fs.unlinkSync(
          `${outPutDir}/webp/${fileName.replace(
            /\.[^/.]+$/,
            '.webp'
          )}`
        );
        console.log(
          `\u001b[1;33m ${fileName}${outPutDir}から削除しました。`
        );
      }
      return;
    }
    console.log(
      `\u001b[1;32m ${fileName}を圧縮しました。 ${info.size / 1000}KB`
    );

    // ファイル名に『no-webp』が含む場合は webp を生成しない。
    if (!fileName.includes('no-webp')) {
      // webp生成、もしディレクトリがなければ作成
      if (!fs.existsSync(`${outPutDir}/webp`)) {
        fs.mkdirSync(`${outPutDir}/webp`);
      }
      webp.toFile(
        `${outPutDir}/webp/${fileName.replace(/\.[^/.]+$/, '.webp')}`,
        (err, info) => {
          console.log(
            `\u001b[1;32m ${fileName}をwebpに変換しました。 ${
              info.size / 1000
            }KB`
          );
        }
      );
    }
  });
})();

解説

それでは、簡単な解説をしていきます!
まずは、必要なパッケージをインストールします。

npm install -D sharp onchange

onchange は、ファイルの変更を監視するためのパッケージです。


package.json
"sharp:watch": "onchange \"src/assets/images/**/*.{png,jpg,jpeg,svg,gif}\" -- node sharp-watch.mjs {{changed}}",

{{ changed }} に変更したファイルのパスが渡されます。

jpeg,jpg,png,gif は圧縮と webp 生成を行い、svg は dist へ複製のみを実施します!


sharp-watch.mjs
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';

まず、Node.js の pathfs、モジュール をインストールします。
詳しくは下記をご覧ください。
今回は、パスの読み込みとファイルの生成で使用しています。

https://nodejs.org/api/path.html
https://nodejs.org/api/fs.html


sharp-watch.mjs
let dirName = path.dirname(process.argv[2]);
let fileName = path.basename(process.argv[2]);

let outPutDir = `dist${dirName.replace("src", "")}`;

// 拡張子を取得
function getExtension(file) {
  let ext = path.extname(file || "").split(".");
  return ext[ext.length - 1];
}
const fileFormat = getExtension(fileName);

ここでは、ディレクトリ名、ファイル名、アウトプット先、拡張子を取得しています。
process.argv[2] は、コマンドライン引数で渡されたファイルのパスです。


package.json
"node sharp-watch.mjs {{changed}}",

上記だと、node が process.argv[0] , sharp-watch.mjs が process.argv[1] , {{changed}} が process.argv[2] となっています。


sharp-watch.mjs
  // もしディレクトリがなければ作成
  if (!fs.existsSync('dist/assets/images')) {
    fs.mkdirSync('dist/assets/images');
  }
  // サブディレクトリがなければ作成
  if (!fs.existsSync(outPutDir)) {
    fs.mkdirSync(outPutDir);
  }

  let sh = sharp(`${dirName}/${fileName}`);
  let webp = sharp(`${dirName}/${fileName}`);

ディレクトリが無ければ作成します。
さらに sharp をインスタンス化します。


sharp-watch.mjs
  if (fileFormat === 'jpg' || fileFormat === 'jpeg') {
    sh = sh.jpeg({ quality: 70 });
    webp = webp.webp({ quality: 70 });
  } else if (fileFormat === 'png') {
    sh = sh.png({ quality: 70 });
    webp = webp.webp({ quality: 70 });
  } else if (fileFormat === 'gif') {
    sh = sh.gif({ quality: 70 });
    webp = webp.webp({ quality: 70 });
  } else if (fileFormat === 'svg') {
    // svgは複製のみ
    fs.copyFile(process.argv[2], `${outPutDir}/${fileName}`, (err) => {
      if (err) {
        fs.unlinkSync(`${outPutDir}/${fileName}`);
        console.log(
          `\u001b[1;33m ${fileName}${outPutDir}から削除しました。`
        );
        return;
      }
      console.log(
        `\u001b[1;32m ${fileName}${outPutDir}に複製しました。`
      );
    });
    return;
  } else {
    console.log('\u001b[1;31m 対応していないファイル形式です。');
    return;
  }

if 文で拡張子毎に処理を分岐します。
quality で画質調整(圧縮率)を個別にすることができます。
svg は複製のみを実施しています。


sharp-watch.mjs
  sh.toFile(`${outPutDir}/${fileName}`, (err, info) => {
    if (err) {
      // 該当ファイルがない場合はdistから削除
      if (fs.existsSync(`${outPutDir}/${fileName}`)) {
        fs.unlinkSync(`${outPutDir}/${fileName}`);
        fs.unlinkSync(
          `${outPutDir}/webp/${fileName.replace(
            /\.[^/.]+$/,
            '.webp'
          )}`
        );
        console.log(
          `\u001b[1;33m ${fileName}${outPutDir}から削除しました。`
        );
      }
      return;
    }
    console.log(
      `\u001b[1;32m ${fileName}を圧縮しました。 ${info.size / 1000}KB`
    );

    // ファイル名に『no-webp』が含む場合は webp を生成しない。
    if (!fileName.includes('no-webp')) {
      // webp生成、もしディレクトリがなければ作成
      if (!fs.existsSync(`${outPutDir}/webp`)) {
        fs.mkdirSync(`${outPutDir}/webp`);
      }

      webp.toFile(
        `${outPutDir}/webp/${fileName.replace(/\.[^/.]+$/, '.webp')}`,
        (err, info) => {
          console.log(
            `\u001b[1;32m ${fileName}をwebpに変換しました。 ${
              info.size / 1000
            }KB`
          );
        }
      );
    }
  });

ここで圧縮と WebP 生成を行います。
さらに、画像を削除した場合は dist からも削除します。
また、ファイル名に no-webp がある場合は Webp の生成をスキップします。


全ての画像に実行したいとき

全部の画像にまとめて圧縮と WebP 生成を行いたい場合は下記を実行します。
内容はだいたい似たようなことをしているので省略します。

GitHub からダウンロードしてきた時にビルドに実行したりします。

package.json
"sharp:all": "node sharp/sharp-all.mjs"
sharp-all.mjs
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';

let dirName = 'src/assets/images';

// 拡張子を確認
function getExtension(file) {
  let ext = path.extname(file || '').split('.');
  return ext[ext.length - 1];
}

const readSubDir = (folderPath, finishFunc) => {
  // フォルダ内の全ての画像の配列
  let result = [];
  let execCounter = 0;

  const readTopDir = (folderPath) => {
    execCounter += 1;
    fs.readdir(folderPath, (err, items) => {
      if (err) {
        console.log(err);
      }

      items = items.map((itemName) => {
        return path.join(folderPath, itemName);
      });

      items.forEach((itemPath) => {
        if (fs.statSync(itemPath).isFile()) {
          result.push(itemPath);
        }
        if (fs.statSync(itemPath).isDirectory()) {
          //フォルダなら再帰呼び出し
          readTopDir(itemPath);
        }
      });

      execCounter -= 1;

      if (execCounter === 0) {
        if (finishFunc) {
          finishFunc(result);
        }
      }
    });
  };

  readTopDir(folderPath);
};

//サブディレクトリの列挙 非同期
readSubDir(dirName, (items) => {
  items.forEach((item) => {
    const pathName = path.dirname(item);
    const fileName = path.basename(item);
    const fileFormat = getExtension(fileName);

    let outPutDir = `dist/${pathName.replace('src', '')}`;

    // もしディレクトリがなければ作成
    if (!fs.existsSync('dist/assets/images')) {
      fs.mkdirSync('dist/assets/images');
    }

    // もしディレクトリがなければ作成
    if (!fs.existsSync(outPutDir)) {
      fs.mkdirSync(outPutDir);
    }

    if (fileFormat === '') {
      console.log(
        `\u001b[1;31m 対応していないファイルです。-> ${fileName}`
      );

      return;
    } else if (fileFormat === 'svg') {
      // svgは複製のみ
      fs.copyFile(item, `${outPutDir}/${fileName}`, (err) => {
        if (err) {
          return;
        }
        console.log(
          `\u001b[1;32m ${fileName}${outPutDir}に複製しました。`
        );
      });
      return;
    }

    let sh = sharp(`${pathName}/${path.basename(item)}`);
    let webp = sharp(`${pathName}/${path.basename(item)}`);

    if (fileFormat === 'jpg' || fileFormat === 'jpeg') {
      sh = sh.jpeg({ quality: 70 });
      webp = webp.webp({ quality: 70 });
    } else if (fileFormat === 'png') {
      sh = sh.png({ quality: 70 });
      webp = webp.webp({ quality: 70 });
    } else if (fileFormat === 'gif') {
      sh = sh.gif({ quality: 70 });
      webp = webp.webp({ quality: 70 });
    } else {
      console.log(
        `\u001b[1;31m 対応していないファイルです。-> ${fileName}`
      );
      return;
    }

    sh.toFile(`${outPutDir}/${fileName}`, (err, info) => {
      if (err) {
        console.error(err);
        return;
      }
      console.log(
        `\u001b[1;32m ${fileName}を圧縮しました。 ${info.size / 1000}KB`
      );

      // ファイル名に『no-webp』が含む場合は webp を生成しない。
      if (!fileName.includes('no-webp')) {
        // webp生成、もしディレクトリがなければ作成
        if (!fs.existsSync(`${outPutDir}/webp`)) {
          fs.mkdirSync(`${outPutDir}/webp`);
        }
        webp.toFile(
          `${outPutDir}/webp/${fileName.replace(
            /\.[^/.]+$/,
            '.webp'
          )}`,
          (err, info) => {
            if (err) {
              console.error(err);
              return;
            }

            console.log(
              `\u001b[1;32m ${fileName}をwebpに変換しました。 ${
                info.size / 1000
              }KB`
            );
          }
        );
      }
    });
  });
});

まとめ

Web サイトにおけるスピードアップについて、最近より関心が高くなっていると思います。
そこで画像の圧縮や新しい拡張子(WebP,AVIF など)を使用して、Web サイトのスピードアップを実現することができます。

Webpack を使用して、imagemin-webpack-plugin 等パッケージで圧縮することも可能ですが、ビルド時間が膨大になることが多く sharp を用いて開発体験を向上させることができました。
詳しく比較ができればいいのですが、面倒なので今回は割愛します!
是非、ご自身の手で試してみてください(笑)!

参考

https://youtu.be/js_2iTLQFvI
https://blog.kozakana.net/2019/04/sharp-output-format/

spicato Inc.

Discussion