Reactで学ぶ技術習得ハンズオン - OSS探索法
初めに
この記事では、
「初見技術のキャッチアップの仕方・過程の一つの道標を残す」
ことに焦点を絞っています。その一例として、
Reactをゼロからキャッチアップする工程を記録しています。
経緯として、周囲から勉強法について聞かれることがあったこと、また、過去に学習の仕方やキャッチアップのコツがわからないという経験をしており、この記事だけでは全てを伝えることは難しいのとまだ勉強中の身ではありますが、それでも少しでも雰囲気が伝わり、この記事を見た誰かの後押しになればいいなと思い作成しました。
なお、技術記事の作成は今回が初めてのため、修正点等あれば教えていただけると嬉しいです。
この記事から学べること
✅ 公式ドキュメント・技術記事から情報をキャッチアップしてまとめる流れが掴める
✅ 実際にキャッチアップを行う際に、どのような項目を大切にしているかが把握できる
✅ 実際に公式ドキュメントだけでは理解できない時にどのような手順でOSSのコードベースを見ていけばいいか詳細かつ段階的にわかる
✅ 初期位置からReactを1日でキャッチアップする流れがわかる、どこまでできそうか一例で把握できる
✅ React内部の要旨や開発時プラクティスの一例として何を考えているか知ることができる
NOTE
- docs:ドキュメントのこと
- 使用技術(エディタ): VSCode
- フック:hooks
目的の確認
ReactのカスタムフックをReact自体をほぼ0からキャッチアップしながら開発に挑戦
今回、実際何をしたか
目的のためにdocsでキャッチアップを試みたが、技術記事などをみる中で自分の理解が本当に問題ないか、という疑問を持ったため、内部仕様を知るためにOSS内部を読み進めた
所要時間
- docs・技術記事 3.0h
- OSSコード内容分析 4.0h
- 技術記事作成 2.0h
- 計 9.0h
初期位置の確認
Reactは何となく把握しているが、公式docsをきちんと読んだ経験はなく、その時その時で何となく書いてた状態
最初にしたこと
Reactの公式docsを読む(スタート何とかみたいなところとクライアントAPI部分) - 2時間程
- 確認したこと
- JSXとは何か
- use系で状態管理をするのにアーキテクチャ的にどうなのか
- 責任分離としての公式のお薦めするプラクティスはどうなっているのか
- Next.jsなどのFWがReactの標準で提供するAPIに加えてどのような内部的な処理を挟んで開発者のUXに貢献しているのか
- 途中でしたこと
- わからない部分の詳細リンクをざっと見て感覚を掴む
- コードベースで一部わかりにくいなと思ったところはGPTに聞いたり、わからない単語はGoogle検索
- 気づいたこと
- Next.jsのApp Routerすごい
- Reactを利用したUIレンダリング(CSR,SSR)におけるレンダリングのタイミングの調整を"use client","use server"+ファイル切り分けなどでブロックのスコープを切り分けて宣言するだけでdom操作を調整(+最適化?)してくれている
- クライアントAPIを利用して開発するコスト対効果は平易な開発状況ではあまり無さそうな印象、開発+管理コスト増加によるデメリットが非常に大きく、そこを自動で管理してくれるNextのApp Routerや他FWを利用するのが良さそう
- Next.jsのApp Routerすごい
意識しておいた方が良さそうなこと
業務的な開発Tips
- 事実ベースで開発+設計する
- 事実をどこから取得するか -> 公式docs、実際に動作しているコードベース(Githubなど)
- 業務で一回ミスした経験 -> GPTや技術記事などの根拠がないAPI情報などは参照せず、きちんと事実ベースで、サードパーティツールの公式docsを見にいくこと
- 視野が狭くならないように他の人の知見を学び、自身でも実践を続け、自身の設計+開発上のプラクティスを更新し続ける
- 汎用的なプラクティスを手札として構築するにはどうすればいいか -> 実はプログラミングというかソフトウェアレイヤーにおいて共通している概念モデルがあるのでそれを経験を積むうちに把握することで諸々の解像度を上げ、作業効率や学習速度を大幅に改善することができる
- 学習を継続すること - 技術記事、テックリファレンス、他のエンジニアから学ぶ・意見を聞く、技術書、実際に手を動かして演習 など - で基礎的な原則を十分に身につける
キャッチアッププロセス
以下のステップに従ってキャッチアップを行いました。
- docs読み込み
- 技術記事参照
- OSSコード調査
docs読み込み・技術記事参照
を見て内容把握
最初に
結論ベースで話せと言われることがあるので最初にまとめておきます
- useEffect: 副作用を扱う(コンポーネントのレンダリング以外の外部とのやり取り・状態の変更)ためのフック
- 副作用の例:データフェッチ、イベントリスナー登録・解除、DOM直接操作、タイマーの設定など
- 利点:useEffect内で副作用を実行することで、Reactのレンダリングフローを乱さずに副作用を管理できる
- useMemo: 特定時点での関数の処理結果をメモ化(キャッシュ)するフック
- useCallback: 関数オブジェクトをメモ化(キャッシュ)するフック
内容まとめ
-
レンダー: コンポーネントが仮想DOMを生成するプロセス。
- 詳細: レンダリングは、Reactコンポーネントが自身の状態やプロパティに基づいて仮想DOMを生成するプロセス。これにより、UIの変更が効率的に管理される。
-
再レンダー: 状態やプロパティの変更により、コンポーネントが再度レンダリングされること。
- 詳細: 再レンダーは、コンポーネントの状態やプロパティが変更された際に、Reactが新しい仮想DOMを生成し、実際のDOMとの比較を行い、必要最小限で部分的にUIおよび関連情報を更新するプロセス。
-
関数定義のキャッシュ: クライアントサイドで関数オブジェクトを保持し、再レンダー時の再生成を避けること。
- 詳細: useCallbackを使用することで、特定の依存関係が変わらない限り関数オブジェクトを再生成しないようにしてパフォーマンス改善を図る。
useMemoとuseCallBackで何が違うのか?
-
useMemo
- 関数を呼び出した結果、戻り値をキャッシュする。特に計算コストが高い結果を、計算処理の元となる値を保持する依存関係(下記例コードで変数a,b)が変わっていない時に再計算する手間を省ける
- 下記コード:computeExpensiveValueの結果を、依存配列[a, b]が変化したときにのみ再計算
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
-
useCallback
- 関数オブジェクトそのものをキャッシュする
const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);
- 下例のように、特に関数がコンポーネントを呼び出す場合でコンポーネントが変わらない時、子コンポーネントを仮にメモ化しても関数がメモ化されてないと、メモ化されているものは残ったまま新しいChildComponentが作成され、メモ化する前より状況が悪化する可能性があるので関数もメモ化する
const ChildComponent = React.memo(({ onClick }) => { console.log('ChildComponent rendered'); return <button onClick={onClick}>Child Button</button>; }); function ParentComponent() { const [count, setCount] = useState(0); const handleClick = useCallback(() => { console.log('Button clicked'); }, []); return ( <div> <button onClick={() => setCount(count + 1)}>Parent Button</button> <ChildComponent onClick={handleClick} /> </div> ); }
useMemoとuseCallbackのプラクティス
- 最小権限の原則と一緒でパフォーマンス改善系のロジック使用もトレードオフを考慮して最小化:
- useMemoやuseCallbackのキャッシュ対象の処理コストが小さいとき、メモ化処理・メモ内容の比較処理・取得時I/Oコストによりキャッシュがパフォーマンス低下につながる
- メモ化によるデータ量が多くなりすぎることでメモリ領域を多く消費し、キャッシュミスが多発するとパフォーマンスが低下
- 依存先、依存配列を必要十分に指定する
- 不必要な依存先を指定: 不必要にキャッシュ内容が再生成され、無駄な処理コスト発生
- そもそもuse系のフックとは? -> 依存先の変数を指定し、その変数の値または参照先の変数のアドレスが変わると内部の処理を実行するReactの標準関数。イベントハンドラが抽象化されて便利になってる感じ
- 純粋関数を使う
- 副作用のない関数を渡す:useMemoやuseCallbackに渡す関数は、副作用のない純粋関数にして、副作用処理の責任は専用のフックuseEffectに委任するReactの指定している暗黙のルールを守る
- 副作用のない純粋関数を使用することで、キャッシュ破棄時の予期しない動作によるバグやセキュリティリスクを避ける
- 子コンポーネントの最適化と組み合わせる
- React.memoと組み合わせる:子コンポーネントがReact.memoでメモ化されている場合、親から渡す関数やオブジェクトをメモ化する
useEffectとuseCallbackの使い分けによるパフォーマンス最適化
余談
effectという単語は、ef:外部+fect:なんかどこかしらに手で動作をするイメージ、なのでそれが定義されているスコープ外へと何かしらの影響を与える処理の責任を担当する、という感じがします
命名からそのソフトなどの開発者の意図を汲むことも大切です
目的
下記の初期コードにてroomIdが変わった時だけuseEffect処理が実行されるようにしたい
初期コード
- createOptionsがメモ化されてないためroomIdが変わらなくても再レンダリング時、外部との接続処理が行われ、useEffectが無駄に発火し、同じデータ・UIが再生成される
function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); function createOptions() { return { serverUrl: 'https://localhost:1234', roomId: roomId }; } useEffect(() => { const options = createOptions(); const connection = createConnection(options); connection.connect(); // ... }, [createOptions]); }
改善1
- useCallbackを使用、createOptions関数をメモ化して、roomIdが変更されたときのみ関数が再生成されて副作用が実行されるようにしました
function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); const createOptions = useCallback(() => { return { serverUrl: 'https://localhost:1234', roomId: roomId }; }, [roomId]); // ✅ useEffect(() => { const options = createOptions(); const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [createOptions]); // ✅ }
改善2
- 改善1においてuseEffectがcreateOptionsに依存する関係を定義しなくても、依存配列にroomIdを直接指定することで、副作用がroomIdの変更時のみ実行されるようにリファクタリングしました
function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); useEffect(() => { const options = { serverUrl: 'https://localhost:1234', roomId: roomId }; const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [roomId]); // ✅ }
useEffectとuseCallback比較まとめ
より良いプラクティスが存在すると思いますが、この程度で一応整理できたかなと思います。
useEffectとuseCallbackを使用して実際にコードを書く練習をしました。
docs・技術記事キャッチアップまとめ
docs・技術記事からのキャッチアップのまとめとしてはここで終わりにします。
ここまでで3時間ほどでした。
Reactのdocsがとても綺麗にまとまっていて勉強しやすかったです、また、同時に以下の技術記事からも学ばせて頂きました。とてもわかりやすく勉強になりました。ありがとうございました。
ここでですが、一番下の記事を拝見した際に、自分の認識が間違ってるかもしれないと感じたため、内部をきちんと把握するためにdocsを改めて見に行きました。が、実際docsは、Reactなどを利用する開発者が、内部実装の詳細が抽象化されていてもAPIを利用できるようにするためのdocsなので実際のossを見に行きました。
OSSコード調査 - 事実ベースを担保するためにOSSコード本体を見に行こう(ステップバイステップ)
目的
use系のフックの状態管理の内部ロジックがどうなっているのか、そして高速化のロジックも肌感覚(?)的に知りたい
下記まとめ全体の方針
著作権やライセンスに配慮し、OSSのコードの詳細がわからない範囲でシンボル等の指示をし、理解した内容やプロセスを自分の言葉で要約・解説しています
まず初めにプロジェクト内を調べた結果
にuseMemoの実装があるっぽいので見てみると、useMemoの検索にヒットする部分が多いです。
ここで、Dispatcher - HooksDispatherOnRerender変数のuseMemo変数に関連づけられているのがupdateMemoというシンボルでした。
ここで変数名的に内部のフック関連の情報を管理するのに利用するディスパッチャ(一般的に有名なデザインパターンだと思います)であり、アップデート処理に利用するものというのがわかりました。
次に、アクションタイプによって詳細や実装方針は大体同じ感じのはずなので特殊ケースupdateMemoの詳細を見に行けばいいかなという感じになります。よって、updateMemoを見てみるとnextDepsやprevState、そしてhookとしてupdateWorkInProgressHook()が処理として切り分けられています。なので、updateWorkInProgressHookを見に行きます。すると、内部にcurrentlyRenderingFiberというシンボルがあります、fiberというと繊維というイメージがあり、ここから何かと何かを繋ぐ存在かなというのが推測できます。ここで一旦まとめておくと、内部実装的にidのようなものは振られていないので、React内部ではコンポーネントや状態フック1つ1つに一意なidのようなプリミティブな値を振っていなさそうだな、という予想がつきます。
またここで、Hook型が気になったので型定義を見にいくと(同一ファイルで管理してるんですね)、状態を保持しておくためのqueueと(これがコンポーネントに関連づけられた状態を管理するものっぽいです、Reactでは状態を内部で管理している連結リストのようなオブジェクトの参照順序で一意性を担保してるみたい)あとはbaseなのでデフォルト値、memorizedStateなので現時点で参照してる値を保持するフィールド、そして、nextメンバに関してはHook型なので連結リストみたいな感じのやつを採用しているのはほぼ確定かなと思います。
さて、話に戻り、
fiberというシンボルが気になったので定義を見に行きます。すごくわかりやすいですが、
にあるようです。ファイル名的にたぶんそうかな(?)みたいな感じです。
上から見ていくと
export type {Fiber};
があるので型定義情報を確認します。ファイル行数が1,000行もないので少なくてありがたい...と思いつつ見ていくと、FiberNodeの管理メソッドやメンバ定義などの処理を管理しているファイルみたいで、どこかにステート管理するFiberNodeとReactコンポーネントの接続情報を管理するところがあると思うので、当てがはずれたみたいです。他にもパラパラファイルを見て行きましたが、1つ1つのファイル行数が多いので手間です。
よってシンボル検索をかけたいと思います。
currentlyRenderingFiber
を用いて検索をするとReactFiberHooks.js・ReactFiberNewContext.jsファイルでシンボル名がヒットしました。
ただここで、先ほど見ていたReactFiberHooks.jsにおいて
currentlyRenderingFiber = workInProgress;
という行を見つけたので、workInProgressを見てみるとFiber型が割り当てられていてrenderWithHooksなどの関数内部で使用されているみたいです。
renderWithHooksという関数が気になったため、参照先を見てみると、ReactFiberBeginWork.jsファイル内で使用されているみたいです。
上の方から見てみて、reconcileChildrenという関数がありますが、これはおそらく仮想domか何かのコンポーネントを比較して効率的にdomを更新する処理部分を提供してそう、というのが命名的に想定されます。
ここで、現在のファイルは
です。
reconcileChildren内部では
- mountChildFibers
- reconcileChildFibers
の2つの関数があり、マウントの方に関しては「ファイルマウント」と同じ要領で、異なるインスタンス同士を何かしらのインターフェースで情報・アクセス的に接続を図る、という感じなので、初期化っぽいことしてるんだなー、という感じです。
次に、reconcileChildFibersの方を見た方がマウントの方の基本実装も推測できそうなので見てみます。
引数にLanesというのがありますがこれは、レンダリングの優先度・種類を示すビットフィールド、らしいです。PC内部のレジスタ・Assemblyを使用した開発時の話になりますが、レジスタの状態を表現する際のビットと同じ要領ですね。
色々と再帰的に見に行ってみると、ReactFiber.jsにcreateFiberImplObjectという関数があり、これはFiberを作成する関数っぽいです、内部で、enableObjectFiberというのがあり気になったのでインポート文を見てみるとフラグみたいです。こちらの探索は一旦ここまでにしたいと思います。
また、ReactChildFiber.jsファイルにて、createChildReconciler関数があり、内部を見てみるとポインタを用いて子要素を削除しているdeleteRemainingChildren関数やmapRemainingChildren関数があります。ここで、mapRemainingChildren関数はmap構造を利用してFiberノードへのアクセスを高速化しているようです、ここでの目的はReactコンポーネントとFiber、use系が扱うステートとの連携部分を見つけることなので関係なさそうですが、こういった改善部分の実装にも目を向けると全体の実装ロジックの方針が見えてくることもあります。方針として一貫しているところはやはりプロという感じがしてすごいなと思います。
さらに探索を続けると、useFiber関数がありこれもmapRemainingChildrenと同種です。
ここで、
- fiber系のシンボルから探しても見つからなそうなこと
- 今回のReactコンポーネントとuse系の状態管理は、
- Reactコンポーネント
- Fiber
- use系が扱うステート
の3つから構成されていそうだということがわかったので探索の視点を変えて、Reactコンポーネントの命名で使われていそうなシンボルelementが見てる中で見つかったので検索に使用します。
見てみるとupdateElement関数が見つかりました。ビンゴです!
関数の引数に、FiberとReactElementがあり、Reactコンポーネントの背景では、Fiberを通じてuse系が使用するステートにアクセスする連結リスト構造になっていることがわかっているのでたぶんこの関数かな(ビンゴというのは早いかも)という感じです、見ていくと、createFiberFromElementという関数があり、さらに定義を辿ると、ReactFiber.jsのcreateFiberFromTypeAndPropsという関数がありました。この関数の内部実装を見ていくと、引数部分にまずtypeがありそれをif文の判定文内で使用しているので分岐処理を行う際の判断指標となるデータであることがわかります、そして、各分岐処理内容を見ていくとfiberTag変数にref,lazy,contextという状態データを格納したり(それぞれ種類別に適切な処理が違うので分岐している感じだと思います)、createFiberFromTracingMarkerといった適切な形式を持ったFiberを作成する処理を実装していることがわかります。
なお、createFiberFromElementのreferenceについては見ればすぐわかるので省略です。
OSS内部仕様調査まとめ
ここまでの内容から、スコープやタグなどでFiberを分類したりすることがわかりました。
調べる中でわかった内容を下記にまとめておきます。
- React: キーとスロットを組み合わせて、最小限の更新でUIを再描画
- Fiberノード: Reactが内部でUIを効率的に保持・更新するための連結リストを利用したステータス管理データ構造、各コンポーネントの情報を保持、仮想DOMを効率的に管理・更新するデータ構造体
- タイプに応じて、適切なFiberのタグ(fiberTag)を決定
- 必要に応じて特殊なFiber(フラグメントやサスペンスなど)を作成して処理ロジックを細分化
- レンダリング作業を小さな単位に分割し、非同期的に処理することで、ブラウザのメインスレッドをブロックせず、ユーザーの操作に素早く応答可能
- Hooksを使用する関数コンポーネントの場合、Hooksの状態もmemoizedStateに格納
- 副作用はコミットフェーズでまとめて実行
- 各作業に優先度を設定し、緊急性の高い更新を優先的に処理することでUXの向上を可能にしている
- コンポーネントの種類(関数・クラス・ホストコンポーネントなど)に応じて、Fiberを生成
- Fiber内部構造・役割
内部:- type: コンポーネントのタイプ(関数、クラス、文字列など)
- key: リスト内で要素を一意に識別するためのキー
- child: 最初の子Fiberへの参照
- sibling: 次の兄弟Fiberへの参照
- return: 親Fiberへの参照
- Fiberはツリー構造を形成し、Reactコンポーネントがレンダリングされる際そのインスタンスの情報を保持する役割を果たす
- 調停(Reconciliation): 現在のFiberツリーと新しい要素(仮想DOM)を比較、差分を検出・適用・必要に応じて作成する処理、メインとなる関数はreconcileChildren、reconcileChildFibersあたり
- createFiberFromTypeAndProps: 与えられたコンポーネントタイプ(type)とプロパティ(props)から新しいFiberを作成
- 全体の概要としての処理フロー・責任分離確認
- Reactコンポーネント: renderメソッド・関数コンポーネントとして、仮想DOMを返す
- 仮想DOM: 実際のDOMの軽量なコピー、UIの状態を表現
- 調停プロセスを通じて、新旧の仮想DOMを比較し、差分検出(Fiberアーキテクチャモデルで効率化)
- レンダラー(react-domなど): 検出された差分を実際のDOMに適用、最小限のDOM操作により、パフォーマンスを最適化しつつ、UIを更新
用語集
- Fiberノード: Reactが内部でUIを効率的に保持・更新するための連結リストを利用したステータス管理データ構造。各コンポーネントの情報を保持し、仮想DOM(Virtual DOM)の効率的な管理・更新を可能にする。
- 調停(Reconciliation): 現在のFiberツリーと新しい要素(仮想DOM)を比較し、差分を検出・適用・必要に応じて新しいFiberを作成するプロセス。
- 仮想DOM(Virtual DOM): 実際のDOMの軽量なコピーで、UIの状態を表現する。
- メモ化(Memoization): 計算結果をキャッシュし、同じ入力に対して再計算を避ける手法。
- React.memo: 高階コンポーネントで、プロパティが変更されない限り再レンダリングを防ぐ機能。
- useCallback: 関数オブジェクトをメモ化するフック。再レンダー間で関数定義をキャッシュし、不要な再生成を防ぐ。
- useMemo: 計算結果をメモ化するフック。依存関係が変更されない限り、計算を再実行しない。
- useEffect: 副作用を扱うフック。コンポーネントのレンダリング以外の外部とのやり取りや状態の変更を管理する。
終わりに
今回、Reactについての理解を深めるために、技術記事・公式ドキュメント・OSSコードを調べ、学ぶ過程を記事にしました。次回の記事では、カスタムフックの作成を詳細設計を考えながらステップバイステップでまとめる予定です。
Reactはまだわからないことも多いですが、今後も勉強を続けていきます。長文になってしまいましたが、お読みいただきありがとうございました。
追記:下記の記事を後から知って、とてもみやすくまとまっていました。引用失礼致します。
Discussion