先日、ReactがReact Server Componentsを予告しました。まだ開発中とのことなので使えるのは先になりそうですが、非常に面白い内容となっているのでこちらで共有したいと思います。
What is React Server Component
React Server Componentとは、サーバーサイドのみで実行され、バンドルサイズへの影響を与えません。React Server Componentはクライアントでダウンロードされないため、アプリの起動時間などの向上などが期待できます。
また、Server Componentは、データベースなどサーバサイドのデータソースにアクセスすることができたり、レンダリングするClient Componentを動的に選択することができます。
ここで簡単な例に触れてみましょう。
React Server Componentを使った例
以下はノートのタイトルと本文をサーバーサイドから取得し表示し、ノートのエディタをClient Reactコンポーネントとしてレンダリングする例です。
まず、Server Componentを実装するには拡張子を.server.js
または.server.jsx
、.server.tsx
などにします。
import db from 'db.server';
// (A1)
import NoteEditor from 'NoteEditor.client';
function Note(props) {
const {id, isEditing} = props;
// (B)
const note = db.posts.get(id);
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
{/* (A2) */}
{isEditing
? <NoteEditor note={note} />
: null
}
</div>
);
}
この例からいくつか重要なことがわかります。
- A1 :Client Reactコンポーネントをインポートする時は
.client.js
または.client.jsx
,.client.tsx
の拡張子をつける。 - B : データベースなどのサーバーサイドのデータソースに直接アクセスしている
- A2: Client Componentは
isEditing
がtrue
の時のみクライアントにロードされる。つまり必要に応じて動的にロードされることになる
続いて動的にロードされるNoteEditor
コンポーネント(Client Component)について見ていきましょう。
export default function NoteEditor(props) {
const note = props.note;
const [title, setTitle] = useState(note.title);
const [body, setBody] = useState(note.body);
const updateTitle = event => {
setTitle(event.target.value);
};
const updateBody = event => {
setTitle(event.target.value);
};
const submit = () => {
// ...save note...
};
return (
<form action="..." method="..." onSubmit={submit}>
<input name="title" onChange={updateTitle} value={title} />
<textarea name="body" onChange={updateBody}>{body}</textarea>
</form>
);
}
この例で重要な点は、Server Componentの結果をクライアントにレンダリングするときに、以前にレンダリングされた可能性のあるClient Componentの状態を保持することです。具体的には、Reactは、サーバーから渡された新しいPropsを既存のクライアントコンポーネントにマージし、これらのコンポーネントの状態(およびDOM)を維持して、focus、stateや進行中のアニメーションなどを保持します。
React Server Componentのメリット
他にもたくさんのメリットがあるので今確認できるものを紹介していきます。
Zero-Bundle-Size Components
冒頭に述べたように、React Server Componentはサーバーサイドのみで実行されるので、バンドルサイズが0となります。そのため開発時にコードサイズによるパフォーマンス低下を回避することができます。
// NoteWithMarkDown.js
// 従来のReactコンポーネントなのでそのままのバンドルサイズとなる
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* render */);
}
// NoteWithMarkdown.server.js
// ServerComponentなのでバンドルサイズが0になる
import marked from 'marked'; // zero bundle size
import sanitizeHtml from 'sanitize-html'; // zero bundle size
function NoteWithMarkdown({text}) {
// same as before
}
バックエンドへのフルアクセス
Reactでアプリを作成する際の課題として、データへのアクセス方法及び、データの保存場所が挙げられるそうです。Server Componentはバックエンドに直接アクセスできるので、例えば新しいアプリを作成し始めた時などに、データの保存場所がわからない場合にはファイルシステムを用いることもできます。
// Server Componentなのでバックエンドにアクセスできる
import fs from 'react-fs';
function Note({id}) {
const note = JSON.parse(fs.readFile(`${id}.json`));
return <NoteWithMarkdown note={note} />;
}
自動コード分割
コード分割により、アプリケーションを小さなバンドルに分割することができ、クライアントに送信するコードを減らせます。これの一般的なアプローチは、ルート毎にバンドルを遅延ロードするか、実行時になんらかの基準により異なるモジュールを遅延ロードすることが挙げられます。
// Client component
import React from 'react';
// これらの1つは、クライアントでレンダリングされるとロードを開始する
const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js'));
function Photo(props) {
// Switch on feature flags, logged in/out, type of content, etc:
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <PhotoRenderer {...props} />;
}
}
コード分割はパフォーマンス向上に役立ちますが、import時にReact.lazy
を用いて動的インポートする必要が出てきます。また、このアプローチはコンポーネントのロード開始タイミングを遅らせるため、コードのロードする量を減らすといった利点を弱めてしまいます。
そこで、Server Componentを用いることでこれらの問題に対応することができます。コード分割を自動化するために、Server Componentは全てのClient Componentを潜在的なコード分割点として扱います。
加えて、Server Componentは開発者により早く使用するコンポーネントを選ばせることができるので、クライアントはレンダリングプロセスの早期段階でコンポーネントをダウンロードできます。
// Server Componentなので自動コード分割される
import React from 'react';
// これらの1つは、レンダリングされてクライアントにストリーミングされると、ロードを開始する
import OldPhotoRenderer from './OldPhotoRenderer.client.js';
import NewPhotoRenderer from './NewPhotoRenderer.client.js';
function Photo(props) {
// Switch on feature flags, logged in/out, type of content, etc:
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <PhotoRenderer {...props} />;
}
}
No waterfall
パフォーマンスが低下する原因の1つとして、アプリケーションがデータをフェッチするために連続してリクエストを行うときに発生します。たとえば、以下の例のように最初にコンポーネントをレンダリングしてから、useEffect()
内でデータをフェッチすることで生じます。
// Note.js
function Note(props) {
const [note, setNote] = useState(null);
useEffect(() => {
// 子のwaterfallによりレンダリング後にロードされます
fetchNote(props.id).then(noteData => {
setNote(noteData);
});
}, [props.id]);
if (note == null) {
return "Loading";
} else {
return (/* render note here... */);
}
}
親コンポーネントと子コンポーネント両方でこのアプローチをとってしまうと、子コンポーネントは親コンポーネントがデータをロードし終わるまでデータをロードし始めることができません。ただし、このアプローチをとるとアプリケーションが必要なデータを正確にフェッチすることができ、レンダリングされていないUIの部分のデータをフェッチしないようにするといったメリットがあります。
Server Componentを使用すると、サーバとクライアントの連続した往復処理をサーバに移すことでこのメリットをそのままに、問題点を解決することができます。またこの往復処理をサーバーに移動することで、リクエストのレイテンシーを減らし、パフォーマンスを向上させることができます。さらに、Server Componentはコンポーネント内から必要最小限のデータを直接フェッチし続けることができます。
// Note.server.js - Server Component
function Note(props) {
// サーバに低いレイテンシでデータにアクセスし、レンダリング中にロードされます
const note = db.notes.get(props.id);
if (note == null) {
// handle missing note
}
return (/* render note here... */);
}
まとめ
今回説明したServer Componentの特徴を以下にまとめます。
- React Server Componentはサーバーサイドのみで実行されるので、バンドルサイズが0となる
- Client Component(従来のReactコンポーネント)とServer Componentをわけるために
.client.js
、.server.js
のように拡張子を変える - データベース、ファイルシステムサーバー側のデータソースにアクセスできる
- レンダリングするClient Componentを動的にロードできるのでクライアントではページのレンダリングに必要な最小限のコードのみダウンロードできる
- リロード時にクライアントの情報を保持する
さらに詳しい情報が知りたい場合は、公式からデモ動画を見て、デモ用のプロジェクトを試してみてください🙌