✂️

next/imageをexportでも使いたい

2021/11/05に公開

はじめに

ご存知の方も多いとは思いますが、
Next.js の主要な機能(だと思っています笑)である、
next/imageは基本的にはnext exportでは使えません。[1]

Vercel を使えないようなプロジェクトなどで、
それでも Next.js を使いたいケースというのがあると思います。
そんなときにnext/imageの便利さ、優秀さを一度知ってしまっていると、
一から画像コンポーネントを作るのは骨が折れます。。。

そんな想いから、ビルド時に画像を最適化して、
next exportnext/imageの機能を持つコンポーネントを作成しました。

同じ考えを持った方がいればぜひ参考にしていただいて、
さらに良い方法やこの辺りの運用方針などもコメントいただけると幸いです!

この記事でお伝えできる内容

残念ながら、公式のnext/imageを使ってビルド時に画像を最適化することはできないので、
next exportでも使えるnext/imageの機能を持った非公式コンポーネントの紹介になります。

Next.js の思想とはおそらく異なります

作っておいてなんですが、このコンポーネントは Next.js、Vercel の思想とは異なる点にご注意ください。

https://nextjs.org/docs/migrating/from-gatsby#image-component-and-image-optimization

こちらの公式ドキュメントにもあるように、
ビルド時に画像を最適化するソリューションは
画像数の増加に伴いビルド時間が比例して増加するというデメリットがあります。

なので、いわゆる静的ホスティングサービスなどを使っている場合を除き、
基本的にはオンデマンドでの画像最適化を使用する方が良い場合が多いだろうなとは考えています。

実装

重要なところに絞って解説をさせていただきますので、
コードの全容はこちらのリポジトリをご覧ください。

https://github.com/dc7290/next-export-image

responsive-loader を用いて画像を最適化する

今回はビルド時に画像最適化をするにあたり、
Next.js は webpack をバンドラに利用していることから loader の機能を使おうと考えました。

そこで、next/imageで用いられている「レスポンシブ画像」の実装に必要な
複数サイズの画像を生成できるという観点で、
responsive-loaderを採用しました。

https://github.com/dazuaz/responsive-loader

このライブラリは画像処理にsharpを使うことができるので、
ビルド時間もわりかし早いというのも推しポイントです。

Next.js で webpack の設定をいじるにはnext.config.jsを触る必要があります。

/**
 * @type {import('next/dist/next-server/server/config-shared').NextConfig}
 */
const config = {
  reactStrictMode: true,
  images: {
    disableStaticImages: true,
  },
  webpack: (config) => {
    config.module.rules.push({
      test: /\.(jpe?g|png|webp)$/i,
      use: {
        loader: "responsive-loader",
        options: {
          name: "[path][name].[hash].[width].[ext]",
          outputPath: "static/chunks/images/",
          publicPath: "/_next/static/chunks/images/",
          sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
          placeholder: true,
          adapter: require("responsive-loader/sharp"),
        },
      },
    });

    return config;
  },
};

module.exports = config;

このように設定をします。

options の設定を軽く説明させていただくと、

  • name: 画像名をどうするか
  • outputPath: .nextディレクトリのどこに画像を出力するのか
  • publicPath: img タグ等が画像を取得する際に使われるパス
  • sizes: 生成する画像のサイズ
  • placeholder: プレースホルダーを出力するのか
  • placeholderSize: プレースホルダーに使う画像のサイズ
  • adapter: デフォルトのjimpを使わない場合はそれを上書きできる

となります。

これで、jpgpngwebpをインポートしたときは
responsive-loaderで処理されるようになります。

コンポーネント側の実装

公式の Image コンポーネントと同様なところは省略した
全体のコードは以下のようになっています。

const ExportImage = ({
  src,
  sizes,
  alt,
  priority = false,
  loading,
  lazyBoundary = "200px",
  className,
  placeholder = "empty",
  objectFit,
  objectPosition,
  onLoadingComplete,
  wrapperClassName,
  ...all
}: Props) => {
  //
  // ~~~省略~~~
  //

  const {
    placeholder: blurDataURL,
    width,
    height,
    srcSet,
    src: outSrc,
  } = require(`~/src/images/${src}`);
  const serSetWebp = require(`~/src/images/${src}?format=webp`).srcSet;

  //
  // ~~~省略~~~
  //

  let imgAttributes: {
    src: string;
    srcSet?: string;
    serSetWebp?: string;
    sizes?: string;
  } = {
    src: emptyDataURL,
    srcSet: undefined,
    serSetWebp: undefined,
    sizes: undefined,
  };

  if (isVisible) {
    imgAttributes = {
      src: outSrc,
      srcSet,
      serSetWebp,
      sizes,
    };
  }

  return (
    <span className={wrapperClassName} style={wrapperStyle}>
      {sizerStyle && (
        <span style={sizerStyle}>
          {sizerSvg && (
            <img
              style={{
                maxWidth: "100%",
                display: "block",
                margin: 0,
                border: "none",
                padding: 0,
              }}
              alt=""
              aria-hidden={true}
              src={`data:image/svg+xml;base64,${toBase64(sizerSvg)}`}
            />
          )}
        </span>
      )}
      <picture>
        <source srcSet={imgAttributes.serSetWebp} type="image/webp" />
        <img
          {...rest}
          src={imgAttributes.src}
          srcSet={imgAttributes.srcSet}
          sizes={imgAttributes.sizes}
          decoding="async"
          className={className}
          ref={(img) => {
            ref(img);
            handleLoading(img, outSrc, layout, placeholder, onLoadingComplete);
          }}
          style={{ ...imgStyle, ...blurStyle }}
          alt={alt}
        />
      </picture>
      <noscript>
        <img
          {...rest}
          src={outSrc}
          srcSet={srcSet}
          sizes={sizes}
          decoding="async"
          style={imgStyle}
          className={className}
          loading={loading ?? "lazy"}
          alt={alt}
        />
      </noscript>

      {priority ? (
        <Head>
          <link
            key={
              "__nimg-" +
              imgAttributes.src +
              imgAttributes.serSetWebp +
              imgAttributes.sizes
            }
            rel="preload"
            as="image"
            href={imgAttributes.serSetWebp ? undefined : imgAttributes.src}
            imagesrcset={imgAttributes.serSetWebp}
            imagesizes={imgAttributes.sizes}
          ></link>
        </Head>
      ) : null}
    </span>
  );
};

export default ExportImage;

細かく見ていきます。

まず、画像名をsrcprop で受け取り、
コンポーネント内でその画像をインポートします。

const {
  placeholder: blurDataURL,
  width,
  height,
  srcSet,
  src: outSrc,
} = require(`~/src/images/${src}`);
const serSetWebp = require(`~/src/images/${src}?format=webp`).srcSet;

こちらのデータを元にnext/imageと同じようにコンポーネントを実装しました。

公式と違う点としては、picture タグで実装していることです。
こちらのコンポーネントではビルド時に画像処理をするので、
ユーザーエージェントを見て、webp と元の拡張子の画像をだし分ける必要がないので、
安全に picture タグで出し分けるようにしました。

return (
  <span className={wrapperClassName} style={wrapperStyle}>
    {sizerStyle && (
      <span style={sizerStyle}>
        {sizerSvg && (
          <img
            style={{
              maxWidth: "100%",
              display: "block",
              margin: 0,
              border: "none",
              padding: 0,
            }}
            alt=""
            aria-hidden={true}
            src={`data:image/svg+xml;base64,${toBase64(sizerSvg)}`}
          />
        )}
      </span>
    )}
    <picture>
      <source srcSet={imgAttributes.serSetWebp} type="image/webp" />
      <img
        {...rest}
        src={imgAttributes.src}
        srcSet={imgAttributes.srcSet}
        sizes={imgAttributes.sizes}
        decoding="async"
        className={className}
        ref={(img) => {
          ref(img);
          handleLoading(img, outSrc, layout, placeholder, onLoadingComplete);
        }}
        style={{ ...imgStyle, ...blurStyle }}
        alt={alt}
      />
    </picture>
  </span>
);

そして、priorityprop がtrueのときは遅延読み込みでないかつ、
preload する機能が以下になります。

<Head>
  <link
    key={
      "__nimg-" +
      imgAttributes.src +
      imgAttributes.serSetWebp +
      imgAttributes.sizes
    }
    rel="preload"
    as="image"
    href={imgAttributes.serSetWebp ? undefined : imgAttributes.src}
    imagesrcset={imgAttributes.serSetWebp}
    imagesizes={imgAttributes.sizes}
  ></link>
</Head>

こちらも公式と違う点として、(こちらはデメリットになります)
ビルド時に画像を処理して、画像 URL が webp と元の拡張子で異なるので、
preload する画像を webp のみにしなければなりません。

補足

もちろん、webp と元の拡張子の画像の両方を preload することもできますが、
必要でない画像までロードしてしまい、本末転倒になってしまいます。
なので、今回のコンポーネントでは webp のみを preload することにしています。
この辺りも公式の画像処理の手段である、画像のリクエスト先は同じだけど、
返却される画像は違うというアプローチは優れていると感じます。

終わりに

ここまで読んでいただきありがとうございます!
説明不足な点や間違っている点などあればぜひコメントや Twitter でメッセージください!

Next.js が目指す方向性や世界観はとても理想的であると感じることも多く、優れたフレームワークではあるのですが、
今回のようにexportしたいけど使える機能が限定される、ということが多々ある点は人によっては好みが分かれるところかもしれません。

そんな方に今回の記事が少しでも Next.js を使えるケースを増やすお手伝いになれば幸いです。

今後

今後、今回のようなコンポーネントをライブラリで提供できるようにしようかなあとも考えています。

以下のライブラリは更新が止まってからもかなりの数のダウンロードがされていたり、
issue を見ると、ビルド時の画像処理の需要が少なからずあることを確認しています。

https://github.com/cyrilwanner/next-optimized-images

それを考えると、少し方向性は違いますが、こちらのコンポーネントのようなものが提供できれば
使ってくれる人も結構いるのかなと思ってます笑

自分の実力的に実現できるかはわかりませんが、その際はぜひ使っていただけると幸いです笑

脚注
  1. 外部の画像プロバイダーを使えばその限りではありません。 ↩︎

Discussion