Reactのコード分割と遅延読み込み

Reactアプリケーションは通常、特に設定しなければhtmlや外部リソースを除くと、1つのバンドルファイルのみが書き出さます。これはいくつコンポーネントがあっても同じです。結果、1つのファイルサイズが大きくなるため、読み込み・解析に時間がかかることになります。

これを回避するのがコード分割と遅延読み込み(Lazy Loading)です。ルーティングやイベント処理など、必要なときに必要分のコンポーネントを動的にインポートしてくれます。

本記事では、ライブラリを使わないReactの動的読み込みと、react-loadableライブラリを利用した方法に触れてみます。

ライブラリを使用せずに遅延読み込み

まずはライブラリを使わずに遅延読み込みを行ってみます。ディレクトリの構造は以下を想定しています。

/src
- /components
- /modules
  - moduleA.js
- Loading.js
- index.js

サンプルコード

例えば以下のようなシンプルなモジュール(コンポーネント)を読み込むとします。

// /src/modules/moduleA.js

import React from "react";

const moduleA = () => <p>moduleA</p>;

export default moduleA;

利用場所で以下のように動的に読み込みを行うようにするのが目的です。

// /src/index.js内など

<Loading resolve={() => import("./modules/moduleA")} />

そこでモジュール読み込み用のファイルを作成します。async/awaitを利用します。

// src/Loading.js

import React from "react";

export default class Loading extends React.Component {
  state = {
    module: null
  };

  componentDidCatch(error) {
    this.setState({ error });
  }

  async componentDidMount() {
    try {
      const { resolve } = this.props;
      const { default: module } = await resolve();
      this.setState({ module });
    } catch (error) {
      this.setState({ error });
    }
  }

  render() {
    const { module, error } = this.state;
    if (error) { return <p>{error.message}</p> }
    if (!module) { return <p>モジュールをロード中</p>; }
    if (module)  { return React.createElement(module); }
    return <p>ロード済</p>;
  }
}

asyncをつけた関数は、Promiseを返します。awaitは非同期処理の完了を待つため、呼び出し元のimport()処理が完了するまで待機してくれます。

{default: module}は、読み込み先のdefault exportしたものを指しています。

{ default: module } = () => import("./modules/moduleA") // export default moduleA;

これをstateオブジェクトにセットし、moduleに値があるとそれをレンダリングします。まだ読み込まれていなければ、読み込み中を伝えるメッセージを表示しています。

プラグインを使った方法

通常はプラグインに頼った方が賢明です。今回利用するreact-loadableは、Reactで実質標準とも言えるコード分割のプラグインです。まずはパッケージを入れます。

yarn add react-loadable

シンプルな例を示します。以下のようなプロジェクト構成を想定します。

/src
- /components
  - /Item
    - ItemA.js
    - Loading.js
- App.js
- index.js

Loadable()という高階コンポーネントに、以下の2つの設定を指定するだけです。

  • loader – Promiseを返す関数を指定します。実際は読み込むコンポーネントをimportで指定するだけです(importはPromiseを返してくれます)。
  • loading – 読み込み完了までに差し込みたいコンポーネントを指定します。
// src/App.js

import React from 'react';
import Loadable from 'react-loadable';

// ロード中に差し込まれるコンポーネント
import Loading from './components/Item/Loading';

// 読み込むItemAコンポーネント
const ItemA = Loadable({
    loader: () => import('./components/Item/ItemA'),
    loading: Loading,
});

const App = () => <ItemA />

export default App;

ロード時に差し込まれるコンポーネントです。loadingに指定したコンポーネントはerror propsを受け取るため、これでエラー時の処理を分岐しています。

// src/components/Item/Loading.js

import React from 'react';

const Loading = props => {
    if (props.error) {
        return <div>Error!</div>;
    } else {
        return <div>Loading...</div>
    }
};

export default Loading;

読み込まれるコンポーネント側は単なるReactコンポーネントです。

// src/components/Item/ItemA.js

import React from "react";

const ItemA = () => <p>ItemA</p>;

export default ItemA;

以上でItemAコンポーネントは別のJavaScriptファイルとして分割され読み込まれます。本来はReact RouterやUI操作のイベントに応答して利用することになると思います。

また、Webpackでサーバ側のレンダリングや同期レンダリングなどの複雑な機能を使用できるようにするにはreact-loadableのimport-inspectorを使用する必要があるようです。

読み込み先を1つにまとめた場合と注意点

読み込むモジュールが多い場合は、共通のコンポーネントの読み込みを行うファイルを別途用意することもあるようです。

- components/
  - /Item
    - index.js(読み込むコンポーネントをまとめているだけのファイル)
    - ItemA.js(単なる読み込まれるコンポーネント)
    - ItemB.js(単なる読み込まれるコンポーネント)
    - Async.js(動的インポートを行うファイル)
    - Loading.js(先程の例と同じで、ロード時に差し込まれるコンポーネント)
- App.js
- index.js

モジュールの一覧となるファイルです。

// src/components/Item/index.js

import ItemA from "./ItemA";
import ItemB from "./ItemB";

export { ItemA, ItemB };

実際にインポート処理を行うのが以下のファイルです。

// src/components/Item/Async.js

import Loadable from "react-loadable";
import Loading from "./Loading";

const AsyncItemA = Loadable({
  loader: () => import("./index").then(module => module.ItemA),
  loading: Loading
});

const AsyncItemB = Loadable({
  loader: () => import("./index").then(module => module.ItemB),
  loading: Loading
});

export { AsyncItemA, AsyncItemB };

今回はimport()後にthenを使っています。この引数にはItem/index.jsでexportしている内容(ItemA, ItemB)が格納されるため、module.ItemAのように指定すれば、モジュールを個々で読み込むことができます。この点が注意する部分です。

これをReact Routerで使用するとします。

// src/App.js(抜粋)

<ul>
  <li><Link to="/">Home</Link></li>
  <li><Link to="/itema">ItemA</Link></li>
  <li><Link to="/itemb">ItemB</Link></li>
</ul>
<Switch>
  <Route exact path="/" component={Home} />
  <Route path="/itema" component={AsyncItemA} />
  <Route path="/itemb" component={AsyncItemB} />
</Switch>

ItemAのリンクをクリックするとAsyncItemAが実行されItemAが動的にロードされますが、まだ実行していないItemBも同時にロードが行われます。このため、読み込み場所を1箇所にまとめる場合は、ロードするコンポーネント数やそのサイズに注意が必要になりそうです。

反面、この例とは異なりA+B=Cありきのような依存性のあるコンポーネントを動的にロードする場合は、上記のように読み込み先を1つにまとめるのは有益です。

直接読み込み先をインポートすれば、同時にロードされることはありません。

const AsyncItemA = Loadable({
  loader: () => import("./ItemA" /* webpackChunkName: "myItemA" */),
  loading: Loading
});

const AsyncItemB = Loadable({
  loader: () => import("./ItemB" /* webpackChunkName: "myItemB" */),
  loading: Loading
});

尚、上記はChunk名をつけています。

                          Asset       Size   Chunks             Chunk Names
   main.2edb766c34ab217d620d.js    3.3 MiB     main  [emitted]  main
myItemA.2edb766c34ab217d620d.js   2.38 KiB  myItemA  [emitted]  myItemA
myItemB.2edb766c34ab217d620d.js   2.38 KiB  myItemB  [emitted]  myItemB
                    favicon.ico   3.78 KiB           [emitted]
                   ./index.html  266 bytes           [emitted]
Entrypoint main = main.2edb766c34ab217d620d.js

実行・ビルド時にChunk名が書き出されます。

以上です。使用は簡単ですが、どの部分を動的に読み込むかを設計しておかないと、後々手直しが発生しやすくなるため注意が必要です。