はじめに
こんにちは、でぃーすけです。
Next.jsの画像コンポーネント、next/image
では、外部画像のサポートもされています。
しかし、単一のimg
タグをspan
タグでラップしてレンダリングされるため、picture
タグを使ってアートディレクションだったり拡張子ごとに画像を出し分けることができませんでした。
しかし、先日発表された Next.js ver.12.2.0 では、next/future/image
が追加され、それが(ある程度)可能になりました。
この記事では、next/future/image
と microCMS のデフォルトで使える画像プロバイダー imgix を組み合わせて、
複数の拡張子を出しわけ、かつアートディレクション※1対応のレスポンシブ画像※2のコンポーネントを実装していきます。
※1 アートディレクション: ここでは、「表示される画像を異なる画像表示サイズに合うように変更すること」と定義します。
※2 レスポンシブ画像: 画面サイズ、解像度などの機能が大きく異なる機器で適切に動作する画像
https://developer.mozilla.org/ja/docs/Learn/HTML/Multimediaandembedding/Responsive_images
今回の実装コード
今回実装したコードは GitHub にあげています。
https://github.com/dc7290/nextjs-picture
注釈
今回紹介する実装は以下の点に注意した上で参考にしてください。
- Next.js の実験的機能を使っている(
next/future/image
) - semver の対象外である Next.js 内部でのみ使用されている関数等を使用している
実行環境
- macOS v12.2.1
- Node.js v16.15.1
- Yarn v1.22.19
- Next.js v12.2.0
- React, ReactDOM v18.2.0
実装
※ こちらに記述するコードは一部となり、説明で必要な部分のみになります。詳細については GitHub からご確認ください。
作成するコンポーネントの名称は MicroCMSPicture
としておきます。
まずは受け取る props から考えていきます。
propsの定義
next/image
で指定できる props は指定できるようにしておきたい、
かつ外部画像しか使わないことを考慮して、
import Image, { ImageProps } from 'next/future/image'
type Props = Omit<
ImageProps,
'src' | 'width' | 'height' | 'blurDataURL' | 'loader' | 'alt'
> & {
src: string
width: number
height: number
alt: string
}
src
は通常は文字列、またはimport
,require
で取得したローカル画像を指定しますが、ここでは外部画像のみを取り扱うため、文字列のみとします。width
、height
はImageProps
ではstring
も許容していますが、number
で扱う方が効率が良いためnumber
で定義し直します。blurDataURL
はplaceholder="blur"
にした際にnext/image
で使われるぼかし画像になります。imgix のパラメータを指定する必要があり、コンポーネント内部で指定するため props からは除外します。loader
も同じく imgix のパラメータを指定する必要があり、コンポーネント内部で指定するため props からは除外します。
さらに、アートディレクションするためのsource
タグに用いる値、そしてlink[rel="preload"]
を設定する時に用いる拡張子を指定する値を外から受け取れるようにします。
そうなると、props は以下のようになり、
type ArtDirective = {
src: string
media: string
width?: number
height?: number
}
const AVIF = 'image/avif'
const WEBP = 'image/webp'
type Props = Omit<
ImageProps,
'src' | 'width' | 'height' | 'blurDataURL' | 'loader' | 'alt'
> & {
src: string
width: number
height: number
alt: string
artDirevtives?: ArtDirective[]
preloadFormat?: typeof WEBP | typeof AVIF
}
全体のJSX
次にどのような JSX をレンダリングするかを考えていくと、
picture
タグでsource
タグを必要な数レンダリングするコンポーネントとnext/image
を
ラップする必要があることがわかります。
const MicroCMSPicture = ({ ...props }: Props) => {
return (
<picture>
<Sources />
<Image {...props} />
</picture>
)
}
一旦おおもとのコンポーネントはこのようにしておいて、 Sources
コンポーネントを考えていきます。
Sourcesコンポーネント
このコンポーネントでは source
タグを必要なだけレンダリングします。
また、priority
が true
時は link[rel="preload"]
が必要なので props としては
type SourcesProps = {
sources: DetailedHTMLProps<
SourceHTMLAttributes<HTMLSourceElement>,
HTMLSourceElement
>[]
preloadLinks: { srcSet: string; type: string; media?: string }[]
} & Pick<ImageProps, 'sizes' | 'priority'>
となります。
まずは受け取った sources
を map
して source
タグをレンダリングします。
const Sources = ({ sources }: SourcesProps) => {
return (
<>
{sources.map((sourceProps) => (
<source
key={sourceProps.srcSet}
{...sourceProps}
/>
))}
</>
)
}
つづけて、priority
が true
時の処理を追加します。
const Sources = ({
sources,
sizes = '100vw',
priority,
preloadLinks,
}: SourcesProps) => {
return (
<>
{priority &&
preloadLinks.map(({ srcSet, type, media }) => (
<Head key={srcSet}>
<link
key={'__nimg-' + srcSet + media + sizes}
rel="preload"
as="image"
type={type}
media={media}
imageSrcSet={srcSet}
imageSizes={sizes}
/>
</Head>
))}
</>
)
}
これで、適切な props を渡せば適切な source
タグ、 link[rel="preload"]
タグがレンダリングされるコンポーネントを作成できました。
loaderの実装
次に next/image
に渡す loader
関数を実装します。
このloader
は next/image
内部で URL を解決する際に、src
やsrcset
で使用されます。
https://nextjs.org/docs/api-reference/next/image#loader
今回は microCMS の画像に特定したコンポーネントになるので、その文脈で実装していきます。
const normalizeSrc = (src: string): string => {
return src[0] === '/' ? src.slice(1) : src
}
const loader = ({
src,
width,
quality,
format,
}: ImageLoaderProps & { format?: string }) => {
const url = new URL(normalizeSrc(src))
const params = url.searchParams
params.set('fit', params.get('fit') || 'max')
params.set('w', params.get('w') || width.toString())
if (quality) {
params.set('q', quality.toString())
}
if (format) {
params.set('fm', format)
}
return url.href
}
※ next/image
のimgixLoader
を参考にしています。
getSourcesの実装
ここでこのコンポーネントの肝である getSources
の実装に移ります。getSources
は<Sources />
コンポーネントに渡すsources
, preloadLinks
を返す関数となります。
引数について考えます。
まず srcSet
を生成するために、next/image
内部でも使用される deviceSizes
と <Picture />
コンポーネント自身に渡す artDirevtives
, src
, width
, height
, quality
が必要です。
そして、もともとの画像とは別にモダン拡張子は何を生成するのかを受け取れるようにしておきたいので、formats
も必要です。
最後に、priority
が true
時の link[rel="preload"]
に使用する拡張子も受け取れるようにしておきたいので、preloadFormat
も必要です。
(全ての拡張子について preload してしまうと、例えば、avif が使えるブラウザを使用しているユーザーは、その画像の webp、元の画像の3つを preload してしまい、通信量が無駄になってしまいます。そのため、どの拡張子を優先とするかという戦略をその時の状況に合わせて指定できるようにします。)
type GetSourcesArgs = {
deviceSizes: number[]
src: string
width?: number
height?: number
quality?: number
formats?: string[]
artDirevtives?: ArtDirective[]
preloadFormat: typeof WEBP | typeof AVIF
}
返り値について考えます。
これは <Sources />
コンポーネントが必要としている値でいいので、以下となります。
type GetSourcesResult = {
sources: DetailedHTMLProps<
SourceHTMLAttributes<HTMLSourceElement>,
HTMLSourceElement
>[]
preloadLinks: { srcSet: string; type: string; media?: string }[]
}
まずはアートディレクションしない、単一の画像について考えていきます。
その場合、必要な要素は、
- type が avif の
source
タグ、link[rel="preload"]
タグ - type が webp の
source
タグ、link[rel="preload"]
タグ
となるので、
const FORMATS = [AVIF, WEBP]
const getSources = ({
deviceSizes,
src,
width,
height,
quality = 75,
formats = FORMATS,
artDirevtives,
preloadFormat,
}: GetSourcesArgs): GetSourcesResult => {
const getFotmatParam = (format: string): string =>
format.replace(/^image\//, '')
const getSrcSet = (src: string, format?: string): string =>
deviceSizes
.map(
(deviceSize) =>
`${loader({
src,
width: deviceSize,
quality,
format: format !== undefined ? getFotmatParam(format) : undefined,
})} ${deviceSize}w`
)
.join(', ')
return {
sources: formats.map((format) => ({
srcSet: getSrcSet(src, format),
type: format,
})),
preloadLinks: [
{
srcSet: getSrcSet(src, getFotmatParam(preloadFormat)),
type: preloadFormat,
},
],
}
}
そして、アートディレクションする画像については、これに加えて、
- media に特定の条件があり、type が avif の
source
タグ、link[rel="preload"]
タグ - media に特定の条件があり、type が webp の
source
タグ、link[rel="preload"]
タグ - media に特定の条件がある
source
タグ、link[rel="preload"]
タグ
が必要です。
そのため、先ほどの記述に追加して、
const getSources = ({
deviceSizes,
src,
width,
height,
quality = 75,
formats = FORMATS,
artDirevtives,
preloadFormat,
}: GetSourcesArgs): GetSourcesResult => {
if (artDirevtives !== undefined) {
if (!Array.isArray(artDirevtives)) {
throw Error('`artDirevtives`には配列を指定してください。')
}
const artDirectivesSources = artDirevtives.map(
({ src, media, width, height }) => [
...formats.map((format) => ({
srcSet: getSrcSet(src, format),
type: format,
media,
width,
height,
})),
{ srcSet: getSrcSet(src), media, width, height },
]
)
const defaultSources = formats.map((format) => ({
srcSet: getSrcSet(src, format),
type: format,
width,
height,
}))
const artDirectivesPreloadLinks = artDirevtives.map(({ src, media }) => ({
srcSet: getSrcSet(src, getFotmatParam(preloadFormat)),
type: preloadFormat,
media,
}))
const defaultPreloadLink = {
srcSet: getSrcSet(src, getFotmatParam(preloadFormat)),
type: preloadFormat,
media: 'not all and ' + artDirectivesPreloadLinks.at(-1)?.media,
}
return {
sources: [...artDirectivesSources, ...defaultSources].flat(),
preloadLinks: [...artDirectivesPreloadLinks, defaultPreloadLink],
}
}
}
※ next/image
内部では srcSet
生成の際に用いる number[]
の値の取り扱いがもう少し複雑で、deviceSizes
の一番小さい値よりも小さい値が必要になる場合に imageSizes
の値が使用されます。気になった方はぜひこちらからソースコードを見てみてください。
deviceSizesを取得する
getSources
では next.config.js
で設定する deviceSizes
を使用していたので、それをコンポーネント内で取得する必要があります。そのための準備として、next/future/image
ではどのように next.config.js
の値を取得しているかを見にいきましょう。
https://github.com/vercel/next.js/blob/canary/packages/next/client/future/image.tsx#L309
こちらを覗くと、コンテキストと環境変数に格納した値を使用していることがわかります。
Tips: Next.js では process.env
にオブジェクトを定義するために、webpack の DefinePlugin
を使用しています。
こちらを参考にして、deviceSizes
は取得します。
MicroCMSPictureの実装
これで必要な素材は集まったので、最終的なコンポーネントの実装に移ります。
まずは先ほど手順を確認した、next.config.js
の値から deviceSizes
を取得します。
const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete
const MicroCMSPicture = ({ }: Props) => {
const configContext = useImageConfig()
const deviceSizes = useMemo(() => {
const c = configEnv || configContext || imageConfigDefault
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
return deviceSizes
}, [configContext])
}
次に getSources
で <Sources />
コンポーネントに渡す props を取得し、コンポーネントを追加します。
const MicroCMSPicture = ({
src,
width,
height,
quality,
priority,
artDirevtives,
preloadFormat = 'image/webp',
...props
}: Props) => {
const configContext = useImageConfig()
const deviceSizes = useMemo(() => {
const c = configEnv || configContext || imageConfigDefault
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
return deviceSizes
}, [configContext])
const sources = getSources({
src,
width,
height,
quality: Number(quality),
deviceSizes,
artDirevtives,
preloadFormat,
})
return (
<picture>
<Sources {...sources} sizes={props.sizes} priority={priority} />
</picture>
)
}
最後に、<Image />
コンポーネントを追加します。
その際 props は、以下のように指定します。sizes
は undefined
だと、srcSet
が 1x, 2x の画像しか用意されなくなるので、今回はデフォルトで100vwを指定します。blurDataURL
は小さい画像を用意してあげればいいので、loader
関数を実行して指定します。priority
はそのまま渡してしまうと、内部で元の拡張子の link[rel="preload"]
タグが生成されてしまうので、loading
を eager
にするのみに留めておきます。
const MicroCMSPicture = ({
src,
width,
height,
quality,
alt,
priority,
artDirevtives,
preloadFormat = 'image/webp',
...props
}: Props) => {
const configContext = useImageConfig()
const deviceSizes = useMemo(() => {
const c = configEnv || configContext || imageConfigDefault
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
return deviceSizes
}, [configContext])
const sources = getSources({
src,
width,
height,
quality: Number(quality),
deviceSizes,
artDirevtives,
preloadFormat,
})
return (
<picture>
<Sources {...sources} sizes={props.sizes} priority={priority} />
<Image
{...props}
{...{ src, width, height, quality, loader, alt }}
sizes={props.sizes ?? '100vw'}
blurDataURL={
props.placeholder === 'blur'
? loader({ src, width: 8, quality: 10 })
: undefined
}
loading={priority ? 'eager' : props.loading}
/>
</picture>
)
}
これで MicroCMSPicture
コンポーネントが完成しました!!
実際の表示
最後にこのコンポーネントを実際のWebサイトで見ていきましょう。
https://nextjs-picture.vercel.app/
※ <noscript>
タグは ネイティブのloading属性では必要ないので、next/future/image
側の不具合ですね💦
おわりに
next/future/image
は 通常の img
タグにかなり近い使い方ができるため、今回のようにさまざまな用件を組み合わせたコンポーネントを作成することができました。
そして、imgixを使えばユーザーごとに適切な品質の画像を提供することも容易にできるので、ぜひ活用してみてください。
また、今回の実装には最初の注釈で記述したようにいくつかの注意点があります。
特にNext.js内部でしか使用されていない、Next.jsからすると外部には公開していない認識の関数等を使用している点です。
そのためアップデートの際に壊れているか確認できるように、この記事では載せていませんが実際のコードには簡易的なテストを実装しています。configの取得のロジックが大幅に変わることは頻繁に起きることはないと思いますが、その点には注意して参考にしていただけると幸いです。