パフォーマンス

next/dynamicで重いコンポーネントを遅延読み込みする

読了 7分
パフォーマンス

next/dynamicで重いコンポーネントを遅延読み込みする

ページの初期表示がなんとなく重い、と思って調べたら、開いてすぐには見えないモーダルやグラフ描画のライブラリが、最初のJavaScriptバンドルに丸ごと入っていました。そのときのメモです。

結論として、初期表示に不要なコンポーネントは next/dynamic必要になったときに読み込む ようにすると、最初に読み込むJavaScriptを小さくできます。体感の初期表示がだいぶ軽くなりました。

何が起きているのか

通常の import で読み込んだコンポーネントは、それを使うページのバンドルに含まれます。つまり、画面にすぐ出ないコンポーネントでも、ページを開いた時点でコードがダウンロード・パースされます。

import HeavyChart from './HeavyChart' // 重いグラフライブラリに依存

export default function Page() {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen(true)}>グラフを見る</button>
      {open && <HeavyChart />}
    </div>
  )
}

HeavyChart はボタンを押すまで表示されないのに、ページを開いた瞬間にそのコードは読み込まれています。これがバンドルを膨らませる原因でした。初期表示の指標にも効いてくるので、Core Web Vitalsの記事で触れた数値が悪いときは、ここも疑ってみるとよさそうです。

next/dynamic で遅延読み込みにする

next/dynamic を使うと、コンポーネントを 別のチャンク に切り出し、実際に描画されるタイミングで読み込めます。書き方は以下のようになります。

import dynamic from 'next/dynamic'
import { useState } from 'react'

const HeavyChart = dynamic(() => import('./HeavyChart'))

export default function Page() {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen(true)}>グラフを見る</button>
      {open && <HeavyChart />}
    </div>
  )
}

import('./HeavyChart') という 動的インポートdynamic() に渡すのがポイントです。これで HeavyChart のコードはページ初期表示のバンドルから外れ、opentrue になって実際に描画されるときに読み込まれます。最初に読むJavaScriptが減るので、初期表示が軽くなるわけです。

読み込み中の表示を出す

遅延読み込みは、コードを取りに行くあいだ少し待ちが発生します。その間に何も出ないと不自然なので、loading でプレースホルダを指定できます。

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <p>グラフを読み込み中…</p>,
})

ここで指定したコンポーネントが、本体の読み込みが終わるまで表示されます。一瞬の空白を埋められるので、入れておくと体感がよくなります。

ブラウザ専用のコンポーネントは ssr: false

windowdocument を前提にしたライブラリ(地図やエディタ系に多い印象です)は、サーバー側でレンダリングしようとすると落ちることがあります。その場合は ssr: false でサーバーレンダリングを切り、ブラウザ側だけで描画します。

const MapView = dynamic(() => import('./MapView'), {
  ssr: false,
  loading: () => <p>地図を準備中…</p>,
})

これでサーバーでは描画されず、ブラウザに届いてから読み込まれます。window is not defined のようなエラーで困ったときは、まずこれを疑うとよさそうです。

ただし注意点として、ssr: false にしたコンポーネントはサーバー側のHTMLに含まれません。検索エンジンに拾ってほしい本文や、初期表示で必ず見せたい要素には使わないほうがよいです。「最初は見えなくてよくて、操作で初めて出てくるもの」に限定するのが安全だと思います。

なお、App Router の Server Components では ssr: false は指定できません(クライアントコンポーネントの中で使う必要があります)。このあたりはServerとClient Componentsの記事の使い分けと合わせて考えると整理しやすいです。

何でも遅延すればいいわけではない

便利なので全部 dynamic にしたくなりますが、やりすぎると逆効果なこともあります。

  • 初期表示で必ず見えるもの を遅延すると、表示が遅れてかえって体感が悪くなります。ファーストビューの要素は通常の import のままがよいです。
  • チャンクが細かく分かれすぎると、リクエスト数が増えてオーバーヘッドになることがあります。小さなコンポーネントを無理に切り出す必要はありません。

効果が大きいのは「重いライブラリに依存していて、かつ初期表示には出ない」コンポーネントです。next build の出力でチャンクサイズを見て、大きいものから手を付けるのが現実的だと思います。

まとめ

  • 通常の import は、すぐ表示されないコンポーネントも初期バンドルに含めてしまう
  • next/dynamic + 動的インポートで、必要になったときに読み込めて初期バンドルが軽くなる
  • loading で読み込み中の表示を、ssr: false でブラウザ専用ライブラリに対応できる
  • 初期表示に必要なものまで遅延しない。効くのは「重い・かつ最初は見えない」もの

まずは一番重そうなコンポーネント一つを dynamic に置き換えて、next build のチャンクサイズがどう変わるか見てみるのがおすすめです。詳しい指定方法はNext.js公式ドキュメント(Lazy Loading)も参考になります。以上です。