Next.js+next/dynamic(Dynamic Import)+@svgr/webpackでSVGの非同期読み込みを実現する
はじめに
前記事と前前記事にて、Next.jsにおけるSVGファイルの扱い方について探ってきました。
結論、@svgr/webpack
を導入することで、SVGファイルをReactコンポーネントとしてimportする方法を選択することにしました。
SVGファイルを直接importすることで、インラインコードのjsx
(tsx
)への変換の手間が省かれ、HTMLやCSSでスタイルの変更が可能になり、HTTPリクエスト回数が抑えられるなど、複数のメリットがあります😌
ただ、毎度SVGファイルへのパスを設定する必要があったり、img
タグのように容易に遅延or非同期読み込みが実現できなかったりと、いくつかの懸念点はあります。
本記事では、Next.jsのnext/dynamic
(Dynamic Import)を利用してSVGファイルの非同期読み込みを実現し、SVGをReactコンポーネントとして使いやすく扱えるようにセットアップを進めます。
セットアップ
以下記事で@svgr/webpack
の導入等が完了している想定で進めます。
Dynamic Importを使ってSVGを読み込むコンポーネントの作成
next/dynamic
(Dynamic Import)を使ってSVGを読み込むための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
コンポーネントを作成します。
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ファイルへのパスを設定することなく、stroke
やstrokeWidth
など、svg
タグが本来持っているプロパティをpropsとして設定することができます。
<SvgIcon fileName="menu" width={20} height={20} stroke="#333" strokeWidth={2.5} />
ぜひ、使ってみてください🙆♂️
SvgIconList
の概要
以下記述でSVGAttributes<SVGElement>
の型を拡張して本コンポーネントの型を定義することで、svg
タグ本来が持つプロパティをpropsとして受け取ることができます。
export type SvgIconProps = SVGAttributes<SVGElement> & {
fileName: string;
width: number;
height: number;
};
width
とheight
を必須にすることで、親コンポーネントのSvgIcon.tsx
にも、width
とheight
を設定できるようにします。SVGファイルを非同期で読み込む場合、CLS(Cumulative Layout Shift)に悪影響を及ぼす可能性があるため、SVGファイルと同じ表示サイズの親要素で内包することで、ページ読み込み時の表示ズレを防ぐことができます。
また、以下記述ではimportしたいSVGファイルを記載しています。
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)の詳細は以下公式をご確認ください。
また、以下記述では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
こんにちは。
ちょうど同じようなことをしたくて、本投稿を参照させていただきました。
そこで質問があるのですが、よろしいでしょうか?
nextjs, react自体初心者なので、変な記述があるかもしれませんが、ご了承ください。。
私がやりたいことは、単純にsvgをdynamic importして(ローディング時にかかる負荷を減らしたい)、
指定されたpropsによって表示するSVGを決定する、ということがしたかったのですが、
dynamic import したコンポーネントにclassNameやwidth等のpropsの渡し方がわからなかったため断念し、どうしようかと思っていたところ本投稿にたどり着きました。
以下やろうとしていたことです。
やりたかったこと、伝わりますでしょうか?
ここで私が実現したいことと、本投稿でやっていることって、同じことでしょうか?
変な質問ですみません。