⚙️

「React Journey」から学ぶ React のレンダリングの仕組み

2023/12/04に公開
1

はじめに

「React Journey」 と呼ばれる React においてコンポーネントがブラウザに表示されるまでどのようなプロセスを踏むのか?を示した図 が非常にわかりやすかったので、説明を加えながらみなさんに紹介したいと思います。

https://alexsidorenko.com/react-journey

レンダリングの流れについて理解が曖昧な人は、ぜひ最後までご覧ください。

対象読者

React における「レンダリング」について

本題に入る前に、React を学習していると混乱しやすい「レンダリング」と呼ばれる概念をまず整理しておきましょう。

以下の記事にも書いてありますが、「レンダリング」という言葉はしばしば次の2種類の意味で使用されます。

  1. ブラウザへ画面を表示させること
  2. 関数コンポーネントを実行すること

https://zenn.dev/1129_tametame/articles/bf4fc2005bea4d

しかし、公式ドキュメントを読むと上記の2つは別物だと明記されています。

https://ja.react.dev/learn/render-and-commit#step-2-react-renders-your-components

「レンダー」とは、React がコンポーネントを呼び出すことです。

(中略)

このプロセスは「ブラウザレンダリング」として知られていますが、我々は、混乱を避けるために、ドキュメント全体を通して「ペイント」と呼ぶことにします。

https://ja.react.dev/learn/your-first-component

すなわち React コンポーネントとは、マークアップを添えることができる JavaScript 関数です。

このようにドキュメント上では

  1. ブラウザへ画面を表示させること = ペイント
  2. 関数コンポーネントを実行すること = レンダリング

と定義されています。

つまり 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 と表現してこのあたりをうまく説明しています。

https://ja.react.dev/learn/render-and-commit#

今回の「トリガー」はさしずめ 「お客様(=ユーザー)の注文を厨房(=コンポーネント)に伝えるという行為」を表しています。

さて、このようにして「トリガー」された初回レンダリングでは以下の2つが行われます。

  1. createRoot とその render メソッドを、コンポーネントに対して呼び出す。
  2. 呼び出し先のコンポーネントをレンダリングする

1. createRoot とその render メソッドを、コンポーネントに対して呼び出す

以下のように対象となる 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 なども深く関わってくるのですがここでは割愛します。

気になる人は以下の記事を読むことをおすすめします。

https://zenn.dev/ktmouk/articles/68fefedb5fcbdc

Return a snapshot

さてレンダリングされた結果、 初期 UI の「スナップショット」が得られます。

ここで新たに 「スナップショット」 と呼ばれる概念が登場しました。
こちらは公式ドキュメントにも度々出てくる言葉ですので、詳細に説明します。

https://ja.react.dev/learn/state-as-a-snapshot

スナップショット

まず「スナップショット」という言葉そのものについて考えてみましょう。

以下によると、「スナップショット」とは

ある時点(瞬間)における対象の全体像を丸ごと写し取ったものを表す。

と明記されています。

https://e-words.jp/w/スナップショット.html#:~:text=スナップショット 【snapshot】,を指すことが多い。

今回の文脈に置き換えると 「ある時点における 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 要素)」 と呼ばれます。実態としてはただのオブジェクトです。

https://ja.react.dev/reference/react/createElement

この得られたオブジェクトは、初回レンダリング時点における state(count = 1)を元に計算された DOM = UI のスナップショットということになります。[3]

Commit to the DOM

スナップショットが得られると、それを元に React は DOM を変更します。
初回レンダリングではappendChildを利用して、作成された DOM を画面に反映させます。

DOM
<html>
  <head>...</head>
  <body>
    <div id="root">
      <button>1</button>
    </div>
  </body>
</html>

Browser paints the screen

コミットフェーズを経て、以下のようにブラウザに UI が描画されます。

ちなみにこの過程は 「ブラウザレンダリング」 と呼ばれたりします。
詳細は以下の記事などを参考にすると良いでしょう。

https://web.dev/articles/rendering-on-the-web

https://zenn.dev/oreo2990/articles/280d39a45c203e

Run the effect

最後にuseEffectが実行されます。

「エフェクトとクリーンアップ処理の実行タイミング」ばかりに着目されがちであまり知られていませんが、useEffectは実態としては 「ペイントが終わったあとに実行される関数を予約するもの」 です。

Run the effect
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をデータフェッチに用いた場合などパフォーマンス上の問題がいくつか発生する恐れがあります。

※詳細は下記記事などを参考にしてください

https://zenn.dev/aidiot_dev/articles/20231026-useeffect-waterfall

コンポーネントが再レンダリングされてペイントされるまで(Re-render)

さて次はボタンをクリックして、コンポーネントが再レンダリング(re-render)されるまでの流れを見ていきましょう。

基本的には初回レンダリングと同じですが、所々違った点があります。


まずボタンをクリックすると、イベントハンドラが実行されて state が更新されます。

これは「Initial render」でも説明しましたが、 「トリガー」 と呼ばれるプロセスにあたります。

おさらいになりますが、コンポーネントのレンダリングをトリガーするタイミングとしては

  1. コンポーネントの初回レンダー
  2. コンポーネント(またはその祖先のいずれか)の 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 のみを変更するということです。

DOM

  <head>...</head>
  <body>
    <div id="root">
-     <button>1</button>
+     <button>2</button>
    </div>
  </body>

これだけではイメージがつかみづらいと思いますので、以下のサンプルコードを見てください。

App.jsx
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()} />
  );
}
Clock.jsx
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が実行されます。
ただし依存配列が設定されている場合には、依存配列内の値に変化がない場合は実行がスキップされます。

https://ja.react.dev/learn/lifecycle-of-reactive-effects#react-verifies-that-you-specified-every-reactive-value-as-a-dependency

今回はリアクティブな値である count に反応するため、エフェクトは実行されます。

function App() {
 count : 2;

 useEffect(() => {
   const t = setTimeout(() => {
     console.log(2);
   }, [2 * 1000]);
   return () => clearTimeout(t);
+ }, [12]);

 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)
    1. トランスパイル
    2. (トリガー)
    3. 初回レンダリング
    4. スナップショット取得
    5. コミット
    6. ペイント
    7. エフェクト作用
  • 再レンダリング時(Re-Render)以降
    1. (トリガー)
    2. 再レンダリング
    3. スナップショット取得
    4. 差分比較
    5. コミット(最小限
    6. ペイント
    7. 依存配列チェック
    8. クリーンアップ処理
    9. (違っていたら)エフェクト作用

違いとしては太字部分くらいで、あとは基本的には 「トリガー → レンダリング → コミット」 の流れを繰り返しているに過ぎません。

こう見ると、React のレンダリングの仕組みは実にシンプルで美しいことがわかるでしょう。

今回の記事を通してレンダリングの流れについて整理していただけたら嬉しい限りです。最後までご覧いただきありがとうございました!


弊社では、フロントエンドエンジニアのみならずエンジニアの採用を全方位的に行っていますので、興味ある方はぜひ以下のリンクからお気軽にご連絡ください!

https://counterworks.co.jp/recruit/?utm_source=zenn&utm_medium=referral&utm_campaign=advent-calendar-2023&utm_content=4

参考資料

https://alexsidorenko.com/react-journey

https://zenn.dev/ktmouk/articles/68fefedb5fcbdc

https://zenn.dev/yend724/articles/20230210-3d7uuxoj29jr4lpc

https://zenn.dev/yumemi_inc/articles/react-effect-simply-explained

脚注
  1. https://en.wikipedia.org/wiki/JSX_(JavaScript) ↩︎

  2. Fiber とは 1 個のコンポーネント(<MyComponent>や<div>など)管理するオブジェクトです。 React は「DOM ツリー」ならぬ「Fiber ツリー」を生成してレンダリング処理に利用します。また作業単位でもあります。 ↩︎

  3. これらは仮想 DOM としてメモリ上に保持されます。Re-render 時に 差分検出処理(Reconciliation)を行うために使用されます。 ↩︎

COUNTERWORKS テックブログ

Discussion