「React Journey」から学ぶ React のレンダリングの仕組み
はじめに
「React Journey」 と呼ばれる React においてコンポーネントがブラウザに表示されるまでどのようなプロセスを踏むのか?を示した図 が非常にわかりやすかったので、説明を加えながらみなさんに紹介したいと思います。
レンダリングの流れについて理解が曖昧な人は、ぜひ最後までご覧ください。
対象読者
- ある程度 React を触っているが、もっとレンダリングについて理解したい人
- 公式ドキュメントの「レンダーとコミット」や「state はスナップショットである」などを読んだことがない人
React における「レンダリング」について
本題に入る前に、React を学習していると混乱しやすい「レンダリング」と呼ばれる概念をまず整理しておきましょう。
以下の記事にも書いてありますが、「レンダリング」という言葉はしばしば次の2種類の意味で使用されます。
- ブラウザへ画面を表示させること
- 関数コンポーネントを実行すること
しかし、公式ドキュメントを読むと上記の2つは別物だと明記されています。
「レンダー」とは、React がコンポーネントを呼び出すことです。
(中略)
このプロセスは「ブラウザレンダリング」として知られていますが、我々は、混乱を避けるために、ドキュメント全体を通して「ペイント」と呼ぶことにします。
すなわち React コンポーネントとは、マークアップを添えることができる JavaScript 関数です。
このようにドキュメント上では
- ブラウザへ画面を表示させること = ペイント
- 関数コンポーネントを実行すること = レンダリング
と定義されています。
つまり React において、「レンダリング」とはコンポーネントという名の JavaScript 関数を呼び出し、戻り値として JSX を得ることと同義なのです。
(以降の「React Journey」の流れを追う際にも、こちらで統一していきます。)
ではここから「React Journey」の流れを追っていきたいと思います。
(以降は「React Journey」をお手元に表示させながら見ていくことをおすすめします。)
大きく
- 初回レンダリング = Initial Render
- 再レンダリング以降 = Re-render
の2つに分けて見ていきます。
また、今回使用するコンポーネントとしては、以下のカウンターアプリを想定しています。
function App() {
const [count, setCount] = useState(1);
useEffect(() => {
const t = setTimeout(() => {
console.log(count);
}, [count * 1000]);
return () => clearTimeout(t);
}, [count]);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
コンポーネントが初回レンダリングされてペイントされるまで(Initial render)
まずコンポーネントが初回レンダリングされて、ブラウザに描画されるまでの流れを見ていきます。
Transpile JSX
まず最初に 「JSX のトランスパイル」 が行われます。
さてここでみなさんに1つ質問です。
React を書く際に用いる「JSX」とは何者でしょうか?
よく HTML と勘違いされがちな JSX ですが、実態としては全く違います。
まず JSX という名前は 「JavaScript Syntax Extension ≒ JavaScript XML」 の略称です。[1]
簡単に言うと 「JavaScript の中で XML ライクな記述ができるようにしたもの」 というイメージです。
この JSX はそのままでは当然実行できず、Babel や TS(tsc)などのツールにより標準の JavaScript に変換(Transpile) されてから実行されることになります。
以下は 今回の JSX をトランスパイルした結果を表しています。
function App() {
const [count, setCount] = useState(1);
useEffect(() => {
const t = setTimeout(() => {
console.log(count);
}, [count * 1000]);
return () => clearTimeout(t);
}, [count]);
+ return React.createElement(
+ "button",
+ {
+ onClick: () => setCount(count + 1),
+ },
+ count
+ )
createElement
関数は、指定した type、props、children を持った React Element(後述する) を作成します。
これにより次ステップ以降の「レンダリング」の準備が整ったことになります。
Initial render
さてトランスパイル が終わったら次は 「レンダリング」 です。
今回はアプリ起動時に 「トリガー」 される 「初回レンダリング(= Initial render)」 が行われます。
ここで 「トリガー」 と呼ばれる見慣れないワードが出てきました。
一般的に、React においてコンポーネントがレンダリングされるためには「トリガー」という過程を踏む必要があります。
イメージとしては、レンダリングのきっかけを与える感じでしょうか。
公式ドキュメントでは、React を「ウェイター」、コンポーネントを「料理人」、できた「料理」を UI と表現してこのあたりをうまく説明しています。
今回の「トリガー」はさしずめ 「お客様(=ユーザー)の注文を厨房(=コンポーネント)に伝えるという行為」を表しています。
さて、このようにして「トリガー」された初回レンダリングでは以下の2つが行われます。
-
createRoot
とそのrender
メソッドを、コンポーネントに対して呼び出す。 - 呼び出し先のコンポーネントをレンダリングする
createRoot
とその render
メソッドを、コンポーネントに対して呼び出す
1. 以下のように対象となる DOM 要素に対して createRoot
を呼び出し、作成された React ルートの render
メソッドを、コンポーネントに対して呼び出します。
こうすることで、ブラウザの DOM 要素内に React コンポーネント(レンダーツリー)を表示するためのルートが作成できたことになります。
import { createRoot } from "react-dom/client";
import App from "./App.js";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
2. 呼び出し先のコンポーネントをレンダリングする
1 で呼び出されたコンポーネントがレンダリングされます。
これにより、下記のような<App />
が実行されることになります。
(ここでは状態 = state の初期値として 1 が代入されています)
function App() {
+ count : 1;
useEffect(() => {
const t = setTimeout(() => {
console.log(1);
}, [1 * 1000]);
return () => clearTimeout(t);
}, [1]);
return React.createElement(
"button",
{
onClick: () => setCount(1 + 1),
},
+ 1
);
}
以上が「Initial Render」のざっくりとした流れです。
レンダリングフェーズでは本当は Fiber なども深く関わってくるのですがここでは割愛します。
気になる人は以下の記事を読むことをおすすめします。
Return a snapshot
さてレンダリングされた結果、 初期 UI の「スナップショット」が得られます。
ここで新たに 「スナップショット」 と呼ばれる概念が登場しました。
こちらは公式ドキュメントにも度々出てくる言葉ですので、詳細に説明します。
スナップショット
まず「スナップショット」という言葉そのものについて考えてみましょう。
以下によると、「スナップショット」とは
ある時点(瞬間)における対象の全体像を丸ごと写し取ったものを表す。
と明記されています。
今回の文脈に置き換えると 「ある時点における UI を丸ごと写し取ったもの」 という感じでしょうか?
もう少し正確に言い換えると
「ある特定のレンダリング時点における状態(= state)を元に計算される JSX」
ということになります。
React では 「状態 = state」 をアプリケーションのコアとして扱います。
UI が動いているように見えても、それはただ背後にある state が変化した結果として UI が毎回ゼロから(JSX をベースに)再構築されているに過ぎず、すべて(= JSX 内の props、イベントハンドラ、ローカル変数)特定のレンダリング時における state の値を元に計算されています。
↑ いわゆる宣言的 UI <=>「UI = f (state)」 と呼ばれるものです。
要するに、React の文脈におけるスナップショットとは上記のように 「コンポーネントが呼び出された時点の状態をもとに計算される DOM = UI」 ということになります。
こうして考えるとスナップショットと言っている意味がなんとなく理解できるのではないでしょうか?
さてレンダリングを経て、<App />
からは以下のような結果が得られます。
{
type: "button",
key: null,
ref: null,
props: {
onClick: () => setCount(1 + 1),
children: 1
}
}
createElement
関数の実行結果は、一般的には 「React Element(React 要素)」 と呼ばれます。実態としてはただのオブジェクトです。
この得られたオブジェクトは、初回レンダリング時点における state(count = 1)を元に計算された DOM = UI のスナップショットということになります。[3]
Commit to the DOM
スナップショットが得られると、それを元に React は DOM を変更します。
初回レンダリングではappendChild
を利用して、作成された DOM を画面に反映させます。
<html>
<head>...</head>
<body>
<div id="root">
<button>1</button>
</div>
</body>
</html>
Browser paints the screen
コミットフェーズを経て、以下のようにブラウザに UI が描画されます。
ちなみにこの過程は 「ブラウザレンダリング」 と呼ばれたりします。
詳細は以下の記事などを参考にすると良いでしょう。
Run the effect
最後にuseEffect
が実行されます。
「エフェクトとクリーンアップ処理の実行タイミング」ばかりに着目されがちであまり知られていませんが、useEffect
は実態としては 「ペイントが終わったあとに実行される関数を予約するもの」 です。
function App() {
count : 1
+ useEffect(() => {
+ const t = setTimeout(() => {
+ console.log(1);
+ }, [1 * 1000]);
+ return () => clearTimeout(t);
+ }, [1]);
return React.createElement(
"button",
{
onClick: () => setCount(1 + 1),
},
1
)
ペイント完了まで待たないと実行されないという性質上、例えばuseEffect
をデータフェッチに用いた場合などパフォーマンス上の問題がいくつか発生する恐れがあります。
※詳細は下記記事などを参考にしてください
コンポーネントが再レンダリングされてペイントされるまで(Re-render)
さて次はボタンをクリックして、コンポーネントが再レンダリング(re-render)されるまでの流れを見ていきましょう。
基本的には初回レンダリングと同じですが、所々違った点があります。
まずボタンをクリックすると、イベントハンドラが実行されて state
が更新されます。
これは「Initial render」でも説明しましたが、 「トリガー」 と呼ばれるプロセスにあたります。
おさらいになりますが、コンポーネントのレンダリングをトリガーするタイミングとしては
- コンポーネントの初回レンダー
- コンポーネント(またはその祖先のいずれか)の state の更新
の2つが存在してます。
今回は後者にあたります。
Re-render with new state
トリガーによって、レンダリングがキューイング(予約)されます。
今回は以下の関数が実行されることになります。
function App() {
+ count : 2;
useEffect(() => {
const t = setTimeout(() => {
console.log(2);
}, [2 * 1000]);
return () => clearTimeout(t);
}, [count]);
return React.createElement(
"button",
{
onClick: () => setCount(2 + 1),
},
+ 2
);
}
Return a new snapshot
関数が実行されると、以下のような React Element が得られます。
{
type: "button",
key: null,
ref: null,
props: {
onClick: () => setCount(2 + 1),
children: 2
}
}
これは count = 2 を元に計算された UI のスナップショットになります。
Compare with previous snapshot
「Initial render」との大きな違いが、この「Compare with previous snapshot」と次の「Update minimal required DOM」です。
まず「Compare with previous snapshot」ですが、これはメモリ上に保存してある以前のスナップショット(React Element)と先ほど得られた新しいスナップショットとを比較します。
そしてそこから差分を導き、実際の DOM に反映させようとします。
このように「どこを変更すべきなのか?」をライブラリ側により解決することを React では 「差分検出処理(= Reconcilialation)」 と呼びます。ここにも Fiber が深く関わっています。
{
type: "button",
key: null,
ref: null,
props: {
- onClick: () => setCount(1 + 1),
+ onClick: () => setCount(2 + 1),
- children: 1
+ children: 2
}
}
Update minimal required DOM
次にコミットフェーズです。
ここで大切なのが、React は違いがあった DOM のみを変更するということです。
<head>...</head>
<body>
<div id="root">
- <button>1</button>
+ <button>2</button>
</div>
</body>
これだけではイメージがつかみづらいと思いますので、以下のサンプルコードを見てください。
import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
return (
<Clock time={time.toLocaleTimeString()} />
);
}
export default function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
こちらのコードでは、<App />
から props として渡される time
により<Clock time={...} />
に毎秒再レンダリングが走りますが、<input />
に入力したテキストは再レンダリングが走っても消えることはありません。
これは、更新されるのは変更が加わった<h1>
のみだからです。
Browser re-paints the screen
そして DOM の更新をブラウザが画面に反映させます。
Check if effect dependencies changed
さてペイントが終わったら、最後はお決まりのuseEffect
が実行されます。
ただし依存配列が設定されている場合には、依存配列内の値に変化がない場合は実行がスキップされます。
今回はリアクティブな値である count に反応するため、エフェクトは実行されます。
function App() {
count : 2;
useEffect(() => {
const t = setTimeout(() => {
console.log(2);
}, [2 * 1000]);
return () => clearTimeout(t);
+ }, [1 → 2]);
return React.createElement(
"button",
{
onClick: () => setCount(2 + 1),
},
2
);
}
Cleanup previous effect
次に前回レンダリング時にuseEffect
内に定義されていたクリーンアップ関数が実行されます。
基本的にuseEffect
は 「エフェクト発火 → クリーンアップ」 のライフサイクルをたどります。再レンダリングが起こった場合も、同様に古いエフェクトで起こった変化分を打ち消すため 「クリーンアップ関数」 が実行されます。
(これはレンダリング前後で、古いスナップショットの影響でロジックが破綻してしまうのを防ぐために行われます。)
function App() {
count : 1;
useEffect(() => {
const t = setTimeout(() => {
console.log(1);
}, [1 * 1000]);
+ return () => clearTimeout(t);
}, [1]);
return React.createElement(
"button",
{
onClick: () => setCount(1 + 1),
},
1
);
}
Run the effect
最後に state 更新後のエフェクトが実行されます。
今回だと下記のようなsetTimeout
関数が実行されることになります。
function App() {
count : 2;
useEffect(() => {
+ const t = setTimeout(() => {
+ console.log(2);
+ }, [2 * 1000]);
return () => clearTimeout(t);
}, [2]);
return React.createElement(
"button",
{
onClick: () => setCount(2 + 1),
},
2
);
}
1つ補足ですが、useEffect
のクリーンアップ関数は当然ながらコンポーネントのアンマウント時にも行われ、最終的にコンポーネントが UI に与えた影響を元通りにしようとします。
これも冪等性を担保するためと言えるでしょう。
おわりに
いかがでしたでしょうか?
長々と話してきましたが、ここまでの話を一旦まとめるとコンポーネントがペイントされるまでの流れとしては以下のようになります。
- 初回レンダリング時(Initial Render)
- トランスパイル
- (トリガー)
- 初回レンダリング
- スナップショット取得
- コミット
- ペイント
- エフェクト作用
- 再レンダリング時(Re-Render)以降
- (トリガー)
- 再レンダリング
- スナップショット取得
- 差分比較
- コミット(最小限)
- ペイント
- 依存配列チェック
- クリーンアップ処理
- (違っていたら)エフェクト作用
違いとしては太字部分くらいで、あとは基本的には 「トリガー → レンダリング → コミット」 の流れを繰り返しているに過ぎません。
こう見ると、React のレンダリングの仕組みは実にシンプルで美しいことがわかるでしょう。
今回の記事を通してレンダリングの流れについて整理していただけたら嬉しい限りです。最後までご覧いただきありがとうございました!
※弊社では、フロントエンドエンジニアのみならずエンジニアの採用を全方位的に行っていますので、興味ある方はぜひ以下のリンクからお気軽にご連絡ください!
参考資料
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion
わかりやすい!!