Next.js 13のServer Componentsは書き方が便利になるだけ
導入
Next.js 13では、目玉機能としてServer Componentsが導入されています。
多くの方が、React 18のRFCや解説を読み、「なんだこれは...」となったのではないでしょうか。(筆者はなりました)
筆者は20時間以上かけて、Next.js 13のドキュメントをほぼ全て読み、片っ端から動作を確認しました。
その結果、Next.js 13において、Server Componentsは難しいものではないという結論に至りました。
この記事では、筆者の調査結果を踏まえ、Server Componentsが全く難しい技術ではないことを説明したいと思います。
この記事のゴール
- Next.js 12 → Next.js 13においてServer Compoentnsに不安を感じている方が、「なんだ、Server Components楽勝じゃん」と思えること
結論
Next.jsのServer Componentsにより得られるメリットは、以下2点です。
- async/awaitが使えて、書き方がちょっとラクになる
- JSバンドルサイズが削減され、パフォーマンスが高くなる
難しさに関して言うと、前者はただ書き方が便利になるだけですし、後者はクライアントでロードされるJSの通信量が少なくなったりしますが、根本的な通信の仕組みに影響するものではありません。
ということで、「書き方が便利になるだけ」というタイトルは半分嘘なのですが、難しさに対する心構えとしてはそのくらいで丁度良いと思っています。
記事の流れ
この記事の前半では、上記Server Componentsのメリットを詳細に説明します。
Next.js 12までの課題がServer Componentsによりどう解決されるかを示します。
この記事の後半では、特にイメージがつきづらいバンドルサイズの削減について、デモを通して理解を深めていただきます。
※以後、Server Component(s)をSC、Client Component(s)をCCと略します。
※この記事でSC/CC と呼んだときは、Next.jsにおけるSC/CCを指します。
1. Server Componentsの目的
Next.js 12までの課題
Next.js 12までのコンポーネントの書き方は以下のようになっていました。
- ルール1. コンポーネントのJSコードは、サーバーでプリレンダリング時に実行された後、必ずクライアントで再度実行される
- Hooksの実行やonClickなどのDOMイベントの登録は、クライアントでのハイドレーション時にJSを走らせることで行われるから
- ルール2. Reactの仕組み上、Promiseをクライアントでの初回レンダリング時に同期的に待機できず、useEffect等を用いて非同期で実行される
- ルール3. ルール1とルール2の結果、コンポーネントでasync/awaitは使えず、サーバーでのみ走るコードを定義したいときにはgetServerSideProps等に分離する
- コンポーネントでPromiseをasync/awaitしたら、ルール1によりクライアントでも走ることになるが、ルール2によりそれは不可能だから
こうしたルールに従い、例えばNext.js 12ではコンポーネントを次のように記述します(抜粋)。
import dayjs from 'dayjs';
const HolidayPage: NextPage<Props> = ({ holidays }) => {
const newYearsDay = dayjs("2023-01-01").format("YYYY/MM/DD");
return (
<div>
<p>{ `${newYearsDay}〜` } </p>
<HolidayList holidays={holidays} />
</div>
);
};
export const getServerSideProps: GetStaticProps<Props> = async () => {
return {
props: { holidays: await fetchHolidays() },
};
};
しかし、こうした書き方には2点の課題がありました(下図)。
Next.js 13 Server Components
勘の良い方はもうお気づきかと思いますが、上記のルールについて、「そもそもJSをクライアントで実行しなくていいコンポーネントをマーキングできたら、こうした課題が解消できるのでは?」という発想が、Next.js 13のSCです。
つまり、SCとは、JSをクライアントで実行せず、クライアントではHTMLとCSSのみとなるコンポーネントのことです。
SCでは、先ほどのコンポーネントを次のように記述できます(抜粋)。
import dayjs from 'dayjs';
const HolidayPage: NextPage<Props> = async ({ holidays }) => {
const newYearsDay = dayjs("2023-01-01").format("YYYY/MM/DD");
const holidays = await fetchHolidays();
return (
<div>
<p>{ `${newYearsDay}〜` } </p>
<HolidayList holidays={holidays} />
</div>
);
};
SCにより、先ほどの課題2点が次のように解消されます。
Server Componentsの目的まとめ
逆に言えば、SCとはたったこれだけのものです。
本当に、少し書き方が便利になってバンドルサイズが削減されるだけで、身構えるほど難しい点はありません。
「あれ、React18のRFCによると、クライアントからSC単位でサーバーにリクエストが行くんじゃ...?」と思われる方もいるかと思いますが、Next.js 13においてはそのような挙動はしません。SCとCCで、全体的な通信の流れは同じです。
な細かな制約やテクニックは学習の必要がありますが、そのあたりはNext.jsの公式ドキュメントが充実していますので参照ください。
この時点で、「なんだServer Components楽勝じゃん」と思っていただけていたら、この記事のゴールは既に達成しています。
せっかくなので、この記事の後半では、実際にバンドルサイズが削減されるのかを見ていきたいと思います。
async/awaitが取り扱える、といったメリットは分かりやすいかと思うのですが、JSバンドルサイズの削減は、具体的に動作を見たほうがイメージがクリアになると思うからです。
2. デモ:SCによるJSバンドルサイズ削減
コードの説明
Next.js 13では、appフォルダ配下のpage.tsxが、ページを表します。
今回は4つのページと1つのコンポーネントを用意しました。
heavy-component.tsは、以下のような重たいJSを含むコンポーネントです。
重たくするためだけに、lodashやaxiosといったパッケージをロードし、特に意味のない処理をしています。
import _ from 'lodash';
import axios from 'axios';
export default function HeavyComponent() {
axios.toString();
_.toString(null);
return (
<p>this is heavy component</p>
)
}
Next.js 13では、appフォルダ配下のコンポーネントはデフォルトでSCですが、CCから直接読み込まれたコンポーネントはCCになります。
この性質を利用し、ページコンポーネントをSCにしたりCCにしたりしながらこの重たいコンポーネントをインポートして、この重たいコンポーネントがSC/CCのときに、それぞれバンドルサイズや通信にどう影響が出るか確かめます。
4つのページのうち、SCである2ページを紹介します。CCのページは、これらのファイルの先頭に'use client'
がついているだけです。
export default function ServerComponentEmptyPage() {
return <p>this is empty server component</p>
}
import { HeavyComponent } from "../../components/heavy-component";
export default function ServerComponentHeavyPage() {
return <div>
<p>this is heavy server component</p>
<HeavyComponent/>
</div>
}
next dev
ではHMRなど邪魔なものが入ったり、SG周りの動きが変わるので、next start
で検証していきます。
1. 空のページでCC/SCを比較する
まず、空のページをそれぞれCCとSCとして作成し、比較してみます。
結論として、通信の流れや、1つ1つのファイルの大きさなど、ほとんど違いはありません。
ここからも、Next.js 13のSCが通信の流れを根本から覆す技術でないことは分かります。
1点の違いとして、CC側では、page-xxx.jsという小さなファイルがリクエストされており、リクエスト数が1つ多くなっています。
CCではクライアントでコンポーネントのJSが再実行されるため、JSがバンドルされてクライアントでロードされます。このファイルがそのJSです。
今回は、ほぼ空に等しいCCであるため、このファイルも1kBと限りなく小さく、1ファイルのみとなっています。
(以下、page-xxx.jsのコードのイメージ)
一方、SCのみのページでは、同様のファイルはダウンロードされていません。
これはレンダリングされている全てのコンポーネントがSCのみだからです(コンポーネントが、SCであるページのみ)。
例えばこのSCからCCを1つでもインポートすれば、同様のファイルがインポートされるようになります。
import {SomeClientComponent} from "./SomeClientComponent";
export default function ServerComponentImportingCcPage() {
// ページはSCだが、CCをインポートしているため、そのCC分はクライアントJSがロードされる
return <div>
<p>this is server component importing cc </p>
<SomeClientComponent/>
</div>
}
ロードされているJSファイルが1つ増え、リクエスト数が合計7つになっているのが分かります。
2. ページに重いCCをインポートして比較する
続いて、重いCC/SCをインポートしたときの違いを見ていきます。
まずはCCから見ていきます。
結果は上記のとおり、通信量が40kB増え、通信時間が20ms遅くなり、DOMロードが5ms遅くなっています。
※軽量なため、通信時間やDOMロード速度は実行するたびに異なる数字が出ますが、傾向はこのとおりです。通信量は毎回変わりません。
※もちろんアプリケーションやコンポーネントの規模により、具体的な数字は変わります。
空のCCページのときはクライアントでロードされるコンポーネントのJSはpage-xxx.jsだけでしたが、以下のようなランダムな名前のJSファイルが2つ増えています。
- [29107295-...].js
- [604-...].js
これらのJSの中には、lodashやaxiosなどのパッケージのコード含む、クライアントで実行されるコンポーネントが含まれています。
なお、JSファイルが2つに分割されているのは、40kBのJSファイル1つを追加するより、良い区切りで複数に分割して、並列でロードしたほうが効率が良いとNext.jsが判断したためです。
これを一般にCode Splittingと言います。
例えば、より軽量なパッケージのみ追加した場合、これらはまとめて1つのJSファイルにバンドルされることもあります。
こうした結果から、CCのコンポーネントのJSは、Next.js 12までと同様にクライアントにロードされ、CCが増えれば増えるほどバンドルサイズやクライアントでの処理時間が伸びることが分かりました。
3. ページに重いSCをインポートして比較する
続いて、ページに重いSCをインポートして比較します。
もう多くの方は予想がついているかと思いますが、結果は上記のとおり、全く変わりません。
この結果から、SCのJSファイルはクライアントにロードされず、CCと比較して、バンドルサイズやクライアントでの処理時間の削減に効果があることが分かります。
これだけだと重いSCをインポートした場合の解説が少し寂しいため、2点補足説明します。
補足1:CC/SCで、サーバーサイドの実行方法に違いはない
CC/SCはあくまでJSがクライアントでも実行されるかどうかの違いであり、サーバー上での実行には違いがありません。
補足2:SCでバンドルサイズは削減できても、サーバーでの実行速度には影響が出る
重いSCをインポートしてもバンドルサイズには影響ありませんが、SSRの場合、サーバーサイドでの処理量が多くなることにより実行速度には影響が出ます。
以下は、先ほどの重いSCをインポートした際の比較を、SGからSSRに変更した結果です。
通信内容にはほとんど違いがありませんが、右側(重いSCをSSR)のレスポンスタイムがやや遅いことが分かります。これはバンドルサイズの違いに起因するのではなく、サーバー側での処理に少し時間がかかっているためです。
補足1のとおり、これは重いCCをSSRしたり、Next.js 12で重いコンポーネントをSSRした場合でも同じです。
CCやNext.js 12の場合、サーバー側での処理時間がかかるうえに、バンドルサイズにも影響が出るので、ダブルパンチになります。
まとめ
Next.jsのServer Componentsは、JSをクライアントで実行しなくていいコンポーネントをマーキングするだけの機能です。
これにより、以下2点のメリットが得られます。
- async/awaitが使えて、書き方がちょっとラクになる
- JSバンドルサイズが削減され、パフォーマンスが高くなる
これらのメリットは素晴らしく、加えて、Server Componentsは難易度の高い技術でもありません。
前者はただ書き方が便利になるだけですし、後者はクライアントでロードされるJSの通信量が少なくなったりしますが、根本的な通信の仕組みに影響するものではないからです。
この記事では、前半でこうしたServer Componentsの目的を説明したうえで、後半で実際にバンドルサイズが削減されることをデモで確認しました。
「Server Components楽勝じゃん」と思ってもらえましたら幸いです。
追伸
よければTwitterもフォローお願いします!
@sumiren_t
もし記載に誤り等ありましたら、いつでもご指摘をお願いいたします。
特に筆者はReactのコアにはNext.jsほど詳しくないため、ご指摘いただけますと大変嬉しいです。
Discussion
コメント失礼します。CCはモジュール参照先だけがSCのレンダリング結果(payload)の一部として(M0, M1等)クライアントに渡されて、JS実行される理解だったのですが、"CCにおいてサーバーサイドでJSが実行される"という意味を少し補足して頂くことは可能でしょうか?
コメントありがとうございます!
まずはゼロベースで説明させていただいたうえで、ご説明いただいた認識に対してコメントさせていただきます。
【1. ゼロベースの説明】
例えば以下のような状況が発生したとします。
このとき、以下のような結果になると認識しています。
※本文が分かりづらいだけでしたら、すみません!修正します!
【2. ご理解へのコメント】
概ね同じ認識です!
細かいですが、「もしかしたらここが認識違うかな」と思う点を2点述べさせていただくと、
というのが私の認識でした!
長文申し訳有りませんが、よろしくお願いいたします。
補足説明ありがとうございます!正しく理解でき勉強になりました!
React server componentのpayloadばかりを気にしていてNext.js本来のHTMLがサーバーサイドで必ずプリレンダリングされるという基本機能を忘れていました。(これがCCにおいてJSがサーバーサイドでも実行されるに対応)。