絵描き兼フロントエンジニアのゆきです。今日はしばらく前に作ったポートフォリオ兼ギャラリーサイトの画像を新しい画像形式であるWebPに変えてさらに軽くしたお話です。
※ Safariは2020/9時点。次版で対応すれば自動的にWebP表示に切り替わる...はず
コードは全て公開しているので、流用・参考にしてください。このサイトはFirebase+Vueですが、他のスタックでも似たようなことはできると思います。
サイト: https://pf.nekobooks.com/
リポジトリ: https://github.com/yuneco/portfolio/
WebPはJPG/PNGに変わる新しい画像圧縮フォーマット
WebPはざっくり言うと、JPGとPNGのいいとこどりができる夢のフォーマットです。
- JPG並みかそれ以上に圧縮率が高い(同じ画質で27%軽くなるらしい)
- PNGと同様、半透明の透過ができる
- GIFやAPNGのようなアニメーションもできる
今回は扱わないけど、圧縮画像で透過付きのアニメーションを扱えるのとか、すごく夢がありますね。
WebP対応のアプローチ
まあなんとなく想像つく話だと思うのですが、夢のフォーマットなのにみんながそこまで使っていないのは、面倒なことがあるからですねうん、知ってたよ...
WebPの場合、残念ながらSafariがまだ対応していません(2020/9/5時点)。
したがって現時点ではクライアント環境に合わせてWebPとJPEG/PNGのどちらかを出し分ける対応が必要です。
ただし今秋リリースのiOS14では対応すると言われているので、もうしばらくすれば何も考えずに使えるようになるかもしれません。1
どっちにしろ新しいフォーマットは今後も出続けるので、新しいものについていくのであればブラウザごとの「出し分け」機能は必須です。今回のWebP対応では次の2つの手法を組み合わせてこの「出しわけ対応」を行います:
- 静的な(ビルドに含める)画像はビルド時にJPEG/PNG版とWebP版を作成。どちらを使うかは実行時にVueで判定する
- 動的な(FirebaseのStorageに格納している)画像は実行時にリクエストに応じてFirebase functionsでWebPに変換する
今回は既存サイトの改修なので2つの手法を組み合わせていますが、新規で作るなら全て2の方法にまとめてしまうのも良いかもしれません。
逆に動的な要素が少なかったり、サーバサイドが使えない環境であれば1に寄せるのが良いかと思います。
Vueのビルド時にWebP画像を自動生成する
まずは静的なリソースに含まれる画像をVueのビルド時にWebPに変換します。
WebP変換ライブラリをインストールする
画像フォーマットの変更にはimageminという画像の変換や圧縮をおこなってくれるライブラリを使います。
Vue.js(VueCLI)のビルドはwebpackを使用しているので、今回はimageminをwebpackで簡単に使えるようにしてくれる派生版をインストールします。
yarn add -D imagemin-webpack-plugin
これだけだとWwebPには対応しないので、WebP変換をおこなってくれるimagemin-webp-webpack-pluginを入れます。ついでにJPEGをいい感じに小さくしてくれるimagemin-mozjpegも入れておきましょう。
yarn add -D imagemin-webp-webpack-plugin imagemin-mozjpeg
他にも画像フォーマットごとに様々なライブラリがあるので必要に応じて追加してください。
Vue(webpack)ビルド時にWeb変換を行う
ビルドの設定を追加するために、(もし未作成なら)vue.config.js
をプロジェクトのルートに作成します。
vue.config.jsはVue(VueCLI)のビルド設定をカスタマイズするためのファイルで、下のような形でwebpackの設定を追加することができます:
module.exports = {
chainWebpack (config) {
// ここにwebpackの設定を追加
}
}
今回はここで画像のWebP変換と、JPEG/PNGの圧縮を行います。
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の設定については説明しませんが、雰囲気でコピペしてもらえれば動くと思います。この状態でyarn build
を実行すると、ビルド結果(dist
フォルダ)にPNG/JPEGと並んでWebPファイルが生成されます。
ブラウザがWebP対応か判断して適切な画像を読み込む
2種類の画像ができたので、あとはアプリ側からどちらか必要な方だけを読み込む処理を追加します。
複数の画像形式を出し分ける方法を検索すると、Picture要素を使う方法が出てくると思います。これでも良いのですが、Picture要素だと面倒な上にCSSの背景画像に対応できないのでVueを使って出し分けを行います。
ブラウザがWebPに対応しているかどうかはsupports-webpのようなライブラリを利用して簡単にチェックできます。ただ、このライブラリの検出方法は正攻法ではあるのですが、検出結果が非同期にしかとれなくてこれまたちょっとめんどくさいです。ということで今回はHow to Detect Browser Support WebPで紹介されていたCanvasを使った検出を利用します。2
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です。必要に応じて単体のコンポーネントにするなり、いい感じに抽象化してください
<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の画像も事前に一括して変換してしまうのもありなのですが、ストレージ使用量が増えることと、将来的にフォーマットや画質を変更したくなった時の対応が**超絶めんどくさい**ので、Storageには高画質の原本だけ持ち、オンデマンドで必要な形式・画質に変換して返す方がオススメです。3
Firebase Hostingの設定でfunctionを呼び出す
まずは所定のURLを叩いた時にこれから作るfunctionを呼び出すよう、hostingの設定を行います。
firebase.json
にrewriteの設定を追加します。source
とfunction
は他と被らなければ好きなURL・名前を設定してOKです。
{
"hosting": {
"public": "dist",
"rewrites": [
{
"source": "/api/gallery/images/*/*",
"function": "galleryImageWebp"
}
]
}
}
これでブラウザから/api/gallery/images/piyopiyo/myaomyao
のようなURLを叩くとgalleryImageWebp
という名前のfunctionが呼ばれるようになりました。
functionでStorageの画像をWebPに変換する
functions側での画像形式の変換にはsharpを使います。
functionsディレクトリでsharpをインストールします。
cd functions
yarn add sharp
続けて、sharpを使って先ほど設定だけしたgalleryImageWebp
を実装します。ちょっと長いので全体はGitHubの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()
だけで変換できるので超絶簡単です。神
もう一つの大切なポイントがキャッシュの設定です。
リクエストされるたびに毎回この処理を実行していると時間もかかるし何より課金がやばいです。クラウド破産しないためにも必ずキャッシュを設定しましょう。4
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です。5
最後に実装したgalleryImageWebp
functionの呼び出しを設定します。
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を呼ぶとWebPが、
https://pf.nekobooks.com/api/gallery/images/full/1558153452954_467.jpegを呼ぶとJPEGが返却されるようになります。
どちらを呼び出すかの判定は先ほどと同様isWebpSupported
を使ってVueで処理します。
もう一歩:対応画像を自動判定する
VueとFirebaseを使う場合は、基本的にはこれで十分かと思います。将来的に別のフォーマットが出てきてもおそらく簡単に対応できるはず
ただクライアント側の環境によっては今回使ったJS(isWebpSupported
関数)での判定は使いたくないケースもあるかもしれません。最後におまけとして、WebP対応の有無自体をfunctions側で判定して、クライアント側では一切画像形式のことは考えなくてよい仕組みを紹介します。
functionsでWebPサポートを判定する
リクエストを送ってきたブラウザがWebPをサポートしているかどうかはAccept
ヘッダーを見ると判定できます。
WebP以外にもapng(アニメーションPNG)やavif(Chrome85から追加された新しい画像フォーマット)も受け入れできると書かれています。
このように、最近のブラウザーでは「他では対応できないかもしれないけど自分ならイケるよ!」という新し目のフォーマットを明示的にAcceptヘッダーに列挙してくれています。まじえらい
実際のコードは↓こんな感じ。拡張子がない場合のみAcceptヘッダーで自動判定しています。
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の指定を見てよしなにキャッシュを行ってくれます。
if (!ext) {
// 自動判定を行った場合はVaryレスポンスヘッダーを追加する
res.set('Vary', 'Accept-Encoding, Accept')
}
これでブラウザーからhttps://pf.nekobooks.com/api/gallery/images/full/1558153452954_467のようにを拡張子なしでリクエストすると、WebP対応ブラウザならWebPが、非対応ブラウザならJPEGが返るようになります。
自動判定はすべき?
返却する画像の形式を自動判定することで、クライアントアプリは一切画像のフォーマットを意識しなくてよくなります。
JSを使えない静的なhtml/cssでもWebPが使えますし、AVIFのような新しい形式の対応を追加してもアプリには一切手を入れなくて済みます。理想郷ですね。
その一方で、Acceptヘッダーごとにキャッシュを作るということは、Acceptヘッダーのバリエーションごとにいくつもキャッシュが生成されることを意味します。Acceptヘッダーはブラウザごとに異なる上、同じブラウザでもバージョンが変わると変化することがあるので、「今回のようにWebP対応かどうか?」の2択しかないケースでは(必要なバリエーションは2つだけなのにいくつもキャッシュが作られるので)かなり割の悪いキャッシュになる可能性があります。
特にFirebaseのようにCDN側の状態がブラックボックスな環境では注意が必要です。(このあたりはあまり知らないので、詳しい方がいらっしゃったらコメントください)
作るサイトの特性や環境に合わせて選択するのが良いと思います
まとめ:
新しい画像形式への変換はwebpackでビルドにもできるし、Firebase functionsでオンデマンドにもできるよ
画像形式をfunctionsで判定すればクライアントアプリ側は画像形式のことを考えなくてよくなるよ(キャッシュには注意!)
新しい画像形式をサポートしてWebサイトを軽くしよう
-
現状Mac/Win共にOS標準ではWebPをサポートしないので、表示するためにはChrome等のWebP対応ブラウザが必要です。Macの場合はWebP用のQuickLookプラグインを入れておくと幸せになります。このプラグインはMacOS10.15.2以降ではGateKeeperにブロックされて動作しないことがあるので、その場合一度プラグインそのものをコンテキストメニューから「開く」して検疫フラグを落とす必要があります(2020.7時点) ↩
-
正攻法ではWebP画像のBase64文字列から実際に
<img>
要素を作ってロードできるかどうかをテストしています。今回使った邪道な方はCanvasからのWebP出力なので、もし「WebPの読み込みはできるけど出力はできない」ブラウザがあると判定を誤ることになります。不安なら正攻法で行きましょう ↩ -
実は現状もサムネイル画像の作成は画像の登録時にやっているのですが、後からサムネイルのサイズや画質を変えられないので結構運用が辛いです。ただし、オンデマンドで処理するものが増えるほど障害点も増えることになるので、その辺りはサービスのクリティカル度合いや自分のめんどくささと相談してください ↩
-
今回のサイトの内容は全てオープン(誰でも見られる)なので問題ありませんが、プライベートなコンテンツを含む場合、下手にキャッシュするとセキュリティ事故になるのでちゃんと調べてから設定してくださいね ↩
-
Firebaseの場合、キャッシュされたデータはHostingで再デプロイするとクリアされるようです。 ↩