Reactのレンダーを理解する
Reactの公式ドキュメントを読んでレンダーについて整理できたので記事にしました。
ドキュメントを読むことでレンダーとレンダリングは別物だということがわかりました。
この記事ではレンダーを説明したあとで、レンダリングとの違いも説明します。
レンダーとは
「レンダー」とは、Reactがコンポーネントを呼び出すことです。と公式のドキュメントには記載されています。
あなたがレンダーをトリガした後、React はコンポーネントを呼び出して画面に表示する内容を把握します。「レンダー」とは、React がコンポーネントを呼び出すことです。
https://ja.react.dev/learn/render-and-commit#step-2-react-renders-your-components
しかしこれだけではレンダーを理解した感じがしませんでした。ドキュメントを一通り読んでの私の理解は次の通りです
レンダーは以下の2つの処理をします
- 画面に表示すべき内容を全て把握すること
- 把握した内容をもとに前回との差分計算をすること
Reactは「レンダー」することで上記の2つの処理を行います。次に各仕事を理解するために画面更新の流れを確認します。そもそもレンダーとは画面が更新される際に行われる処理のことです。画面更新の処理を理解することでレンダーの理解が深まります。
画面更新処理
画面の更新処理は以下の3工程で行われます。
- トリガー
- レンダー
- コミット
初回に関してはcreateRootがトリガーとなり、2回目以降はset関数が呼ばれたタイミングがトリガーとなります。(set関数はuseStateもしくはuseReducerの処理です)
準備
今回はviteを使って説明します。以下を実行してReactのテンプレートを作成しました。
npm create vite@latest
1. 初回レンダーのトリガー
初回レンダー時はcreateRootを実行し、createRootの戻り値のrenderを呼び出すことがレンダーのトリガーになります。
アプリが開始するときには、初回のレンダーをトリガする必要があります。フレームワークやサンドボックスは、しばしばこのコードを隠蔽しますが、自力で行う場合には、ターゲットとなる DOM ノードに対して createRoot を呼び出し、作成されたルートの render メソッドを、コンポーネントに対して呼び出します。
https://ja.react.dev/learn/render-and-commit#initial-render
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
2. Reactがコンポーネントをレンダー
Reactはコンポーネントを再帰的に呼び出すことで画面に表示する内容を完全に把握します。
再帰的の説明は公式ドキュメントの説明を引用します。
コンポーネントが他のコンポーネントを返す場合、次にそのコンポーネントを React がレンダーし、そのコンポーネントも何かコンポーネントを返す場合、そのコンポーネントも次にレンダーし、といった具合に続きます。
https://ja.react.dev/learn/render-and-commit#step-2-react-renders-your-components
3. Reactがコミット
コミットとは、Reactがレンダー結果をDOMへ反映することを意味します。
反映にはappendChildを使用するようです。
初回レンダー時には、React は appendChild() DOM API を使用して、作成したすべての DOM ノードを画面に表示します。
https://ja.react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom
これでレンダー結果が画面に表示されました。
冒頭で記載したように
- トリガー
- レンダー
- コミット
の順番で画面の更新が行われました。次はset関数をトリガーとした画面の更新の流れを確認します。
4. トリガー(set関数の呼び出し)
ボタンを押下するとカウントアップする画面を用意しました。
ボタン押下でクリックイベントが発火、setCountが呼ばれます。これがトリガーとなり再レンダーが実行されます。
import { useState } from 'react'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div className="card">
<Counter count={count} />
<button onClick={() => setCount((count) => count + 1)}>
count up
</button>
</div>
</>
)
}
function Counter({count}:{count:number}) {
return(
<div>
<h1>Count: {count}</h1>
</div>
)
}
5. 再レンダー
再レンダーの対象はsetCountが呼ばれたコンポーネントになります。今回であればAppコンポーネントが再レンダーされます。前述した通りレンダーは再帰的に行われるのでAppコンポーネント内で呼ばれるCounterコンポーネントも再レンダーされます。
初回のレンダーと異なるのは、今回は前回のレンダー結果があるということです。Reactは前回のレンダーからどの部分が変わったのかを計算します。
6. 再レンダー結果をコミット
このとき前回から変わった部分だけ
をコミットします。
この点がとても面白い部分になります。Reactは再レンダーを行い前回と差分がある場合のみコミットを行います。つまりレンダーをした結果差分がなければコミットは行われません。
公式ドキュメントでもその点について説明されており、<input/>が変化していないのでコミットされていないことが確認できます。
https://ja.react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom
DOMへ反映をするコミットという処理はとても重い処理のようで、Reactはこれをスキップすることができるのでパフォーマンスが良いとされているようです。(ハイドレーションとかを勉強したときにどこかでDOM反映は重たい処理だと見たのですが一次情報が見つけられませんでした。以下の記事が素晴らしくわかりやすいので参照してください。)
まとめ
改めて「レンダー」とは、Reactがコンポーネントを呼び出すことです。
そしてレンダーは以下の2つの処理を行います
- 画面に表示すべき内容を全て把握すること
- 把握した内容をもとに前回との差分計算をすること
そしてレンダーの結果をもとに画面の更新をしたりしなかったりします。
もっと深く理解する
補足
レンダリングってなに?
レンダーと聞くとレンダリングってなんだっけとなりました。公式ドキュメントを読んでいてもレンダリングという言葉は出てきません。ただし英語のドキュメントにはガッツリ書いてありました。
そもそもレンダリングの意味を調べたところ次の結果となりました。
レンダリング(rendering)とは、あるデータを処理または演算することで画像や映像を表示させることです。
レンダリングは計算から表示までを意味するので「画面更新の3工程をまとめてレンダリングと呼ぶ」と理解しました。
レンダーはあくまで画面更新の中の1工程であり、レンダリングの意味のうち「あるデータを処理または演算すること」に該当すると考えられます。
仕事や記事の中で「今はレンダリングの話」「これはレンダーのことを言っているな」と判断できたほうが混乱を無くせると思うので覚えておきたいです。
補足として公式Reactのドキュメントではブラウザのレンダリングを「ペイント」と呼んでいます。
レンダーが完了し、React が DOM を更新した後、ブラウザは画面を再描画します。このプロセスは「ブラウザレンダリング」として知られていますが、我々は、混乱を避けるために、ドキュメント全体を通して「ペイント」と呼ぶことにします。
レンダー、レンダリング、ペイントを分けて理解していきたいです!
stateを使った計算はレンダー時に行われる
レンダーをせっかく覚えるなら一緒に理解する良さそうなこととしてstateの更新タイミングです。
stateはイベントハンドラの中では更新されません。
ユーザインターフェースとはクリックなどのユーザイベントに直接反応して更新されるものだ、と考えているかもしれません。React の動作は、このような考え方とは少し異なります。
https://ja.react.dev/learn/state-as-a-snapshot#setting-state-triggers-renders
イベントハンドラ内の全ての処理が完了するまでstateの更新処理が待機されます。
しかしながら、ここにもう 1 つ別の要素が関わってきます。イベントハンドラ内のすべてのコードが実行されるまで、React は state の更新処理を待機します。このため、再レンダーはこれらの setNumber() 呼び出しがすべて終わった後で行われます。
https://ja.react.dev/learn/queueing-a-series-of-state-updates#react-batches-state-updates
stateの更新にはキューが利用されます。イベントハンドラが完了した後、React は再レンダーをトリガし、そして再レンダー中に React はstateのキューを処理します。
イベントハンドラが完了した後、React は再レンダーをトリガします。再レンダー中に React はキューを処理します。
https://ja.react.dev/learn/queueing-a-series-of-state-updates#what-happens-if-you-update-state-after-replacing-it
当たり前といえば当たり前です。レンダーはコンポーネントを呼び出すことであり、jsxのスナップショットを取得する行為だからです。レンダーの処理として「1.画面に表示すべき内容を全て把握すること」と記載してきましたが、具体的には「stateを使って、props、イベントハンドラ、ローカル変数の全てを計算しjsxを取得する」ことを意味しています。
。関数から返される JSX は、その時点での UI のスナップショットのようなものです。その JSX 内の props、イベントハンドラ、ローカル変数はすべて、レンダー時の state を使用して計算されます。
https://ja.react.dev/learn/state-as-a-snapshot#rendering-takes-a-snapshot-in-time
メモ化で省略できるのはレンダー
Reactのコンポーネントをmemoでラップすることでパフォーマンスが良くすることができます。
具体的にはmemoを使用することで無駄な再レンダーを減らすことができます。(再レンダリングではないので注意)
再帰的なレンダーは親がレンダーされたら問答無用で行われます。しかし、実際のところpropsが変化していない場合は再レンダーは不要なはずです。なぜなら表示内容が変化するはずがないからです。(画面更新が必要な画面というのは、stateを参照している、もしくはpropsでstateを受け取っているのどちらかです)。props経由でstateを受け取っているが、そのstateが更新されていないコンポーネントは再レンダーが不要なのでmemoを使うことで再レンダー対象外とすることができます。
Discussion