20
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【React】lazy importでコード分割を行いパフォーマンスの向上を図る

Posted at

はじめに

皆さんはReactでコードの分割行なっていますでしょうか。
WebpackやRollupなどを利用している場合import()構文を用いることでバンドルファイルを分割することができます。これによってメインのバンドルファイル(どのページでも読み込まれるグローバルなバンドルファイル)が小さくなるので初回n読み込み時間の削減に役立ちます。分割されたバンドルファイルは必要になった時に読み込まれます。
この記事で紹介するものは銀の弾丸ではなく、適切に使うことでパフォーマンスが向上することに注意してください。

import()

ES Moduleでファイルのインポートを行う場合以下のnamed exportdefault exportの2つの書き方があります。

named export
// @/utils/calc.ts
export const add = (a: number, b: number) => a + b;

// @/main.ts
import { add } from "@/utils/calc";
 
console.log(add(1, 2));
default export
// @/utils/calc.ts
const add = (a: number, b: number) => a + b;
export default add;

// @/main.ts
import add from "@/utils/calc";
 
console.log(add(1, 2));

この二つの詳細な違いはここでは述べませんが、どちらもファイルの先頭で読み込む必要があります。このルールがあるがぎり、ある分岐でだけ呼び出したいケースなどを叶えることができません(CommonJSのrequireだと可能です)。つまり動的なインポートができません。そこで出てきたのがimport()です。import()はif節の中でもどこでも読み出すことができます。例えば以下のように書くことができます。

import()
// @/utils/calc.ts
export const add = (a: number, b: number) => a + b;

// @/main.ts
console.log(await import('@/utils/calc').then(({ add }) => add(1, 2));

例を見ていただけるとわかると思いますが、importは引数にパスを受け取ってPromiseを返す関数となっています。このケースではwebpackなどのバンドラーはmain.tsの内容を含むindex.jsとcalc.tsの情報を含むcalc.jsの二つに分けたファイルを出力します(名前はバンドラーによって異なります)。現段階では効力を発揮しませんが、ある分岐でのみcalc.jsを使う場合はその分岐に切り替わるまではindex.jsだけを読み込んで利用されるためcalc.js分だけパフォーマンスが有利に働きます。calc.jsを利用する場面にきたら改めて読み込む必要があるので場面遷移のタイミングとしてみるとパフォーマンスが低下することに注意してください。最初の読み込み量が多いことによるパフォーマンス低下と天秤にかけて利用してください。
ちなみにnamed exportしたものだけ紹介しましたが、default exportしたものはimport()では以下のように読み込みます。

import()
// @/utils/calc.ts
const add = (a: number, b: number) => a + b;
export default add;

// @/main.ts
console.log(await import('@/utils/calc').then(({ default: add }) => add(1, 2));

defaultと名前をつけてnamed exportした時とと挙動をはほぼ同じです。

lazy

先ほどES modulesのimport()について学びました。コンポーネントの動的なインポートをimport()を用いて行ってみます。

// @/components/Button.tsx
export const Button = (props: ButtonProps): JSX.Element => (
  <button {...props} />
);

// @/App.tsx
const { Button } = await import('@/components/Button');

function App() {
  return <Button>送信する</Button>;
}

export default App;

Buttonコンポーネントの実装は適当です。このようにした場合ファイル自体を非同期的な扱いをする必要があります(top level awaitのため)。他にもさまざまな実装方法がありますが、どれも環境によっては動かなかったり、非同期的な処理のため扱いづらく不便な点が大きいです。
これを解決するのがreactが提供するlazy関数です。この関数を利用すると、動的なインポートを行ったコンポーネントも普通のコンポーネントのように扱うことができます。

// @/components/Button.tsx
const Button = (props: ButtonProps): JSX.Element => (
  <button {...props} />
);
export default Button

// @/App.tsx
const Button = lazy(() => import('@/components/Button'));

function App() {
  return <Button>送信する</Button>;
}

export default App;

lazyを用いた場合は上のように書き、import()を呼び出す関数を渡すことで行えます。lazyにはコンポーネントをdefault exportによって取得できるファイルをimport()する関数を渡す必要があります。named exportによってコンポーネントを渡しているものはlazyを使えないことに注意する必要があります(named exportでdefaultと名前をつけたものであれば問題ないです)。named exportを用いた動的インポートは後ほど紹介します。

Suspence

普通のコンポーネントとして扱えるものの、動的なインポートなので使用するときは遅延されて読み込まれます。そのためSuepenceを用いて読み込み中のfallbackを用意する必要があります。

// @/components/Button.tsx
const Button = (props: ButtonProps): JSX.Element => (
  <button {...props} />
);
export default Button

// @/App.tsx
const Button = lazy(() => import('@/components/Button'));

function App() {
  return (
    <Suspense fallback={<>Loading</>}>
      <Button>送信する</Button>
    </Suspense>
  );
}

export default App;

named export

lazyを紹介した時named exportを使用しているコンポーネントはlazyを使えないと書きました。しかし、多くのプロジェクトではnamed exportも利用していると考えていますし、それらをlazyに対応するためにdefault exportに書き直すのは億劫です。
Reactドキュメントではnamed exportの解決策として以下のように紹介されてました。

// ManyComponents.js
export const MyComponent = /* ... */;
export const MyUnusedComponent = /* ... */;
// MyComponent.js
export { MyComponent as default } from "./ManyComponents.js";

この方法だと、実装しているコンポーネント側に変更を加える必要があってdefault exportに修正するのと大して変わらないです。
根本的な解決としてlazyをカスタマイズしてnamed exportでも動的なインポートができる関数lazyImportを作成します。実装はこちらを参考にしました。

export function lazyImport<
  T extends { [P in U]: ComponentType },
  U extends string,
>(factory: () => Promise<T>, name: U): T {
  return Object.create({
    [name]: lazy(() => factory().then((module) => ({ default: module[name] }))),
  });
}

これによって下のように書くことができます。

// @/components/Button.tsx
export const Button = (props: ButtonProps): JSX.Element => (
  <button {...props} />
);

// @/App.tsx
const { Button } = lazyImport(
  () => import('@/components/Button'),
  'Button',
);

function App() {
  return <Button>送信する</Button>;
}

export default App;

第一引数にはlazyに渡す関数、第二引数ではnamed export時の名前を渡すことで解決されます。

解説

lazyImport関数について簡単に解説します。

export function lazyImport<
  T extends { [P in U]: ComponentType },
  U extends string,
>(factory: () => Promise<T>, name: U): T {
  return Object.create({
    [name]: lazy(() => factory().then((module) => ({ default: module[name] }))),
  });
}

TypeScriptが苦手な方はこちらの方を見てください(説明は型ありで行います)。

export function lazyImport(factory, name) {
  return Object.create({
    [name]: lazy(() => factory().then((module) => ({ default: module[name] }))),
  });
}

まず引数から見ていきます。第一引数のfactoryはPromiseを返す関数を渡すようになっています。このPromiseはTに解決されます。Tは第二引数であるnameに渡された型Uをキーとして、Reactコンポーネントを表すComponentTypeをバリューとしたオブジェクト型となっています。
関数の中身はオブジェクトを作成しています。第二引数のnameをキー、lazy関数に第一引数のfactoryを実行して解決されたものから第二引数のnameをキーとする値を取り出してそれをバリュー、defaultをキーとするようなオブジェクトを返すようなものとなっています。返り値はTとなっているので、バリューがReactコンポーネントでなければ怒られます。

型や実際の処理のことは忘れて要約するとnamed exportによって取得したものをdefault exportのように扱うことでlazy関数で扱えるようにしています。lazyは引数を実行することで得られたオブジェクトのdefaultをキーとする値を読み込むのでこれで動くと言うわけです。

どこで分割するか

lazyによってコードを分割すると、操作の途中でコードの読み込み時間が発生して逆にUXが悪くなることがあります。そのためlazyでコードの分割をできることは知っているもののどのタイミングで行えば良いかわからないので使用しないと言うことがよくあります。一番簡単でユーザーに影響がないのはページごとに切り替えることです。React Routerを用いたケースでは以下のように書くのがおすすめです。

route.ts
const DashBoard = lazy(() => import('@/components/DashBoard'));
const Account = lazy(() => import('@/components/accounts'));

export const App = () => (
  <Router>
    <Suspense fallback={<>Loading...</>}>
      <Routes>
        <Route path="/" element={<DashBoard />} />
        <Route path="/accounts" element={<Account />} />
      </Routes>
    </Suspense>
  </Router>
);

さいごに

Reactのコンポーネントを遅延読み込みするためにlazyを学びました。使い所を間違えるとパフォーマンスが落ちてしまいますが、まずはページごとに分割するところから試してみてはいかがでしょうか。lazyについての詳細の実装を知りたい場合はこちらにありますのでみてみると面白いかもしれません。

20
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?