next-optimized-images で next/image っぽいものをつくる
next/image での画像最適化が最近のホットなトピックではありますが、見た感じ、next/imageは画像の最適化をサーバサイドで処理する前提のコンポーネントのようです。
Next.jsでサーバを立てられる場合は使っていきたいのですが、個人的に案件でそういう構成になっていることはあまりないです。
そこで、next export
で使える画像最適化についてまとめてnext/imageっぽいコンポーネントを作ってみようと考えました。
一応、公式の next export
での next/image の使用法についてのページはこちら です。
前提
今回はnext/imageのソースを見て、ほぼマルパクリな感じでやっていきます。
IE対応はしません。
Next.jsのバージョンは 11.0.1です。
やること
- webp対応 / 非対応ブラウザの処理
- 画像のsrcSetの生成
- 遅延読み込み
- background-image の対応
画像最適化は next-optimized-images 、遅延読み込みは 普通に(next/imageにならって)Intersection Observerでやっていきます。
リポジトリ、デモURL
使うモジュール
next-optimized-images にまとまっていますが、各拡張子ごとに使うモジュールを別途インストール必要があります。
今回は、全部で以下をインストールしました。
next-optimized-images
imagemin-mozjpeg
imagemin-pngquant
webp-loader
responsive-loader
sharp
raw-loader
インストールは以下コマンドをお使いください
yarn add -D next-optimized-images imagemin-mozjpeg imagemin-pngquant webp-loader responsive-loader sharp raw-loader
next.config.js
next-optimized-imagesのconfigをここで定義しています。
responsive-loaderで生成する画像サイズの種類を一括で適用できます。
const optimizedImagesConfig = {
optimizeImagesInDev: true,
removeOriginalExtension: true,
responsive: {
adapter: require('responsive-loader/sharp'),
sizes: [640, 960, 1200, 1800],
},
}
pngの最適化モジュールについて
next-optimized-imagesのgithubでは imagemin-optipng が推奨のような感じになっていますが(imagepngquantが alternative になっているので)、どっちのほうがいいのかなーと思って軽く検証してみました。
結果的に、
- 2.51MBの画像(無加工)
- imagemin-optipng:1.5MB
- imagemin-pngquant:721KB
- 546KBの画像(1をTinypngのサイトで軽量化したもの)
- imagemin-optipng:559KB
- imagemin-pngquant:612KB
という感じの結果になりました。
画像サイズが大きいとき、optipngがpngquantよりも2倍くらいサイズが大きくなってしまいました。
この辺の画像の最適化について調べたことがなくあまり評価するポイントがわからなかったため、optipngとpngquantでどっちがいいか調べてみたところ
のサイトで pngquantのほうがいいよー的なことが書いてあったので、今回は imagemin-pngquant
のほうを採用することにしました。
(品質が落ちすぎてるなど今後気づいたりしたら変えるかもしれません)
next-optimized-imagesのresponsive-loader の不具合
responsive-loader は画像をレスポンシブでサイズを変えて表示できるように複数の画像を生成するモジュールですが、pngの最適化と併用できないという不具合があるっぽいです。
webpの場合は十分軽量化されているようなので大丈夫そうでした。
※以下、画面幅で表示する画像サイズを複数用意する方法を「サイズの最適化」、単純に画像の容量を落とすことを「画像の最適化」と言うことにします。(表現がわからず)
併用するとサイズの最適化はしてくれても画像の最適化はしてくれないようになって、画面幅が大きいともちろん元の画像サイズのままになってしまいます。
だったら、サイズの最適化はやめて画像の最適化だけ行ったほうが容量は小さくなるので、
webp対応の場合:サイズの最適化 & webp化
webp非対応の場合: 画像の最適化のみ
という方法でやりたいと思います。
(まじで Safari 全バージョンでwebp対応してくれ)
webpack5について
Next.js 11 からwebpack5がデフォルトになったのですが、next-optimized-imagesはまだwebpack5に対応していません。
今回試しに上げてみたらなんとか動いたので、一旦このまま様子を見たいと思います。
font読み込み時の不具合について(21/07/23追記)
こちら に issueで上がっていますが、デフォルトだとフォントファイルも next-optimized-images のwebpack が通ってしまうようになってしまいます。
コメントでもあがっていますが、next.js自体のconfigに以下の記述を加えて、フォントファイルは raw-loader を使うように指定することで解決することができました。
webpack: (config) => {
config.module.rules.push({
test: /(\.ttf|\.otf|\.woff|\.woff2)$/,
use: 'raw-loader',
})
return config
},
webp対応 / 非対応ブラウザの処理、画像のsrcSetの生成
resize
で複数サイズ生成、format=webp
でwebp化ができます。
(デフォルトだと ?webp
ですが responsive-loaderの場合は format=webp
になるので注意してください)
webpだけresize
がついてるところに関しては ↑の「responsive-loader の不具合」を参照してください。
const webp = require(`../../../public${src}?resize&format=webp`)
const img = require(`../../../public${src}`)
pictureタグで二つを出し分けることで、非対応ブラウザは普通のimgを表示するようにします。
遅延読み込み
IntersectionObserverのhooksはnext/imageと少し違うのですが、こちら のコードを使っています。(対して変わりませんが)
表示領域にはいったら、imgタグのsrc attribute にurlを差し込む形です。
const entry = useIntersectionObserver(ref, {
rootMargin: '200px',
freezeOnceVisible: true,
})
const isVisible = !isLazy || !!entry?.isIntersecting
// ...
let imgAttributes = {
width,
height,
src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
srcSet: '',
}
let webpImgAttributes = {
srcSet: '',
}
if (isVisible) {
imgAttributes = {
...imgAttributes,
src: img,
}
webpImgAttributes = {
srcSet: webp.srcSet,
}
}
// ...
<picture css={style.picture}>
<source {...webpImgAttributes} type="image/webp" />
<img
{...rest}
{...imgAttributes}
decoding="async"
alt=""
className={className}
ref={ref}
style={imgStyle}
css={style.img}
/>
</picture>
background-image の対応
まず、デフォルトの画像の説明
next/imageの実装だと、padding-topで領域を確保する用のdivを用意して imageをabsoluteで置くという感じになっていたので、同じようにしました。
next/imageと違うところは、一部のスタイルをインラインではなく Emotion で、クラスの付け替えで表現するようにしています。(そっちのほうがわかりやすそうだったので)
padding-top用のsizerStyle
というstyleを用意して、background用のimageではない場合はこちらで表示するようにしています。
if (
typeof widthInt !== 'undefined' &&
typeof heightInt !== 'undefined' &&
layout !== 'fill'
) {
// default : <Image src="i.png" width="100" height="100" />
const quotient = heightInt / widthInt
const paddingTop = isNaN(quotient) ? '100%' : `${quotient * 100}%`
sizerStyle = { display: 'block', boxSizing: 'border-box', paddingTop }
}
background-imageの場合
sizerStyleをなくすことにより、ただの absolute の image になるので、コンポーネントをラップする側のdivで position: relative
、 Heightを確保することでbackgrond-image風な表示になっています。
background-sizeやbackground-positionなどは object-fit と object-positionにより調整できるようにしてあります。
// ...
<div css={style.header}>
<Image
src={imgList[3]}
layout="fill"
objectFit="cover"
objectPosition="center center"
/>
</div>
// ...
const style = {
header: css`
position: relative;
width: 100%;
height: 320px;
`,
}
デモサイト
上記で記載しましたがあらためて。
vercelにデプロイしてます。
HTML標準の機能で img の loading attribute について少し気になっていたので検証がてらページを作ってみています。
これが標準化されると楽ですね。
最後に
全体的にだいぶ雑だったり、まだ placeholder
で読み込み前に blurの画像を表示する機能や、 priority
で prefetchしておきたい画像は head 内に書いておく機能あたりは書いていないので、今後追加していきたいと思います。
なにか間違ってたりしたら教えてください。
参考記事
Discussion