💨

Next.js+next/dynamic(Dynamic Import)+@svgr/webpackでSVGの非同期読み込みを実現する

2022/10/22に公開1

はじめに

前記事前前記事にて、Next.jsにおけるSVGファイルの扱い方について探ってきました。

結論、@svgr/webpackを導入することで、SVGファイルをReactコンポーネントとしてimportする方法を選択することにしました。

SVGファイルを直接importすることで、インラインコードのjsxtsx)への変換の手間が省かれ、HTMLやCSSでスタイルの変更が可能になり、HTTPリクエスト回数が抑えられるなど、複数のメリットがあります😌

ただ、毎度SVGファイルへのパスを設定する必要があったり、imgタグのように容易に遅延or非同期読み込みが実現できなかったりと、いくつかの懸念点はあります。

本記事では、Next.jsのnext/dynamic(Dynamic Import)を利用してSVGファイルの非同期読み込みを実現し、SVGをReactコンポーネントとして使いやすく扱えるようにセットアップを進めます。

セットアップ

以下記事で@svgr/webpackの導入等が完了している想定で進めます。
https://zenn.dev/toono_f/articles/bd50ddd0a7bc76

Dynamic Importを使ってSVGを読み込むコンポーネントの作成

next/dynamic(Dynamic Import)を使ってSVGを読み込むためのSvgIcon.tsxを作成します。

src/components/atoms/SvgIcon/SvgIcon.tsx
import { SvgIconProps } from "./SvgIconList";
import dynamic, { Loader } from "next/dynamic";
import { FC, SVGProps } from "react";

export const SvgIcon = ({ fileName, width, height, ...props }: SvgIconProps) => {
  const Icon = dynamic(() =>
    import("./SvgIconList").then(
      (module) => module[fileName as keyof Loader<SVGProps<SVGElement>>]
    )
  ) as FC<SVGProps<SVGElement>>;

  return (
    <>
      <span className="icon">
        <Icon width={width} height={height} {...props} />
      </span>
      <style jsx global>{`
        /* レスポンシブ対応させるために設定 */
        svg {
          display: block;
          width: 100%;
          height: 100%;
        }
      `}</style>
      <style jsx>{`
        /* SVGが非同期で読み込まれる前のCLSを考慮するため、親要素に横幅と高さを設定 */
        .icon {
          display: block;
          width: ${width}px;
          height: ${height}px;
        }
        @media (min-width: 768px) {
          .icon {
            width: ${width}px;
            height: ${height}px;
          }
        }
      `}</style>
    </>
  );
};

@svgr/webpackを使ってSVGをimportするコンポーネントの作成

汎用的にimportしたいSVGコンポーネントを記載するSvgIconListコンポーネントを作成します。

src/components/atoms/SvgIcon/SvgIconList.tsx
import { SVGAttributes } from "react";
// 下記で読み込みたいSVGファイルを設定してください
// 今回はmenu.svgとalert.svgを読み込んでいます
import MenuIcon from "public/images/icon/menu.svg";
import AlertIcon from "public/images/icon/alert.svg";

export type SvgIconProps = SVGAttributes<SVGElement> & {
  fileName: string;
  width: number;
  height: number;
};

export const Menu = (props: SvgIconProps) => {
  return <MenuIcon {...props} />;
};

export const Alert = (props: SvgIconProps) => {
  return <AlertIcon {...props} />;
};

実際にページで表示させてみる

SvgIconコンポーネントを以下のように読み込むことで、指定したSVGコンポーネントを表示できます。絶対パスでSVGファイルへのパスを設定することなく、strokestrokeWidthなど、svgタグが本来持っているプロパティをpropsとして設定することができます。

<SvgIcon fileName="menu" width={20} height={20} stroke="#333" strokeWidth={2.5} />

ぜひ、使ってみてください🙆‍♂️

SvgIconListの概要

以下記述でSVGAttributes<SVGElement>の型を拡張して本コンポーネントの型を定義することで、svgタグ本来が持つプロパティをpropsとして受け取ることができます。

src/components/atoms/SvgIcon/SvgIconList.tsx
export type SvgIconProps = SVGAttributes<SVGElement> & {
  fileName: string;
  width: number;
  height: number;
};

widthheightを必須にすることで、親コンポーネントのSvgIcon.tsxにも、widthheightを設定できるようにします。SVGファイルを非同期で読み込む場合、CLS(Cumulative Layout Shift)に悪影響を及ぼす可能性があるため、SVGファイルと同じ表示サイズの親要素で内包することで、ページ読み込み時の表示ズレを防ぐことができます。

また、以下記述ではimportしたいSVGファイルを記載しています。

src/components/atoms/SvgIcon/SvgIconList.tsx
export const Menu = (props: SvgIconProps) => {
  return <MenuIcon {...props} />;
};

export const Alert = (props: SvgIconProps) => {
  return <AlertIcon {...props} />;
};

export文を増やしていく形で、importするsvgファイルを増やしていくことができます。

SvgIconの概要

以下記述では、next/dynamic(Dynamic Import)を利用して、filenameに設定したSVGコンポーネントを非同期で読み込ませています。

  const Icon = dynamic(() =>
    import("./SvgIconList").then(
      (module) => module[fileName as keyof Loader<SVGProps<SVGElement>>]
    )
  ) as FC<SVGProps<SVGElement>>;

  return (
    <>
      <span className="icon">
        <Icon width={width} height={height} {...props} />
      </span>

next/dynamic(Dynamic Import)の詳細は以下公式をご確認ください。
https://nextjs-ja-translation-docs.vercel.app/docs/advanced-features/dynamic-import#名前付きエクスポート

また、以下記述ではCSS in JSのstyled-jsxを用いてスタイルを設定しています。

      <style jsx global>{`
        /* レスポンシブ対応させるために設定 */
        svg {
          display: block;
          width: 100%;
          height: 100%;
        }
      `}</style>
      <style jsx>{`
        /* SVGが非同期で読み込まれる前のCLSを考慮するため、親要素に横幅と高さを設定 */
        .icon {
          display: block;
          width: ${width * 0.7}px;
          height: ${height * 0.7}px;
        }
        @media (min-width: 768px) {
          .icon {
            width: ${width}px;
            height: ${height}px;
          }
        }
      `}</style>

svgタグにwidth: 100%;height: 100%;を設定した理由は、親要素の表示サイズに応じてレスポンシブに横幅や縦幅の変更を実現するためです。

今回だと、768px未満の端末では×0.7のサイズで表示させるように設定してますが、SP用の表示サイズを設定ができるようSvgIconPropsの型にspWidthなどを追加するのもアリだと思います。

Jestのテストの実行時にnext/dynamic(Dynamic Import)を利用したコンポーネントで発生するエラー

Jestによるテストの実行時に、next/dynamic(Dynamic Import)を利用したコンポーネントで、以下のエラーログが表示される可能性があります。

console.error
Warning: An update to ForwardRef(LoadableComponent) inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
/* fire events that update state */
});
/* assert on the output */

This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
at useLoadableModule (/node_modules/next/shared/lib/loadable.js:131:5)
at fileName (/src/components/atoms/SvgIcon/SvgIcon.tsx:12:27)
at composedStory

上記ログにもあるように、act()を利用することで、Dynamic Importで読み込んだコンポーネントに対してもテストを通すことができます。

import { act } from "@testing-library/react";

describe("src/components/atoms/SvgIcon/SvgIcon.tsx", () => {
  test("●●である", async () => {
    await act(async () => {
      // ここにテストの処理を書きます
    });
  });
});

上記例のように、asyncを用いた非同期関数の中に、await act(()=>{})を記述することで、Dynamic Importが完了してからテストが通るようになります。

本記事は以上です。
ここまで読んでいただき、ありがとうございました🌸

Discussion

og189og189

こんにちは。
ちょうど同じようなことをしたくて、本投稿を参照させていただきました。
そこで質問があるのですが、よろしいでしょうか?
nextjs, react自体初心者なので、変な記述があるかもしれませんが、ご了承ください。。

私がやりたいことは、単純にsvgをdynamic importして(ローディング時にかかる負荷を減らしたい)、
指定されたpropsによって表示するSVGを決定する、ということがしたかったのですが、
dynamic import したコンポーネントにclassNameやwidth等のpropsの渡し方がわからなかったため断念し、どうしようかと思っていたところ本投稿にたどり着きました。

以下やろうとしていたことです。

import dynamic from 'next/dynamic'
const Dummy = dynamic(() => import('public/images/dummy.svg'))
const Dummy2 = dynamic(() => import('public/images/dummy2.svg'))

interface Props {
  iconType: 'dummy' | 'dummy2'
  height: number
  width: number
}

const SvgIcon = ({ iconType, height, width }: Props) => (
  if (iconType === 'dummy') {
    return (
      <Dummy width={width} height={height} /> // 型がちがうと怒られる
    )
  } else if (iconType === 'dummy2') {
    return (
      <Dummy2 width={width} height={height} /> // 型がちがうと怒られる
    )
  }

  return <></>
)

export default SvgIcon

やりたかったこと、伝わりますでしょうか?
ここで私が実現したいことと、本投稿でやっていることって、同じことでしょうか?
変な質問ですみません。