microCMS

next/imageを使ってアートディレクション対応のレスポンシブ画像を実装する with imgix

千葉大輔

この記事は公開後、1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに


こんにちは、でぃーすけです。
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 で取得したローカル画像を指定しますが、ここでは外部画像のみを取り扱うため、文字列のみとします。

widthheightImagePropsではstringも許容していますが、numberで扱う方が効率が良いためnumberで定義し直します。

blurDataURLplaceholder="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 タグを必要なだけレンダリングします。
また、prioritytrue 時は link[rel="preload"] が必要なので props としては

type SourcesProps = {
  sources: DetailedHTMLProps<
    SourceHTMLAttributes<HTMLSourceElement>,
    HTMLSourceElement
  >[]
  preloadLinks: { srcSet: string; type: string; media?: string }[]
} & Pick<ImageProps, 'sizes' | 'priority'>

となります。

まずは受け取った sourcesmap して source タグをレンダリングします。

const Sources = ({ sources }: SourcesProps) => {
  return (
    <>
      {sources.map((sourceProps) => (
        <source
          key={sourceProps.srcSet}
          {...sourceProps}
        />
      ))}
    </>
  )
}

つづけて、prioritytrue 時の処理を追加します。

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 関数を実装します。
このloadernext/image 内部で URL を解決する際に、srcsrcset で使用されます。
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/imageimgixLoader を参考にしています。

getSourcesの実装


ここでこのコンポーネントの肝である getSources の実装に移ります。
getSources<Sources /> コンポーネントに渡すsources, preloadLinks を返す関数となります。

引数について考えます。
まず srcSet を生成するために、next/image 内部でも使用される deviceSizes<Picture /> コンポーネント自身に渡す artDirevtives , src, width, height, quality が必要です。
そして、もともとの画像とは別にモダン拡張子は何を生成するのかを受け取れるようにしておきたいので、formats も必要です。
最後に、prioritytrue 時の 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 は、以下のように指定します。

sizesundefined だと、srcSet が 1x, 2x の画像しか用意されなくなるので、今回はデフォルトで100vwを指定します。
blurDataURL は小さい画像を用意してあげればいいので、loader 関数を実行して指定します。
priority はそのまま渡してしまうと、内部で元の拡張子の link[rel="preload"] タグが生成されてしまうので、loadingeager にするのみに留めておきます。

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の取得のロジックが大幅に変わることは頻繁に起きることはないと思いますが、その点には注意して参考にしていただけると幸いです。

リンク集


まずは、無料で試してみましょう。

APIベースの日本製ヘッドレスCMS「microCMS」を使えば、 ものの数分でAPIの作成ができます。

microCMSを無料で始める

microCMSについてお問い合わせ

初期費用無料・14日間の無料トライアル付き。ご不明な点はお気軽にお問い合わせください。

お問い合わせ

microCMS公式アカウント

microCMSは各公式アカウントで最新情報をお届けしています。
フォローよろしくお願いします。

  • X
  • Discord
  • github

ABOUT ME

千葉大輔
microCMSでプロダクトエンジニアをしています。Twitterでは「でぃーすけ」という名前で活動しており、ReactやNext.js、TypeScript、TailwindCSSが特に好きです。趣味はゲームやったりアニメ見たり料理したりをループしています。