SSR(Server Side Rendering)の制限事項
サーバーサイドレンダリング(SSR)は初期読み込み性能とSEOの面で大きなメリットを提供しますが、いくつかの重要な限界点があります。この記事では伝統的なSSRの問題点をみて、React 18でこれを解決するために導入されたSuspenseについて説明します。
1. データフェッチングのブロックの問題
SSRの一番大きな問題点の1つは、サーバーから全てのデータを一度に取得する必要があることです。コンポーネントツリーをHTMLでレンダリングする前に、そのページに必要なすべてのデータが用意されている必要があります。
例えば、次のようなコンポーネントで構成されているブログポストページを考えてみましょう。
- ナビゲーションバー
- サイドバー
- 投稿内容
- コメントセクション
もし、コメントデータを取得するのに時間がかかる場合、開発者は次のような選択をする必要があります。
-
コメントをサーバーのレンダリングから除外する
- メリット: 他のコンポーネントを素早く表示することができます。
- デメリット: ユーザーはJavaScriptがロードされるまでコメントを見ることができません。
-
コメントを含めてサーバーでレンダリングする
- 長所: コメントが初期HTMLに含まれる
- 短所: コメントデータを待つためにページ全体のレンダリングが遅れる。
2. ハイドレーションの問題
SSRの2つ目の大きな問題はハイドレーション(hydration)プロセスに関係しています。Reactはサーバーで生成されたHTMLとクライアントのコンポーネントツリーをマッチングするため、すべてのコンポーネントのJavaScriptコードがロードされるまで待つ必要があります。
また、Reactのハイドレーションプロセスに問題があります。現在Reactはコンポーネントツリー全体を一度にハイドレーションするため、次のような問題が発生します。
-
ツリー全体がハイドレーションされるまで、どのコンポーネントともインタラクションできない。
-
特に低スペックのデバイスでパフォーマンスの問題が発生する
- 重いJavaScriptロジックがある場合、画面がフリーズする。
- ユーザーが他のページに移動したくてもハイドレーションの完了を待たなければならない。
3. ウォーターフォール(Waterfall)プロセスの限界
これらの問題の根本原因は、SSRが次のような逐次的な「ウォーターフォール」プロセスに従うためです。
- データフェッチング (サーバー)
- HTMLレンダリング (サーバー)
- JavaScriptコードの読み込み (クライアント)
- ハイドレーション (クライアント)
各ステップは、前のステップが完全に完了した後にのみ開始できるため、全体的なパフォーマンスとユーザーエクスペリエンスが低下します。
新しい解決策Suspense
これらの問題を解決するために、React 18ではSuspenseが導入されました。Suspenseを使用すると、アプリのさまざまな部分を個別にロードしてハイドレーションすることができ、上記の問題を効果的に解決することができます。
使い方
使い方は簡単です。データを全て読み込むのを待ちたくないコンポーネントを<Suspense>
、fallback
propsにロード時に表示するコンポーネントを入れればいいのです。
function BlogPost() {
return (
<div>
<Nav />
<Sidebar />
<Article />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
初期HTMLは次のようになります。
<main>
<!-- 생략... -->
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
その後、サーバーからコメントコンポーネントのためのデータが準備されると、以下のような処理を行います。
<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// This implementation is slightly simplified
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>
このようなプロセスにより、画面に何かを表示するためにすべてのデータを取得する必要がなくなりました。
ハイドレーションの改善
初期HTMLを生成したものの、ハイドレーションが開始されるためには、JavaScriptを全て読み込む必要があります。このような場合、lazy
を通じた code splittingでハイドレーション過程も分離することができます。
const Comments = lazy(() => import('./Comments'));
function BlogPost() {
return (
<div>
<Nav />
<Article />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
コメントセクションを除いた残りのJavaScriptがロードされた部分からハイドレーションが行われます。その後、コメントコンポーネントのJavaScriptまで全てロードされたら、コメントもハイドレーションが行われます。
ここまでの過程をまとめると次のようになります。
Suspenseを発動させるための条件
すべての種類のデータ読み込みがSuspenseを発動させるわけではありません。 では、どのようなデータソースがSuspenseと互換性があるのでしょうか?
-
use
Hookを使ったデータフェッチングjavascriptimport { use } from 'react'; // 데이터를 가져오는 함수 function fetchUserData(userId) { return fetch(`/api/users/${userId}`) .then(res => res.json()); } function UserProfile({ userId }) { const user = use(fetchUserData(userId)); return ( <div> <h1>{user.name}</h1> <p>{user.bio}</p> </div> ); } // Suspense로 래핑 function App() { return ( <Suspense fallback={<Loading />}> <UserProfile userId={1} /> </Suspense> ); }
-
Lazyコンポーネント
javascriptimport { lazy, Suspense } from 'react'; // 동적으로 로드되는 컴포넌트 const HeavyComponent = lazy(() => import('./HeavyComponent')); function App() { return ( <div> <h1>My App</h1> <Suspense fallback={<Loading />}> <HeavyComponent /> </Suspense> </div> ); }
-
サーバーコンポーネントのasync
javascript// 서버 컴포넌트 async function BlogPost({ id }) { const post = await fetchBlogPost(id); return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> </article> ); } function BlogPage({ id }) { return ( <Suspense fallback={<LoadingPost />}> <BlogPost id={id} /> </Suspense> ); }
-
useDeferredValue
使用javascriptfunction SearchResults({ query }) { // 검색어 변경을 지연시켜 처리 const deferredQuery = useDeferredValue(query); return ( <Suspense fallback={<Loading />}> <SearchResultsList query={deferredQuery} /> </Suspense> ); }
Suspenseがトリガーされない場合
一方、次のような一般的なデータフェッチングパターンはSuspenseをトリガーしません。
-
useEffect
内部でのデータペッチングjavascript// ❌ Suspense를 발동시키지 않음 function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { fetchUserData(userId).then(setUser); }, [userId]); if (!user) return <Loading />; return <h1>{user.name}</h1>; }
-
一般的なPromise処理
javascript// ❌ Suspense를 발동시키지 않음 function UserProfile({ userId }) { const [user, setUser] = useState(null); const [error, setError] = useState(null); Promise.resolve(fetchUserData(userId)) .then(setUser) .catch(setError); if (error) return <Error error={error} />; if (!user) return <Loading />; return <h1>{user.name}</h1>; }
Tanstack Queryを使う場合
従来使っていたuseQuery
ではなくuseSuspenseQuery
を使う必要があります。
参考