前提
この記事の目的
React?何それ?美味しいの?という人に概要を説明して、React?あー、フロントアプリをいい感じに実装できるやつね、という状態にする
想定対象読者
- Reactをこれから学ぶエンジニア
- Reactを何となく書いているけど何故持て囃されているか理解できていない人
この記事で取り扱うこと
- Reactの機能・特徴・仕組みに関する簡単な概要
この記事で取り扱わないこと
- 具体的なReactの書き方など実装関連
- コンポーネント(時間が足りなかった)
- hooksによる状態管理(時間が足りなかった)
- SSR(知識が足りない)
Reactとは
React (リアクト)またはReact.js、ReactJS とは、ウェブブラウザで複雑なUIを容易に生成するためのフリーかつオープンソースなフロントエンドJavaScriptライブラリである[3]。Meta(旧Facebook)が2011年から社内用に開発していたライブラリを2013年に一般に公開したもので、Meta社と個人や企業からなるコミュニティによって開発されている。
Javascriptのフロントエンドライブラリ。
競合するライブラリとしては、VueJS、Svelte、Solidなど。
Reactの特徴
宣言的UI
宣言的UIとは宣言的に実装されたUIのこと。
宣言的(declarative) に実装されたUIとは、そのUIが どうあるべきか が実装されたUIのこと。
この言葉を理解するために、対比される概念 命令的UI を含めて理解するのがよい。
命令的(imperative) に実装されたUIとは、そのUIが どのように振る舞うか が実装されたUIのこと。
言葉だけではよく分からないので、別の表現に置き換えてみる。
これは状態遷移図の例であり、S0・S1・S2が取りうる状態、矢印が状態間の遷移である。
宣言的な実装では、S0・S1・S2の時どう表示されるかを実装するのに対し
命令的な実装では、各矢印でどのような状態変化が起きるかを実装する。
…何となく言わんとしていることは分かってもらえると思うが、React公式の言葉を使って再度噛み砕く。
命令型プログラミングでは、上記の変化がそのまま UI とのインタラクションの実装法に対応します。起こったことに応じて UI を操作するための命令そのものを書かなければならないのです。別の考え方をしてみましょう:
車の中で隣に乗っている人に、曲がるたびに行き先を指示することを想像してみてください。
運転手はあなたがどこに行きたいのか知らず、ただあなたの指示に従うだけです。(そして、あなたの方向指示が間違っていたら、間違った場所に着いてしまいます!)これは命令型と呼ばれます。なぜなら、スピナからボタンに至るまで、個々の要素に対して直接命令し、コンピュータにどのように UI を更新するのか指示しているからです。
React では、あなたが UI を直接操作することはありません。つまり、コンポーネントの有効化、無効化、表示、非表示を直接行うことはありません。代わりに、表示したいものを宣言することで、React が UI を更新する方法を考えてくれるのです。
タクシーに乗ったとき、どこで曲がるかを正確に伝えるのではなく、どこに行きたいかを運転手に伝えることを思い浮かべてください。運転手はあなたをそこに連れていくのが仕事ですし、あなたが考えもしなかった近道も知っているかもしれません!
つまり、 車を前に進めたり左右に曲がらせたりすること は移動するための 命令的 な指示であり、
車をどこに向かわせるか は移動するための 宣言的 な指示ということである。
なお、この公式の表現はReactの宣言的UIの思想を分かりやすく説明する一方、意図的に説明されていない部分がある。
それは、 どこに行きたいかを正確に伝えるのはあなたの責務である ということである。命令的な指示で方向指示に誤りがあれば誤った場所に到着するが、宣言的な指示でも到着地点に誤りがあれば誤った場所についてしまう。
公式ではこの後のページで詳しく説明されるのだが、この表現の中には欠如しているため補足しておく。
(余談)何故Reactを使うと宣言的UIが書けるのか
(以下Webの話)
何の事はなく、Reactが命令的な指示をラップして見えなくしているだけで、実際には裏で命令的な指示を用いてUIを生成している。
WebでDOMを操作する場合は裏でcreateElementなど命令的なAPIを実行している。
しかし、それがReactによって隠蔽されているため、利用者側は意識することなく宣言的な実装を行うことができる。
(余談)宣言的UIの起源について
Reactが宣言的UIの始祖だと思った人がいるかもしれないが、実際にはReact以前から宣言的UIに関する概念や実装したライブラリ等は存在していた。
しかし、Reactがそれを広め普及させるに至った立役者であるため、宣言的UI(の先駆者)といえばReactだと言われるようになった。
仮想DOM
Reactは仮想DOMというものを利用している宣言的UIのパフォーマンスを向上させている。
仮想DOMはその名の通り仮想的な DOM (Document Object Model)である。
Document Object Model(DOM、日: ドキュメントオブジェクトモデル[1])は、マークアップがなされたリソース(Document)をリソース要素(Object)の木構造(Model)で表現し操作可能にする仕組み、またそのモデルである。
HTMLなどのマークアップ言語はタグ要素を組み合わせて木構造を構成するが、DOMはその構造に対応したモデルとして、APIを介しての操作を可能にする。
Reactも同様で、要素を組み合わせて構成された木構造に対応したモデルとして、APIを介して操作を可能にする。
ReactのAPIは呼ばれた裏でDOMのAPIを実行して、ドキュメントを宣言されたあるべき姿に更新する。
これだけだとReactは単にDOMのラッパーに過ぎず、メリットがあるように思えない。
何故仮想DOMを使うと宣言的UIのパフォーマンスが向上するのだろうか。
宣言的UIは状態遷移図で言う状態を表現するものであることは説明した。
状態がS1からS2に変わる時、命令的コマンドではS1からS2に変化する状態変化の内容が伝えられるが、
Reactを介して操作する場合は、S2がどのような状態かだけがReactに伝えられる。
この時、Reactは最終的にDOMのAPIを実行する必要があるため、状態をどのように更新しなければいけないかを知るために、更新前の状態であるS1と更新後の状態であるS2の比較を行い更新内容を把握する。
言い換えると、S1とS2の差分検出処理を実施している。
この差分検出処理は、前の状態(S1)のツリー構造と後の状態(S2)のツリー構造の比較も実施するが、
このツリー構造の比較が厄介で、愚直に実行するとツリーが複雑になるほど比較の負荷が急上昇する。
数学的に言うと、要素数nに対しO(n^3)の計算量が必要になる。
これでは、要素数が増えた時に計算量が多過ぎて画面が固まるなどの悪影響が出てしまう。
そこでReactは大きく2つの方法で対策をしている。
1つが、ツリーの構成に関する条件追加による計算量の削減、
そしてもう1つが仮想DOMによるDOM APIの逐次的実行及び削減である。
ツリーの構成に関する条件追加による計算量の削減
こんな経験は無いだろうか?
- 実装を少しだけ修正してcommitしてpushし、レビューしてもらう前に自分で差分を確認してみたら、修正した内容より遥かに多い差分が表示されて困った
- 2つのドキュメントの差異を見つける必要があり、差分比較ツールに取り込んでみたら、差分をうまく検出してくれない
- 差分比較したら全然関係のない箇所に部分的に一致してしまい、本来比較してほしかった箇所との比較に失敗してしまった
差分検出というのは中々難しく、各種差分ツールも内部的にはいくつかの仮定や条件をつけて比較処理を実行することで計算処理を削減している。その仮定や条件を満たさないものを比較しようとすると見当違いな結果しか得られない。
Reactによるツリー構造の比較も同じで、何も条件を設けないと計算量が膨れ上がってしまう。
そこで、Reactはいくつかの条件を設けて、計算量を削減している。
ツリーの各要素(ノード)において
- 親ノードが異なる場合は子ノードも異なる
- 親ノードが同じ場合でも親から見た順番(1番目、2番目、…)が異なる場合、子ノードは異なる
※例外として、順番が異なってもkeyというフラグが同じ場合、子ノードは一致している - ノードの型が異なる場合、ノードは異なる
※ノードの型については後述
逆に言うと、ノードが一致するための条件は以下ということになる。
- 親ノードが一致している
- 親ノードから見た順番が一致している、あるいはkeyが一致している
- ノードの型が一致している
Reactはこれらの条件を付与することで計算量を削減して、ツリー構造の比較を実現している。
計算量は、要素数nに対しO(n)まで削減されるらしい。
仮想DOMによるDOM APIの逐次的実行及び削減
一般的な話として処理の負荷が高まると画面が固まってしまう。何故だろうか?
負荷の高い処理がいつまで経っても終わらないので、画面更新処理が完了しないからだ。
負荷が高い処理は画面更新処理自体であることもあるし、それ以外であることもある。ただ、詰め込みすぎなのだ。
最近のブラウザなどは優秀で、アニメーション処理などユーザ体験に直結する優先度の高い処理用のインターフェースと、より優先度の低い処理用のインターフェースが用意されていたりする。
しかし、インターフェースが用意されていてもそれを適切に使わなければ意味がないし、適切に使っていたとしても詰め込み過ぎては意味がない。
Reactはこの問題を解決してくれる。
例えば、画面のフレームの範囲内に収まっていない要素のレンダリングは優先度を落として処理するよう指示を出し、ユーザのインタラクションに繋がる処理は優先的に処理するよう指示を出す。処理が複雑に絡み合ったレンダリング処理を分解し、描画処理の遅延でフレーム落ちが発生しない単位で指示を出す。まだ画面側に指示出しをしていないが、既に不要になり画面で実行する必要がない処理を見つけ、指示リストから除外する。
プログラムとブラウザなどの間でReactがタスクマネジメントを行い、ブラウザなどが処理落ちしないように制御をしてくれるのだ。
そして、このタスクマネジメントを支えるDOMの内部管理モデルこそ仮想DOMに他ならない。
(余談)仮想DOMがないとタスクマネジメントが行えない?
仮想DOMが無くてもタスクマネジメントが行えないことはない。そもそも複雑なマネジメントが必要なほどタスクを作らなければ良いのだ。
Reactは仮想DOMという形でモデルを管理する。このモデルの中には、内部状態に関わらず不変のノードも存在する。しかし差分検出の都合上、全てをモデル化して管理するのがReactの方法だ。結果、差分検出対象として毎回チェックが必要になる(裏側では最適化されているかもしれないが)。
一方、SvelteやSolidなどのフレームワークは仮想DOMを使っていない。仮想DOMの構築自体や仮想DOMによるリアルタイムな差分検出処理をオーバーヘッドと捉え、別の方式で宣言的UIを実現している。これらのフレームワークはDOMのどの要素が不変でどの要素が可変なのかを最初に識別してしまう。不変の要素は不変なのだから気にする必要はなく、可変の要素のみに集中ができる。可変の要素がどのように変化し得るかをも事前に計算しておけば、実際に状態が変わった際のタスクが削減できる。
実際、後発とはいえSvelteやSolidは、仮想DOMを使わない方針でReact以上の性能を記録している。
では、仮想DOM方式は宣言的UI黎明期の産物に過ぎず、今後仮想DOMを使わない方式に取って代わられるのだろうか。
これは明確にNoと言える。詳しくは後述するが、仮想DOMには仮想DOMとしての強みがあるからだ。
Reactはどう動くか
reactを初めて触った人は十中八九こう思うだろう。
react-domってパッケージは何なんだろう
これを説明するために、まずはReactがブラウザなどにレンダリングを行う流れを整理する。
Reactの処理の流れ
0. JSXのトランスパイル
実装上の話になるが、ReactはJSXというHTMLライクな構文で実装することができる。
このJSXはBabelによるトランスパイル(同言語への変換処理)で通常のJavascriptに変換される。
JSXは変換後のJavascriptでは__jsxなど(古いバージョンではReact.createElementなど)に変換され、ReactのAPIを呼び出す。
JSXの使用は必須でなく、直接ReactのAPIを呼び出して実装しても構わない。
// トランスパイル前
export default function HelloWorld() {
return (
<div className="hoge">
<p>Hello world!</p>
</div>
);
}
// トランスパイル後
export default function HelloWorld() {
return __jsx(
"div",
{ className: "hoge" },
__jsx(
"p",
null,
"Hello world!"
)
);
}
1. 仮想DOMの構築
__jsxなどに渡された引数を元に、Reactは仮想DOMを構築する。
この際、__jsxに渡される引数を確認すると、タグ(文字列またはクラス)と引数リスト、及びそのタグの子要素が__jsxの戻り値として渡されている。
詳細は割愛するが、子要素リストも親からすると引数と変わりないため、仮想DOM構築には タグ
と 引数リスト
が大事であることが分かる。
タグは先述したツリー構造のノードの型として使われる。ツリー構造の一致に使われるノードの型とはタグのことだ。
2. DOMへの反映(初回)
仮想DOMの構築が終わるとその仮想DOMの内容をDOMに反映していく。
仮想DOMとDOMの関係性は1対1とは限らず、1対0もあり得る。1対0の場合は何もDOMに反映されるものがない。
葉にあたる(子を持たない)ノードは消えてもツリー構造を維持できるが、枝にあたる(子を持つ)ノードが消えると子ノードが宙に浮いてしまう。どうなるのか。
答えとしては何の事はなく、単に親がいた位置に子が配置されるだけである。勿論、子は1つと限らないが、全ての子が親の位置に順番通り配置される。
※1・3・4・5のノードは全て1対1の関係にある場合
※仮想DOM側で1の孫ノードに当たる4・5のノードがDOM側でもそのままの位置に配置されているが、DOM側では1のノードの子であり3のノードの兄弟になる。
仮想DOMとDOMの関係性は1対Nもあり得る。複雑な構造のDOMへの反映も理論上は可能だが、そのようなケースを利用者側が意識する必要はほぼ無い。
3. 差分検出
DOMへの反映が完了すると、一旦Reactの仕事は完了であり、ユーザ入力などのイベントを待ち待機する。
イベントが発生し、Reactが管理している状態に変更が発生すると、状態の差分による影響を計算する。
この際、先述したツリー構造比較の条件に基づき、一致判定を簡略化している。
4. DOMへの反映(2回目)
更新が必要な仮想DOMを特定した後は、更新内容を再度DOMに反映していく。
初回と異なり、2回目は既に仮想DOMと紐付けて生成したDOMが存在するため、前回のDOMとの差分を適用する形になる。
仮想DOMは自身に紐付いた生成済みのDOMを記憶しているため、対象のDOMにピンポイントで変更を適用する。
これにより、DOMを一から再生成することなく、目的のDOMを構成できる。
ここまでReactの処理の流れを見てきたが、流れの中でDOMを操作しているのは2と4に限られている。
1と3(および0)はあくまで裏側の計算処理であり、仮想DOMの操作になる。
Reactにおいて、1と3はレンダーフェーズ、2と4はコミットフェーズと呼ばれる。
ところで、DOMとは何だっただろうか。
Document Object Model(DOM、日: ドキュメントオブジェクトモデル[1])は、マークアップがなされたリソース(Document)をリソース要素(Object)の木構造(Model)で表現し操作可能にする仕組み、またそのモデルである。
DOMはあくまでモデル及びそれを操作可能にする仕組みである。
HTMLを表現したモデル・APIであればHTMLが操作できるし、XMLを表現したモデル・APIであればXMLが操作できる。
であれば、DOMを挿げ替えれば様々なものを対象にフロントが構築できるのではないだろうか?
実際それは可能であり、それが可能なようにReactは構成されている。
reactパッケージはレンダーフェーズを担っており、
WebのDOMを対象にしたコミットフェーズはreact-domパッケージが担っている。
AndroidやiOSの画面を対象にしたコミットフェーズはreact-nativeパッケージが担っており、他にも多数のレンダラーが公開されている。
https://github.com/chentsulin/awesome-react-renderer
※reactパッケージは仮想DOMのレンダリングを行い、レンダラーはそれぞれ対象とするターゲットへのレンダリング(Reactにおけるコミット)を行う。
参考資料
https://zenn.dev/convers39/articles/ac0ac2cc2710b9
https://zenn.dev/villa_ak99/articles/2e4194e8367452
https://tnoyan.hatenablog.com/entry/2021/12/07/001836
https://qiita.com/erukiti/items/fb7bcbd9d79696579d06
https://engineering.monstar-lab.com/jp/post/2022/05/27/Is-Virtual-DOM-Outdated/
https://zenn.dev/porokyu32/articles/960d9d6e45533b