Firebase+Vueで作ったポートフォリオサイトの画像をWebP対応して軽くしたので解説とソース

絵描き兼フロントエンジニアのゆきです。今日は[しばらく前に作った](https://qiita.com/yuneco/items/b14a695a060c96309239)ポートフォリオ兼ギャラリーサイトの画像を新しい**画像形式であるWebPに変えてさらに軽くした**お話です。

![AutoDetectWepSupport.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/50611533-b8ca-8fd3-0b96-6db1b0743288.png)
※ Safariは2020/9時点。次版で対応すれば自動的にWebP表示に切り替わる...はず

コードは全て公開しているので、流用・参考にしてください。このサイトはFirebase+Vueですが、他のスタックでも似たようなことはできると思います。

サイト： https://pf.nekobooks.com/
リポジトリ： https://github.com/yuneco/portfolio/

## WebPはJPG/PNGに変わる新しい画像圧縮フォーマット

WebPはざっくり言うと、**JPGとPNGの:hatched_chick:いいとこどり:hatched_chick:ができる:sparkles:夢のフォーマット**:sparkles:です。

* JPG並みかそれ以上に圧縮率が高い（同じ画質で27%軽くなるらしい）
* PNGと同様、半透明の透過ができる
* GIFやAPNGのようなアニメーションもできる

今回は扱わないけど、圧縮画像で透過付きのアニメーションを扱えるのとか、すごく夢がありますね。

## WebP対応のアプローチ

まあなんとなく想像つく話だと思うのですが、夢のフォーマットなのにみんながそこまで使っていないのは、面倒なことがあるからですね:innocent:うん、知ってたよ...

WebPの場合、残念ながらSafariがまだ対応していません（2020/9/5時点）。
したがって現時点ではクライアント環境に合わせてWebPとJPEG/PNGのどちらかを出し分ける対応が必要です。

ただし今秋リリースのiOS14では対応すると言われているので、もうしばらくすれば何も考えずに使えるようになるかもしれません。[^webpOnMac]

[^webpOnMac]: 現状Mac/Win共にOS標準ではWebPをサポートしないので、表示するためにはChrome等のWebP対応ブラウザが必要です。Macの場合は[WebP用のQuickLookプラグイン](https://github.com/emin/WebPQuickLook)を入れておくと幸せになります。このプラグインはMacOS10.15.2以降ではGateKeeperにブロックされて動作しないことがあるので、その場合一度プラグインそのものをコンテキストメニューから「開く」して検疫フラグを落とす必要があります（2020.7時点）

どっちにしろ新しいフォーマットは今後も出続けるので、新しいものについていくのであればブラウザごとの「出し分け」機能は必須です。今回のWebP対応では次の2つの手法を組み合わせてこの「出しわけ対応」を行います：

1. **静的な（ビルドに含める）画像はビルド時に**JPEG/PNG版とWebP版を作成。どちらを使うかは実行時にVueで判定する
2. **動的な（FirebaseのStorageに格納している）画像は実行時に**リクエストに応じてFirebase functionsでWebPに変換する

今回は既存サイトの改修なので2つの手法を組み合わせていますが、新規で作るなら全て2の方法にまとめてしまうのも良いかもしれません。
逆に動的な要素が少なかったり、サーバサイドが使えない環境であれば1に寄せるのが良いかと思います。

## Vueのビルド時にWebP画像を自動生成する

まずは静的なリソースに含まれる画像をVueのビルド時にWebPに変換します。

### WebP変換ライブラリをインストールする

画像フォーマットの変更には[imagemin](https://github.com/imagemin/imagemin)という画像の変換や圧縮をおこなってくれるライブラリを使います。
Vue.js（VueCLI）のビルドはwebpackを使用しているので、今回はimageminをwebpackで簡単に使えるようにしてくれる派生版をインストールします。

```sh
yarn add -D imagemin-webpack-plugin
```

これだけだとWwebPには対応しないので、WebP変換をおこなってくれる[imagemin-webp-webpack-plugin](https://www.npmjs.com/package/imagemin-webp-webpack-plugin)を入れます。ついでにJPEGをいい感じに小さくしてくれる[imagemin-mozjpeg](https://www.npmjs.com/package/imagemin-mozjpeg)も入れておきましょう。

```sh
yarn add -D imagemin-webp-webpack-plugin imagemin-mozjpeg 
```

他にも画像フォーマットごとに様々なライブラリがあるので必要に応じて追加してください。

### Vue(webpack)ビルド時にWeb変換を行う

ビルドの設定を追加するために、（もし未作成なら）`vue.config.js`をプロジェクトのルートに作成します。

vue.config.jsはVue(VueCLI)のビルド設定をカスタマイズするためのファイルで、下のような形でwebpackの設定を追加することができます：

```js:vue.config.js
module.exports = {
  chainWebpack (config) {
    // ここにwebpackの設定を追加
  }
}
```

今回はここで画像のWebP変換と、JPEG/PNGの圧縮を行います。

```js:vue.config.js
module.exports = {
  chainWebpack (config) {
    config
      .plugin('ImageminWebp')
      .use(ImageminWebp, [{ // ImageminWebpプラグインの設定
        test: /\.(jpg|png)$/i,  // 拡張子がjpg, pngにマッチするものを変換
        option: {
          quality: 85
        }
      }])

    config
      .plugin('ImageminPlugin')
      .use(ImageminPlugin, [{
        test: /\.(jpg|png)$/i,
        disable: process.env.NODE_ENV !== 'production', // JPEG/PNGの再圧縮はプロダクションビルド時のみ
        pngquant: { // PNG減色の設定（下げすぎるとGIFみたいになるので注意）
          quality: '95'
        },
        plugins: [
          ImageminMozJpeg({ // JPEG圧縮の設定
            quality: 75,
            progressive: true
          })
        ]
      }])
  }
}
```

webpackの設定については説明しませんが、雰囲気でコピペしてもらえれば動くと思います:information_desk_person:。この状態で`yarn build`を実行すると、ビルド結果（`dist`フォルダ）にPNG/JPEGと並んでWebPファイルが生成されます。

### ブラウザがWebP対応か判断して適切な画像を読み込む

2種類の画像ができたので、あとはアプリ側からどちらか必要な方だけを読み込む処理を追加します。

複数の画像形式を出し分ける方法を検索すると、[Picture要素](https://developer.mozilla.org/ja/docs/Web/HTML/Element/picture)を使う方法が出てくると思います。これでも良いのですが、Picture要素だと
面倒な上に
CSSの背景画像に対応できないのでVueを使って出し分けを行います。

ブラウザがWebPに対応しているかどうかは[supports-webp](https://github.com/fregante/supports-webp)のようなライブラリを利用して簡単にチェックできます。ただ、このライブラリの検出方法は正攻法ではあるのですが、検出結果が非同期にしかとれなくてこれまたちょっとめんどくさいです。ということで今回は[How to Detect Browser Support WebP](https://medium.com/@JackPu/how-to-detect-browser-support-webp-446b8f91504)で紹介されていたCanvasを使った検出を利用します。[^detectWebP]

[^detectWebP]: 正攻法ではWebP画像のBase64文字列から実際に`<img>`要素を作ってロードできるかどうかをテストしています。今回使った邪道な方はCanvasからのWebP出力なので、もし「WebPの読み込みはできるけど出力はできない」ブラウザがあると判定を誤ることになります。不安なら正攻法で行きましょう

```js:isWebpSupported.js
const canUseWebP = () => {
  const elem = document.createElement('canvas')
  if (elem.getContext && elem.getContext('2d')) {
    // CanvasからWebPを出力して、結果がdata:image/webpで始まっているかチェック
    return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0
  }
  // Canvas自体が使えなければ非対応扱いにする
  return false
}

// 最初に一度だけ判定して結果をexport
export default canUseWebP()
```

あとはVueコンポーネントで画像のパスを指定している部分の拡張子を、WebP対応かどうかで変えてあげればOKです。必要に応じて単体のコンポーネントにするなり、いい感じに抽象化してください:wink:

```vue:webP対応かどうかで画像パスの拡張子を変えているコンポーネント.vue
<template>
  <article-item
    class="about"
    title="Welcome to Nekobooks"
    :image="`/img/profile.${format}`"
  />
</template>
<script>
import isWebpSupported from '@/core/isWebpSupported'
export default {
  computed: {
    format () {
      return isWebpSupported ? 'webp' : 'jpg'
    }
  }
}
</script>
```

## Firebase functionsで動的にWebP変換する

これでビルドに含まれる静的な画像はWebPに対応しました。
続けて、Firebase Storageに入っている画像ファイルをリクエストに応じてWebPに変換する処理を作ります。

Storageの画像も事前に一括して変換してしまうのもありなのですが、ストレージ使用量が増えることと、将来的にフォーマットや画質を変更したくなった時の対応が**:anger:超絶めんどくさい:anger:**ので、**Storageには高画質の原本だけ持ち、オンデマンドで必要な形式・画質に変換して返す**方がオススメです。[^recommentOndemand]

[^recommentOndemand]: 実は現状もサムネイル画像の作成は画像の登録時にやっているのですが、後からサムネイルのサイズや画質を変えられないので結構運用が辛いです。ただし、オンデマンドで処理するものが増えるほど障害点も増えることになるので、その辺りはサービスのクリティカル度合いや自分のめんどくささと相談してください

### Firebase Hostingの設定でfunctionを呼び出す

まずは所定のURLを叩いた時にこれから作るfunctionを呼び出すよう、hostingの設定を行います。

`firebase.json`にrewriteの設定を追加します。`source`と`function`は他と被らなければ好きなURL・名前を設定してOKです。

```son:firebase.json

{
  "hosting": {
    "public": "dist",
    "rewrites": [
      {
        "source": "/api/gallery/images/*/*",
        "function": "galleryImageWebp"
      }
    ]
  }
}
```

これでブラウザから`/api/gallery/images/piyopiyo/myaomyao`のようなURLを叩くと`galleryImageWebp`という名前のfunctionが呼ばれるようになりました。

### functionでStorageの画像をWebPに変換する

functions側での画像形式の変換には[sharp](https://www.npmjs.com/package/sharp)を使います。
functionsディレクトリでsharpをインストールします。

```sh
cd functions
yarn add sharp
```

続けて、sharpを使って先ほど設定だけした`galleryImageWebp`を実装します。ちょっと長いので全体は[GitHubのgalleryImageWebp.js](https://github.com/yuneco/portfolio/blob/master/functions/src/galleryImageWebp.js)を参照してください:kissing:

まず重要なのは↓ここ

```js:/functions/src/galleryImageWebp.js 
/**
 * 画像ファイルを開いて、画像の圧縮・変換を行って返す
 * @param {string} imagePath 
 * @param {string} format webpを指定するとwebpに変換。jpg/jpegを指定すると圧縮。未指定・その他の場合は原本をそのまま返します
 * @return {Buffer} webp img buffer
 */
const loadImageBuffer = async (imagePath, format) => {
  const buffer = sharp(imagePath);
  if (format === 'jpeg' || format === 'jpg') {
    buffer.jpeg({
      quality: 80
    })    
  }
  if (format === 'webp') {
    buffer.webp({
      quality: 80
    });
  }
  return await buffer.toBuffer();
}
```

Storageから持ってきた画像ファイルを開いて、ついでにWebPへの画像変換を行います。`buffer.webp()`だけで変換できるので超絶簡単です。:angel_tone3:**神**:angel_tone3:

もう一つの大切なポイントがキャッシュの設定です。
リクエストされるたびに毎回この処理を実行していると時間もかかるし何より課金がやばいです。:moneybag:**クラウド破産**:moneybag:しないためにも必ずキャッシュを設定しましょう。[^cache]
 
[^cache]: 今回のサイトの内容は全てオープン（誰でも見られる）なので問題ありませんが、プライベートなコンテンツを含む場合、下手にキャッシュするとセキュリティ事故になるのでちゃんと調べてから設定してくださいね

```js:/functions/src/galleryImageWebp.js 
const galleryImageWebp = async (req, res) => {
  // ... 略 ...

  // 形式を指定して画像データをロード
  const buffer = await loadImageBuffer(tmpImg, format)
  
  // レスポンスタイプを設定
  const contentType = `image/${format}`;
  res.set('Content-Type', contentType);

  // レスポンスのキャッシュを設定
  const age = 86400 * 30; // 30日
  res.set('Cache-Control', `public, max-age=${age}, s-maxage=${age}`);
```

頻繁に変わるコンテンツ（URLは変わらず内容だけ変わるもの）でなければキャッシュの有効期間は適当に長めの数字を設定してOKです。[^clearCache]

[^clearCache]: Firebaseの場合、キャッシュされたデータはHostingで再デプロイするとクリアされるようです。

最後に実装した`galleryImageWebp`functionの呼び出しを設定します。

```js:/functions/index.js
const galleryImageWebp = require('./src/galleryImageWebp')

exports.galleryImageWebp = firebase.functions
  .runWith({timeoutSeconds: 20, memory: '512MB'})
  .https
  .onRequest((req, res) => {
    galleryImageWebp(req, res)
  })
```

メモリやタイムアウトはよしなに設定してあげてください。今回は念の為割り当てを512MBに上げています。

これでブラウザーから[https://pf.nekobooks.com/api/gallery/images/full/1558153452954_467.webp](https://pf.nekobooks.com/api/gallery/images/full/1558153452954_467.webp)を呼ぶとWebPが、
[https://pf.nekobooks.com/api/gallery/images/full/1558153452954_467.jpeg](https://pf.nekobooks.com/api/gallery/images/full/1558153452954_467.jpeg)を呼ぶとJPEGが返却されるようになります。

どちらを呼び出すかの判定は先ほどと同様`isWebpSupported`を使ってVueで処理します。

## もう一歩：対応画像を自動判定する

VueとFirebaseを使う場合は、基本的にはこれで十分かと思います。将来的に別のフォーマットが出てきてもおそらく簡単に対応できるはず:relaxed:

ただクライアント側の環境によっては今回使ったJS（`isWebpSupported`関数）での判定は使いたくないケースもあるかもしれません。最後におまけとして、WebP対応の有無自体をfunctions側で判定して、**クライアント側では一切画像形式のことは考えなくてよい仕組み**を紹介します。

### functionsでWebPサポートを判定する

リクエストを送ってきたブラウザがWebPをサポートしているかどうかは`Accept`ヘッダーを見ると判定できます。

▼ Chrome85が送るAcceptヘッダーの例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/bec5f7c5-e74c-3323-1e22-fe553c4f6042.png)

WebP以外にもapng（アニメーションPNG）やavif（Chrome85から追加された新しい画像フォーマット）も受け入れできると書かれています。

このように、最近のブラウザーでは「他では対応できないかもしれないけど自分ならイケるよ！」という新し目のフォーマットを明示的にAcceptヘッダーに列挙してくれています。まじえらい

実際のコードは↓こんな感じ。拡張子がない場合のみAcceptヘッダーで自動判定しています。

```js:/functions/src/galleryImageWebp.js 
 const galleryImageWebp = async (req, res) => {
    // ... 略 ...

  let format
  if (!ext) {
    // 拡張子がない場合、WebvPサポートブラウザならWebPを使う
    const acceptHeader = req.get("Accept") || ""
    const isSupportWebp = acceptHeader.includes("image/webp")
    format = isSupportWebp ? "webp" : "jpeg"
  } else {
    // 拡張子が付いていればそれを利用
    format = ext.substr(1)
  }
```

最後に、自動判定した場合のキャッシュを設定します。
自動判定するということは、**同じURLでもレスポンスがJPEGになったりWebPになったりする**ので、単純に「1URL=1キャッシュ」みたいなキャッシュ保存ができません。

この問題を回避するために`Vary`レスポンスヘッダーを追加して、「このリクエストはAcceptヘッダーの内容によって変わる可能性があるよ！」と伝えます。あとはキャッシュを行うCDNがVaryの指定を見てよしなにキャッシュを行ってくれます。

```js:/functions/src/galleryImageWebp.js 
  if (!ext) {
    // 自動判定を行った場合はVaryレスポンスヘッダーを追加する
    res.set('Vary', 'Accept-Encoding, Accept')
  }
```

これでブラウザーから[https://pf.nekobooks.com/api/gallery/images/full/1558153452954_467](https://pf.nekobooks.com/api/gallery/images/full/1558153452954_467)のようにを拡張子なしでリクエストすると、WebP対応ブラウザならWebPが、非対応ブラウザならJPEGが返るようになります。

### 自動判定はすべき？

返却する画像の形式を自動判定することで、クライアントアプリは一切画像のフォーマットを意識しなくてよくなります。

JSを使えない静的なhtml/cssでもWebPが使えますし、AVIFのような**新しい形式の対応を追加してもアプリには一切手を入れなくて**済みます。:angel:**理想郷**:angel:ですね。

その一方で、Acceptヘッダーごとにキャッシュを作るということは、Acceptヘッダーのバリエーションごとにいくつもキャッシュが生成されることを意味します。Acceptヘッダーはブラウザごとに異なる上、同じブラウザでもバージョンが変わると変化することがあるので、「今回のようにWebP対応かどうか？」の2択しかないケースでは（必要なバリエーションは2つだけなのにいくつもキャッシュが作られるので）かなり割の悪いキャッシュになる可能性があります。

特にFirebaseのようにCDN側の状態がブラックボックスな環境では注意が必要です。（このあたりはあまり知らないので、詳しい方がいらっしゃったらコメントください）
作るサイトの特性や環境に合わせて選択するのが良いと思います:cat::star:

## まとめ：
:star2: 新しい画像形式への変換はwebpackでビルドにもできるし、Firebase functionsでオンデマンドにもできるよ
:star2: 画像形式をfunctionsで判定すればクライアントアプリ側は画像形式のことを考えなくてよくなるよ（キャッシュには注意！）
:star2: 新しい画像形式をサポートしてWebサイトを軽くしよう