はじめに
他のページの情報も含んで配信されるSPAのWebアプリは、
ページごとにレンダリングされるサーバサイドレンダリングのWebアプリに比べて
初期ロードの重さ がデメリットとして語られることが多いです。
実際に使って(作って)いるとサーバサイドレンダリングは素直に早くて良いな、と感じることが結構あります。
しかし、それは結局UXとのトレードオフであり、
エンジニアを悩ませる種の1つであると思います。
対岸の火事でなくなってきた
最近、 Personium Trails
というアプリを作成しています。
https://github.com/personium/app-personium-trails
PersoniumというのはPDSと呼ばれるソフトウェアの1つなのですが、(Personiumについては コチラ)、
Reactを始めとするSPAの潮流を取り入れられていない現状から、
今回のアプリがPersonium+SPAのリファレンスとなることを目指して、
四苦八苦しつつも様々な技術を試しながら制作しています。
そんなアプリですが、調子に乗っていろんなコンポーネントを実装しているうちに
bundle.js
がそれなりのサイズになってきました。
WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.
Entrypoints:
main (2.45 MiB)
bundle.js
いい機会なので code-splitting を導入してみようというのが今回の内容の発端です。
やりたかったこと
スプラッシュ画面的なものを表示したいです。
- ユーザーの認証・ロード画面に関わる部分だけをロード
- ロード画面をレンダリングする
- ユーザーの認証を開始する(非同期)
- アプリの残りの部分をロードする(非同期)
- ユーザーの認証が完了している & アプリのロードが完了している状態まで待つ
- アプリをレンダリングする
上記のようなフローを取り入れることで、アプリのロードが完了してから認証を開始するよりも、
高速にアプリが使えるようにするだけでなく、
2の段階で「お、アプリが起動したな」とユーザーに知らせ、
「なかなか起動しないなこのクソアプリは」という印象を与えてしまうことを防ぐのが狙いです。
実装
以下、コードを交えてやりたかったことを書いていきます。
基本的なパターン
import React, {Suspense, useEffect, useState} from 'react';
import ReactDOM from 'react-dom';
const App = React.lazy(() => import('./App'));
function LoadingView() {
useEffect(() => {
console.log('LoadingView mounted');
return function cleanup() {
console.log('LoadingView unmounted');
};
}, []);
return <h1>いい感じのスプラッシュ画面</h1>;
}
function AppWrapper({children}) {
const [authed, setAuthed] = useState(false);
useEffect(() => {
// handling auth
setAuthed(true);
return function cleanup() {};
}, []);
if (authed) return children;
return <LoadingView />;
}
ReactDOM.render(
<React.StrictMode>
<AppWrapper>
<Suspense fallback={<LoadingView/>}>
<App />
</Suspense>
</AppWrapper>
</React.StrictMode>,
document.getElementById('root')
);
ドキュメントを読むと、遅延ロードするには React.lazy
で実現できるとあり、
実際に使ってみると webpack で生成される bundle.js が複数に分割されます。
最初に bundle.js
が読み込まれ、その後分割された他の部分が読み込まれていきますが、
単体の bundle.js
でもレンダリングができるため、即座にアプリが起動します。便利。
再マウントが気になる…
上記コードを実行すると、
AppWrapper
でレンダリングされる LoadingView
と、
Suspense
でレンダリングされる LoadingView
は別物 であり、
再マウントされてしまうことがわかります。
再マウントされると useEffect
でトリガーするアニメーションが再実行されてしまいます。
(ゲームとかでもロードのフェーズが変わるたびにアニメーションが再トリガーされるロード画面ありますが…)
ちなみに Suspense
ですが、 childrenをレンダリングしようとしたときに
発生する例外をキャッチして fallback の内容をレンダリングしてくれます。便利。
要は、今回はAppロード前にレンダリングしてしまい例外が発生しているのが問題なので、
終了検知をしてから App をレンダリングすれば問題なし、ということでやっていきます。(できませんでした)
終了検知を導入する
import
も所詮はPromiseです。
まずはAppのロードを下記のように書き換えます。
(Appなのでindex.jsがロードされた瞬間に import を開始してよいとしました。)
const appLoader = import('./App');
const App = React.lazy(() => appLoader);
そして、お行儀悪いですが、AppWrapper
に loaded ステートを追加して、
appLoader
が resolve
してから setLoaded
します。
function AppWrapper({children}) {
const [authed, setAuthed] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
// handling auth
setAuthed(true);
appLoader.then(() => setLoaded(true));
return function cleanup() {};
}, []);
if (authed && loaded) return children;
return <LoadingView key="hoge"/>;
}
結果
-
import('./App')
が完了する -
setLoaded
が実行される - authed かつ loaded なので
App
がレンダリングされようとする - 例外が発生する
-
Suspense
のfallbackがレンダリングされる - 結局再マウントが発生する
というわけで import の終了検知をしても、
React.lazy が終了検知できたことにはならず、再マウント問題は解決しませんでした。
結果、再マウントされるタイミングがズレただけでした。残念!
終わりに
自身がReact勉強中というのもあり、アンチパターンを踏み抜いている可能性はあるかもしれませんが、
どなたかの一助になれば幸いです。
また、コメント等あれば教えていただけると嬉しいです。
個人的には LazyComponent ってレンダリング時にロードかけるのが便利なのであって、
今回の使い方にはマッチしていないような気もします…
(サンプルに出てくるのもルートごとにcode splittingして、そのルートの場合のみでロードしている)