🎑

ディレクトリ構造を保ったままwebp生成するNode.js/webp対応について

2021/09/11に公開

モチベーション

自分の前回の記事で、Next.jsの画像最適化モジュール「next-optimized-images」を使った環境などについて書いたのですが、
実際に使っていていくつか問題があったため結局実務では使っていません...。

問題としては、

  • ほぼ空のプロジェクトでも、next-optimized-imagesを使っただけで生成ファイルのサイズが200kbほど増えてしまっていた(詳しくは調べていませんが)
  • ビルド毎に画像生成を行うので毎回ビルドが遅い

ということがありました。

画像最適化というのはビルド時のみの処理であるべきで、アプリ自体の挙動には影響を与えないようにするべきだと思いますし、読み込みのjsが重くなってしまっては本末転倒だとおもいます。

そこからの所感として

  • webpをすでに生成しているのにビルド毎に再生成するのは非効率
  • アプリ自体のビルドと画像のビルドを完全に分けたほうがいいのではないか
  • pictureタグで振り分けができてコンポーネントでwebpのパスにマッピングできればいい

と思い、webp生成用のスクリプトを書けばいいやんということになりました。

ビルド時の流れ

  1. /img/配下に保存してある画像を、ディレクトリ構造を保ったまま、imgフォルダと同階層に生成した/webp/フォルダ配下にwebp変換して保存するスクリプトを書く
  2. 初回のみスクリプトを叩き、画像の変更があったタイミングで叩きなおす
  3. Pictureコンポーネントを作って、srcをpropsとして受け取り、/img/hogehoge/hoge.pngを元に/webp/hogehoge/hoge.png.webpのようなwebpのパスをsourceにセットする
  4. 終わり

ディレクトリ構造

root/
  ├ script/
  │    └ sharp.js
  ├ public/
  │    ├ img/
  │    │    └ ...
  │    ├ webp/
  │    │    └ ...
  ...

ディレクトリ構造を保ったままwebp生成するスクリプト

あまり効率が良くない処理かもしれませんが、以下のようなコードを書きました。
ROOT_DIR ~ EXTENSIONS までが環境変数になります。

sharp.js
const glob = require('glob')
const path = require('path')
const sharp = require('sharp')
const fs = require('fs')

const ROOT_DIR = '../public/'
const ORIGINAL_IMG_DIR = 'img'
const WEBP_DIR = 'webp'
const EXTENSIONS = '(png|jpg)'

const imageFiles = glob.sync(
  ROOT_DIR + ORIGINAL_IMG_DIR + '/**/*.+' + EXTENSIONS,
)
const rootOrgPath = path.resolve(ROOT_DIR + ORIGINAL_IMG_DIR)

fs.rmSync(ROOT_DIR + WEBP_DIR, { recursive: true, force: true })

imageFiles.forEach((filePath) => {
  const resolvePath = path.resolve(filePath)
  const relPath = resolvePath.split(rootOrgPath)[1]
  const dirs = relPath.split('\\')

  dirs.reduce((acc, cur) => {
    const curPath = acc + '\\' + cur

    if (curPath.match(`.*${EXTENSIONS}$`)) return acc

    if (!fs.existsSync(curPath)) {
      fs.mkdirSync(curPath)
    }
    return path.resolve(curPath)
  }, path.resolve(ROOT_DIR + WEBP_DIR))

  sharp(resolvePath)
    .webp({
      quality: 75,
    })
    .toFile(`${path.resolve(ROOT_DIR + WEBP_DIR)}${relPath}.webp`, (err) => {
      if (err) console.error(err)
      return
    })
})

npm-scriptは以下のような感じです。

package.json
{
  "scripts": {
    "webp": "cd script && node sharp.js"
  }
}

srcをもとにwebpのパスにマッピングするPictureコンポーネント

最小限のコードです。

export const Picture = ({ src }) => {
  const img = src
  const webp = src.replace('/img/', '/webp/') + '.webp'
  return (
    <picture>
        <source srcSet={webp} type="image/webp" />
        <img src={img} alt="" />
    </picture>
  )
}

改善点

現在は yarn webpなどで sharp.js が走るような形で npm-script として動かしているのですが、画像を更新したのにwebp生成を忘れてしまったりすることを防ぐために、img/配下のファイル変更をwatchしておいて自動でスクリプトが走るようにしたり、ビルド時に/img/のツリーと/webp/のツリーの差分検知をテストしたりしないとけっこう危ないと思います。
今回は自分一人の開発だったのでこのままやっていくつもりですが。

最後に

画像最適化にはwebp生成だけでなく、見ているウインドウサイズによって読み込む画像サイズを変えたり、遅延ロードのために解像度の低い画像をあらかじめ生成しておく方法だったり様々な手法があると思いますが、それらのための画像をビルド時にすべて用意することは現実的に不可能なんだなーと思いました。(そりゃそう)
ので、ビルド時に生成できるのはあくまでwebpくらいにしておくのがとりあえずベターな選択なのかと思います。

Next.jsのnext/imageがサーバ側で画像処理をやる前提での機能だったりすることもそういうことなんだろうなぁという気持ちになったので、imgixなどのcdnを使うことも視野に入れたり、Vercel先輩などに積極的にのっかっていきたいと思いました。

参考

https://qiita.com/Black-Yamasan/items/3f3b73e422dce883f498

GitHubで編集を提案

Discussion