FORCIA CUBEフォルシアの情報を多面的に発信するブログ

Next.js 10 の新機能 next/image のオプション全部触ってみる

2020.12.21

アドベントカレンダー2020 エンジニア

本記事はNext.js Advent Calendar 2020の 22 日目の記事です。

こんにちは。旅行プラットフォーム部エンジニアの東川です。
フォルシアではフロントエンドフレームワークとして Next.js を使用していますが、2020年は Next.js にとって激動の年であったといえます。 この 1 年間でバージョンは 9.1 から 10.0 に上がり、SSG(Static Site Generation), ISG(Incremental Static Generation)などの新機能が次々に追加されました。

10 月 27 日に Next.js のカンファレンス Next.js Conf の開催と同時に Next.js バージョン 10.0 が発表されました。 国際化に対応したルーティング、Next.js Analytics, Next.js Commerce, React17 対応など数多くの新機能とバージョンアップが発表されましたが、next/image はそのリリースノートの中でも一番上で取り上げられています(Next.js Analytics については山門がこのアドベントカレンダーで紹介記事を書いているのでぜひご覧ください)。 簡単に言えば、next/image とは画像サイズと拡張子をデバイスとブラウザに応じて最適な形で出し分けてくれる React コンポーネントのことです。

この記事では next/image の基礎的な使い方と仕組み、コンポーネントの引数の解説をしたいと思います。

画像の最適化は重要だが手間がかかる

Next.js Conf の Keynoteで指摘されているように、画像ファイルはウェブページ全体のバイト数の半分を占めます。 最適化されていない画像の送受信や描画はページ表示の遅れにつながり、UX(ユーザーエクスペリエンス)の悪化につながります。 フォルシアでは EC サイトの構築を多くやってきていますが、EC サイトのように多くの商品画像を表示する必要があるウェブサイトの場合、この問題は特に重要です。

Next.js Conf の講演Why Images Hurt App Performance & How the New Next.js Image Component Can Helpnext/image の RFCでは、最適化されていない画像とは何かと、その UX への悪影響について以下の点が指摘されています。

  1. 画像サイズ: 通信されるの画像サイズと実際に表示される画像サイズがあっていない
    例えば、スマホの画面に 100×100pixel の画像を<img>タグで表示したいとします。このとき、大きすぎるサイズの画像 500×500pixel の画像を送ってしまうと、無駄な通信が発生して画面描画が遅れます。 また、<img>タグにwidth, heightが設定されていない場合、画像の表示前後で DOM の配置が変化する可能性があります。 これらは、google が提唱する core web vitals の一部、LCP(Largest Contentful Paint, 簡単に言えばファーストビューが表示されるまでの時間です) や CLS(Cumulative Layout Shift, 簡単に言えば描画までに起こった画面レイアウトの変化量) の悪化につながり、全体的な UX の悪化につながります。 特にスマホの場合は、処理スペックに限りがある一方で viewport(画面の表示領域) が小さいため、最適な画像サイズを送ることは特に重要になるといえます。

  1. 拡張子: 軽量な拡張子の画像が使われていない
    モダンな拡張子、例えば webp は jpeg, png に比べて 30%程度軽量です。従って、jpeg, png 画像をこれらの拡張子に置き換えることで通信量の削減ができます。

  1. タイミング: viewport 外の画像を読み込んでいる
    初期描画でページ内のすべての画像を読み込むと、表示にかかる時間が不必要に伸びてしまいます。 速度と表示を両立させるためには、初期描画では viewport 内(ブラウザの表示領域)の画像のみを、それ以外の画像は viewport が近づいたタイミングで順次読み込みます(遅延ローディングと呼ばれます)。

上記の課題は有名な対応法が知られています。 例えば 画像サイズの最適化に関しては、<img>タグでsrcsetを設定すれば、ブラウザが複数の画像から最適なサイズの画像を読み込ませるようにすることができますし、拡張子の最適化は jpeg, png を片っ端から webp に変換すれば対応できます。
遅延ローディングに関しても intersection observer を使った実装などがよく知られています。 Next.js に限ってもnext-optimized-imagesなどの画像の最適化をしてくれるライブラリが知られていました。

しかしながら、画像の最適化はウェブ全体を見ると十分に浸透しているとは言えません(上のリリースノートによれば、99.7%の画像は webp のようなモダンな拡張子が使用されていないそうです)。 これには以下の理由が考えられます。

  1. ブラウザ間の差異を考慮する必要がある
    上述のモダンな拡張子 webp などは、一部のブラウザではサポートされていないため、これらのブラウザのサポートと画像拡張子の最適化を同時にしようとすると、ブラウザを見て返却する拡張子を変化させる必要があります。 また、遅延ローディングには様々な実装方法が知られていますが、実装方法によってはうまく機能しないブラウザなどもあり注意が必要です。 また、ブラウザによって画像サイズを出し分ける場合、素朴には各画像に対してsrcsetと各サイズの準備をする必要があり、実装の手間がかかります。

  1. 外部サーバーから画像を取得する場合、画像の最適化とキャッシュ機能を担う中継サーバーが必要になる
    外部サーバーから取得した画像を最適化する場合、外部サーバーから画像を取得し、画像の最適化をしてブラウザに返却するような中間のサーバーが必要になります(往々にして、このようなサーバーは最適化された画像を保持するキャッシュとしての役割も持ちます)。

画像の最適化に真剣に取り組もうとすると、これらの課題をクリアしながら開発する必要があり、そのコストは決して少なくありませんでした。 これらの解決策として登場したのが next/image です。

next/image の概要と基本的な使い方

また、以下の例は公式の exampleを下敷きにしています。動作確認は以下の条件で行いました。

Next.js: canary(2020/12/14時点), Chrome: 78, Firefox: 83, Internet Exploler: 11

例として、以下の画像ファイル(river.jpg)を考えます。

ac_20201222_01_sample_river_small.jpg

next/image の導入は非常に簡単であり、タグを React コンポーネントに置き換えるだけで画像サイズと拡張子の適切な出し分けができるようになります。 付属ライブラリのインストールなども不要です。

// Case A. 通常の画像コンポーネント
const UnoptimizedImage = () => (
  <img src="{`/river.jpg`}" width="{360}" height="{240}" />
);

// Case B. next/iamgeを使った画像コンポーネント
import Image from "next/image";

const OptimizedImage = () => (
  <img // <img="" />を <img />に置き換えるだけ!!
    src={`/river.jpg`}
    width={360}
    height={240}
  />
);

では、これがどのように画像を最適化してくれるのかを見ていきましょう。 Case A, B の画像を横に並べて比較してみたものが下です。 ac_20201222_02_comparison_original_next_images.png

当然期待されることですが、<img>を使った場合も <Image> コンポーネントを使った場合も同じ画像が表示されます。 一方で、画像取得のリクエストや生成される DOM 要素は大きく異なり、<Image> コンポーネントを使った場合public/下には何の最適化もしていない画像を配置したにもかかわらず、最適な画像サイズの画像が最適な拡張子で遅延読み込みされるようになっています。
上の例だと、元々 resource size 2.7MB の jpeg ファイルが返却されていたのが 24kB の webp に変換され、resource size は元々の 1%程度まで小さくなっています。

<Image>コンポーネントから生成される DOM 要素は下のようであり、<img>に加えて<img>をラップするような DOM 要素が生成されます。

// Case A. 通常の画像コンポーネントから生成されるDOM要素
<img src="/river.jpg" width="360" height="240" />

// Case B. Imageコンポーネントから生成されるDOM要素
<!-- レイアウトを整える用のラッパーDOM要素 -->
<div
  style="
    display: inline-block;
    max-width: 100%;
    overflow: hidden;
    position: relative;
    box-sizing: border-box;
    margin: 0;
  "
>
  <div style="box-sizing: border-box; display: block; max-width: 100%">
    <img
      style="max-width: 100%; display: block"
      alt=""
      aria-hidden="true"
      role="presentation"
      src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYwIiBoZWlnaHQ9IjI0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2ZXJzaW9uPSIxLjEiLz4="
    />
  </div>
  <!-- 画像のDOM要素 -->
  <!-- srcが/_next/image/下のパスに置き換わり、decoding, srcsetが設定されている ! -->
  <img
    src="/_next/image?url=%2Friver.jpg&amp;w=1080&amp;q=75"
    decoding="async"
    style="
      visibility: visible;
      position: absolute;
      inset: 0px;
      box-sizing: border-box;
      padding: 0px;
      border: none;
      margin: auto;
      display: block;
      width: 0px;
      height: 0px;
      min-width: 100%;
      max-width: 100%;
      min-height: 100%;
      max-height: 100%;
    "
    srcset="
      /_next/image?url=%2Friver.jpg&amp;w=384&amp;q=75  1x,
      /_next/image?url=%2Friver.jpg&amp;w=750&amp;q=75  2x,
      /_next/image?url=%2Friver.jpg&amp;w=1080&amp;q=75 3x
    "
  />
</div>

ソースを見るとわかるように<Image>コンポーネントは以下のような DOM 要素を生成します。

// packages/next/client/image.tsx
// <Image>コンポーネントで生成されるDOM要素
<div style={wrapperStyle}>
  {sizerStyle ? (
    <div style={sizerStyle}>
      {sizerSvg ? (
        <img
          style={{ maxWidth: "100%", display: "block" }}
          alt=""
          aria-hidden={true}
          role="presentation"
          src={`data:image/svg+xml;base64,${toBase64(sizerSvg)}`}
        />
      ) : null}
    </div>
  ) : null}
  <img
    {...rest}
    {...imgAttributes}
    decoding="async"
    className={className}
    ref={setRef}
    style={imgStyle}
  />
</div>

<Image> コンポーネントにおける画像取得の仕組みは大まかに以下のようです。

  1. 生成された DOM 要素でdecoding=async が設定されているため、画像のデコード処理が非同期的にバックグラウンド処理されるようになります。<Image> コンポーネントはデフォルトで遅延ローディングになっており、対象画像が viewport に近づくとローディングが始まります。内部的には intersectionObserver を使って対象画像との相対位置を監視しています(ソースの該当箇所)。

  1. 上の生成された DOM 要素 を見ると分かるように、<Image> コンポーネントから生成された<img>タグには srcset が設定されています。これにより、ブラウザは srcset で設定された、w(width)と x(ピクセル密度)が異なる 3 つの画像からブラウザ幅に応じて最適なものを選んでリクエストします。例えば、 PC 画面の場合だと<Image> コンポーネントに設定されたwidth=360に最も近い画像幅w=384をもつx=1のケースが選ばれ、リクエスト/_next/image?url=%2Friver.jpg&amp;w=384&amp;q=75が送られます。

  1. /_next/imageは next のビルド時にできる画像サーバーです。リクエストを受けた画像サーバーは、リクエスト元のブラウザとクエリパラメターから画像サイズと拡張子を適切なものに変換し、ブラウザに返却します。上の例だと、Chrome は webp 対応しているため、webp でwidth=384, height=254の画像ファイルをクライアントに返却します。一点注意するべき点は、<Image> による画像の変換はビルド時ではなくランタイムで行われるということです。これにより画像のレスポンス時間は長くなるものの、画像数の増加によるビルド時間の増大を防ぐことができます。

<Image> を使うことにより、画像のサイズ・拡張子・読み込みタイミングの最適化という冒頭で上げた3つの問題が解消されていることがわかります。
また、この記事では詳しく説明しませんが/_next/imageにできる画像サーバーにはキャッシュ機能があり、変換された webp 画像が.next/cache/images/下に保持され、同じリクエストに対しては webp への変換なしでクライアントに返却される、ブラウザに画像がある場合は/_next/imageでバリデーションした後に 304 コードを返却することでブラウザのキャッシュを利用するようにする、などのことをしています。

<Image> はブラウザに応じて、適切に拡張子を選んでくれます。下は各ブラウザの devtool のネットワークを調べたものですが、 webp に対応している Chrome, Firefox に対しては webp が、webp 非対応の IE に対しては jpeg がそのまま返却されていることがわかります。 ac_20201222_04_comparison_browser.png

コンポーネントのオプション

公式ドキュメントで紹介されているように、<Image>コンポーネントには豊富なオプション引数が存在します。

// <Image>コンポーネントの引数
<Image
  src={`/river.jpg`} // ソースファイル, string
  width={420} // 表示幅, number
  height={280} // 表示高さ, number
  quality={75} // 画質, number
  priority={false} // 表示の優先度, boolean
  loading={"lazy"} // 遅延ロードするかどうか, "lazy" | "eager"
  unoptimized={false} // 最適化するかどうか, boolean
  layout={"fixed"} // レイアウト, "fill" | "fixed" | "intrinsic" | "responsive"
  objectFit={"contain"} // layout='fill'の場合のobject-fit
  objectPosition={"50% 50%;"} // layout='fill'の場合のobject-position
/>

これらのほかに、例えば画像のalt属性など<img>タグに設定できる属性は<Image>コンポーネントの props として設定することができます。 但し、style, srcSet, decodingは例外で、設定したとしても<Image>コンポーネントの内の<img>タグの props を設定する際に上書きされてしまいます(上のソースコードを参照ください)。

必須引数

  • src: 画像のソースファイルです。
    • 型: string
    • public/下を参照するときは通常の<img>タグと同様に/path/to/image/below/publicDir/img.pngのように設定します
  • width: 画像の幅です
    • 型: number
    • 下で説明するように、layout='fill'の時以外は必須です
  • height: 画像の高さです
    • 型: number
    • 下で説明するように、layout='fill'の時以外は必須です

width や height は通常の<img>タグと異なり、必須の引数です。 width や height の設定されていない画像は CLS の悪化を引き起こしますが、<Image>コンポーネントでは開発者が自然とそれを避けられるように設計されていることがわかります。

priority, loading: 表示タイミング・表示の優先度についての任意引数

  • priority: preload するかどうかのフラグです
    • 型: boolean
    • デフォルト値: false
    • true の場合は、ページ遷移時に preload されます。
  • loading: 遅延ローディングをするかどうかのフラグです
    • 型: "lazy" | "eager"
    • デフォルト値: lazy
    • loading=lazyの場合は viewport から計算された値でローディングを開始し、loading=eagerの場合は viewport の位置にかかわらず、ページ遷移した時にローディングを開始します。

上で説明したようにデフォルト設定では画像が遅延ロードされますが、遅延ロードが有効でないケースもあります。 例えば、サイズが大きくローディングに時間がかかる画像やトップページのヒーローイメージのようにファーストビューですぐに表示したい画像などです。 これらのケースではpreload=trueloading='eager'の設定が有効です。

unoptimized, quality: 最適化の有無と画質についての引数

  • unoptimized: 最適化するかどうかのフラグです。
    • 型: boolean
    • デフォルト値: false
    • unoptimized=true の場合、生成される html では<img src='/river.jpg'>のようになり、srcset も設定されません。このため、_next/imageにリクエストはされず、最適化された画像がクライアントに返却されることもありません。
  • quality: 画質
    • 型: number(1~100 の数値)
    • デフォルト値: 75
    • quality を変化させると next の画像サーバー/_next/image/へのリクエストのクエリパラメターqが変化します。下の例だと quality を 1(最低値), 75(デフォルト値), 100(最高値)とした時のスクリーンショットです。resource size はそれぞれ 3.6kB, 24kB, 73kB でした。この例だとq=1 の場合は画質の荒さが気になりますが、q=75q=100 とほとんど遜色なく置き換えても問題ないように感じられます。

qualityによって画像サイズが劇的に変化するため、このオプションは背景画像などサイズが大きくローディング時間を短縮したい状況で使えそうです。 unoptimized=trueと設定するべき状況として、RFC では next/image に対応していない loader の画像を取得する場合などが挙げられています(対応している loader の一覧については公式ドキュメントを参照してください)。

ac_20201222_03_comparison_quality.png

layout, objectFit, objectPosition: 画像の幅と高さなどのレイアウトについての引数

<Image>コンポーネントでは画像のレイアウトに対しても豊富なオプションが提供されています。

  • layout: viewport を変更した時のレイアウトを表します
    • 型: "fill" | "fixed" | "intrinsic" | "responsive"
    • デフォルト値: intrinsic
    • layout='fixed': viewport の幅によらず、設定された width, height の画像を表示します。
    • layout='intrinsic': width が viewport 幅よりも小さい場合は viewport 幅に合わせて小さくなりますが、画像の幅が viewport 幅よりも大きい場合は width の値に設定されます。
    • layout='responsive': viewport 幅に依存して画像幅が変化します。layout='intrinsic'の場合と異なり、画像の幅が viewport 幅よりも大きい場合は viewport 幅に合わせて画像幅が増加します。
    • layout='fill': 親の DOM 要素のheight, widthに合わせて画像の幅と高さが設定されます。
  • objectFit, objectPosition:
    • layout='fill'と同時に使用され、親の DOM 要素内での相対値を表すオプションobject-fit, object-positionの値を設定します。

下の画像はlayout"fixed", "intrinsic", "responsive"の3つのケースに対して、画像幅widthが viewport 幅よりも小さい場合と大きい場合でどのように表示されるかを比較したものです。 デフォルトでは"intrinsic"が適用されており、画像が viewport からはみ出ないようになります。 layout="fixed"は企業ロゴなど常に一定の大きさを保ちたいものに対して使用するのが良さそうです。 背景画像など viewport 幅に合わせて表示したい画像に対してはlayout="responsive"が有効です(css の background-image で画像を指定することも可能ですが、next/image の RFC でも指摘されているようにパフォーマンスの悪化が懸念されます)。

<h2>layout: 'fixed'</h2>
<Image
  src={`/river.png`}
  width={360}
  height={240}
  // viewportの幅によらず一定の画像幅を保つ
  layout={"fixed"}
/>
<h2>layout: 'intrinsic'</h2>
<Image
  src={`/river.png`}
  width={360}
  height={240}
  // widthより小さいviewport幅の場合はviewport幅に合わせてスケール
  // widthより大きいviewport幅の場合はwidthに設定
  layout={"intrinsic"}
/>
<h2>layout: 'responsive'</h2>
<Image
  src={`/river.png`}
  width={360}
  height={240}
  // viewport幅に合わせてスケール
  layout={"responsive"}
/>

ac_20201222_05_layout_compare.png

まとめ

next/image で行われている画像の最適化は、拡張子とサイズの最適化、遅延ローディングと一つ一つを見るとシンプルです。 しかし、これらをブラウザやデバイスの差異を吸収しながら自前ですべて実装しようとするとコストもかかり、バグも生じやすくなります。 next/image を使用することでこの強力な最適化をほとんど zero config で実装でき、通常の<img>からの置き換えがしやすく非常に開発者にやさしい設計となっています。
また、width や height が必須の引数になっていたり、layout="responsive"のオプションが提供されていたりと、パフォーマンスの悪化を招くような実装を自然に避けることができるように、注意深く設計されていることがわかります。
上で解説したように豊富なオプション引数があり、対象画像と要件に合わせて画像の表示タイミングやレイアウトを柔軟に設定できることも魅力の一つです。 新しく Next.js のアプリケーションを作るのであれば next/image を使わない手はないといってよいでしょう。

この記事の執筆中にVercel が 40 億円の資金調達をしたというニュースが入ってきました。 2021 年も Next.js の進化から目が離せませんね。

この記事を書いた人

東川 翔

2019 年新卒入社のエンジニア。
TypeScript が好きです。ゆっくり解説動画はもっと好きです。