はじめに
皆さんはReactでコードの分割行なっていますでしょうか。
WebpackやRollupなどを利用している場合import()
構文を用いることでバンドルファイルを分割することができます。これによってメインのバンドルファイル(どのページでも読み込まれるグローバルなバンドルファイル)が小さくなるので初回n読み込み時間の削減に役立ちます。分割されたバンドルファイルは必要になった時に読み込まれます。
この記事で紹介するものは銀の弾丸ではなく、適切に使うことでパフォーマンスが向上することに注意してください。
import()
ES Moduleでファイルのインポートを行う場合以下のnamed export
とdefault export
の2つの書き方があります。
// @/utils/calc.ts
export const add = (a: number, b: number) => a + b;
// @/main.ts
import { add } from "@/utils/calc";
console.log(add(1, 2));
// @/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節の中でもどこでも読み出すことができます。例えば以下のように書くことができます。
// @/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()
では以下のように読み込みます。
// @/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を用いたケースでは以下のように書くのがおすすめです。
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
についての詳細の実装を知りたい場合はこちらにありますのでみてみると面白いかもしれません。