React ソースコード読み - ReactDOM.render
はじめに
ReactとはUIを記述するためのjsライブラリです。
Reactは仮想DOMと呼ばれる内部オブジェクトによってアプリケーションの状態を保持します。
アプリケーションのデータが変更された時、Reactは仮想DOMを丸ごと作り直して、 必要な箇所のみ実際のDOMを更新します(この処理は差分検出処理と呼ばれています)。
そのため開発者は自分でDOMを操作することなく、宣言的にUIを記述することができます。
この差分検出処理などの内部処理がソースコード上でどう実装されているのか、気になって調べたものをブログにまとめていきます。
この記事では ReactDOM.render
の実装を追っていきます。
バージョン
この記事で扱うReactのバージョンは16.13.1です。
参考文献
この記事を書くにあたって、ソースコードの他に以下の資料を参考にしました。
- https://ja.reactjs.org/docs/getting-started.html
- https://github.com/acdlite/react-fiber-architecture
- https://blog.ag-grid.com/inside-fiber-an-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/
- https://medium.com/react-in-depth/in-depth-explanation-of-state-and-props-update-in-react-51ab94563311
- https://blog.logrocket.com/deep-dive-into-react-fiber-internals/
なお、以下の内容は独学で調べたもので、またかなり端折っているので正確でない部分が多々あると思います。間違いなどありましたらご指摘いただければ幸いです。
概要
ReactDOM.render
は以下のように用いられ、初期表示処理のエントリーポイントとなります。
ReactDOM.render( <App />, document.getElementById('root') );
ReactDOM.render
は以下のように定義されます。
ReactDOM.render(element, container[, callback])
1個目の引数でコンポーネント、2個目の引数でDOMノードを受け取ります。
このメソッドが呼ばれると、Reactは以下のような流れでコンポーネントからDOMを生成します。
React element -> Fiber node -> DOM node
DOMノードの生成が終わると、それをcontainerに挿入します。
React element
React elementはコンポーネントを表す内部オブジェクトで、通常jsxから生成されます。
Reactでは普通jsxを使ってコンポーネントを記述しますが、jsxはコンパイル時に React.createElement
の呼び出しに変換されます。
React.createElement
は React element
を返す関数です。
React elementはプレーンなjsオブジェクトで、コンポーネントのタグ名やpropsを保持します。
{ $$typeof: REACT_ELEMENT_TYPE, type: type, key: key, ref: ref, props: props, _owner: owner, }
これによってReactは(DOMを触ることなく)アプリケーションを記述します。
Fiber
React elementはrender時にただちにDOMノードへと変換されるのではなく、 Fiberノードと呼ばれる別の内部オブジェクトに変換されます。
FiberノードはReact elementと同様にコンポーネントを表すjsオブジェクトですが、もっと多くの情報を含んでいます。
- 子・兄弟・親ノードへの参照
- すでにrenderされたprops
- これからrenderされるprops
- 後で実行する副作用
以下が Fiberノードの定義 です。
function FiberNode( tag: WorkTag, pendingProps: mixed, key: null | string, mode: TypeOfMode, ) { // Instance this.tag = tag; this.key = key; this.elementType = null; this.type = null; this.stateNode = null; // Fiber this.return = null; this.child = null; this.sibling = null; this.index = 0; this.ref = null; this.pendingProps = pendingProps; this.memoizedProps = null; this.updateQueue = null; this.memoizedState = null; this.dependencies = null; this.mode = mode; // Effects this.effectTag = NoEffect; this.nextEffect = null; this.firstEffect = null; this.lastEffect = null; this.expirationTime = NoWork; this.childExpirationTime = NoWork; this.alternate = null; // ... }
コンポーネントと同様に、Fiberノードは木を構成します(ここではFiberツリーと呼びます)。
Fiberの設計資料の中で、Fiberは「コンポーネントのインスタンス、また仮想的なスタックフレーム」に対応すると説明されています。
コンポーネントのインスタンスはともかく、スタックフレームというのはどこから出てきたのでしょうか?
ここからは推測混じりになってしまうのですが、Fiberによる実装を導入する前、Reactはコンポーネントのツリーを再帰的、かつ同期的に走査していたというのが背景にあると思います。
再帰的に関数を呼び出すと、スタックフレームがコールスタックに積まれていきます。jsはスタックが空でないとイベントキューを処理しないため、複雑なツリーを処理するとUIがブロックされてしまい、ユーザー体験がよくありませんでした。
一方、Fiberでは木はwhileループによって走査され、各コンポーネントのインスタンスはヒープに存在します。また、Fiberでは優先度の低い処理を中断して後回しにするという風に非同期的に処理を行うことができます。
このようなFiberと以前の実装の比較が念頭にあって、スタックにないけどスタックフレームと同様に処理の一単位になっている、という意味を込めて「仮想的なスタックフレーム」と言っているのだと考えています。
なお、この辺りの背景についてはこの記事がとても参考になります。
実装に移ると、Reactはrender時に二種類のFiberツリーを保持します。
一つ目がcurrentと呼ばれるFiberツリーで、現在のアプリケーションの状態を表します。
二つ目がworkInProgressと呼ばれるFiberツリーで、これから描画されるアプリケーションの状態を表します。
render時に、Reactは基本的に全てのコンポーネントに対してFiberノードを生成して、workInProgressを一から作り直します(この処理はDOMを作り直すのに比べて軽量だとされています)。
そして、currentとworkInProgressを比較して、差分がある(DOMを更新するという副作用が必要である)部分についてのみ、副作用を実行します。
render
上述したように、render時にReactはFiberツリーの生成とDOMの更新を行います。
この処理は以下の2ステップに分けて行われます。
- renderフェーズ
- 新しい状態を反映したFiberツリー(workInProgress)を生成します。
- commitフェーズ
- workInProgressの中で、副作用が必要なノードについてのみ、副作用を実行します。
ソースコード
サンプルアプリケーションを見ながら、ソースコード上の処理を追っていきます。
サンプルアプリケーション
以下のカウンターアプリケーションを例として処理を追っていきます。
import React, { useState } from 'react'; import ReactDOM from 'react-dom'; function App() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); } return ( <div> <p>You clicked {count} times</p> <button onClick={handleClick}>Click me</button> </div> ); } ReactDOM.render( <App />, document.getElementById('root') );
App └──div ├──p └──button
ReactDOM.render
初期表示時の処理を追っていきます。
1. renderフェーズ
Fiberツリーを生成し、DOMノードに紐付けます(これをマウントと呼びます)。
legacyRenderSubtreeIntoContainer
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(...) // このfiberRootは後でperformSyncWorkOnRootに渡されます。 fiberRoot = root._internalRoot // ... updateContainer(children, fiberRoot, parentComponent, callback);
- サンプルアプリケーションの場合、containerはid=rootのDOMノードです。
なので、マウントされたツリーは以下のようにしてブラウザのコンソールから眺めることができます。
document.getElementById('root')._reactRootContainer
legacyCreateRootFromDOMContainer
Fiberツリーのルートノードを作ります。
const uninitializedFiber = createHostRootFiber(tag); // ... initializeUpdateQueue(uninitializedFiber);
initializeUpdateQueue
- updateQueueを初期化します。
- updateQueueはupdateオブジェクトのキューで、updateオブジェクトには後でrenderするReact elementが入ります。
- この時点では、updateQueueのフィールドはすべてnullになっています。
- updateQueueを初期化します。
-
const update = createUpdate(...) // renderに渡されたelementをupdate.payloadに代入します。 update.payload = {element}; // ... enqueueUpdate(...) scheduleWork(...)
-
// current.updateQueue.shared.pending.payloadの中に、renderに渡されたelementが入ります。 // これが後のprocessUpdateQueueで処理されます。 current.updateQueue.shared.pending = update
-
これは
updateContainer
内のscheduleWork
の実体です。// このrootはlegacyCreateRootFromDOMContainerで生成されたfiberRoot.currentです。 performSyncWorkOnRoot(root)
-
prepareFreshStack(...) // ... do { try { workLoopSync(); break; } catch (thrownValue) { handleError(root, thrownValue); } } while (true) // ... // このfinishedWorkが後のcommitフェーズでDOMに挿入されます。 // current.alternateはworkInProgressを参照しています。 root.finishedWork = (root.current.alternate: any);
-
workInProgressを生成します。
// workInProgressはモジュール内のトップレベルスコープに属します。 workInProgress = createWorkInProgress(...)
-
currentを元に、workInProgressを作成します。
workInProgress = createFiber(...) workInProgress.updateQueue = current.updateQueue // currentとworkInProgressはalternateによってお互いを参照します。 current.alternate = workInProgress workInProgress.alternate = current // ... return workInProgress
-
while (workInProgress !== null) { workInProgress = performUnitOfWork(workInProgress); }
- elementツリーの各ノードに対して繰り返しperformUnitOfWorkを実行します。
- performUnitOfWorkはworkInProgressの子を返します。
- このようにReactはwhileループによって木を走査しています。
- 概要のところでチラッと触れた部分です。
- elementツリーの各ノードに対して繰り返しperformUnitOfWorkを実行します。
-
// このnextはunitOfWorkの子です。 next = beginWork(current, unitOfWork, renderExpirationTime); // ... if (next === null) { next = completeUnitOfWork(unitOfWork); } // ... return next
- workInProgressに対してbeginWorkを実行します。
- beginWorkは、渡されたコンポーネントについて必要な処理を行った後、そのコンポーネントの子を返します。
葉まで到達した場合は、completeUnitOfWork(後述)を実行します。
beginWork
(root)- 最初にrootについてbeginWorkを実行します。
beginWorkの中には長いswitch文があって、workInProgressの種類ごとに異なるupdate関数を実行します。
ここで実行されるのはupdateHostRootです。
// ... return updateHostRoot(...)
-
processUpdateQueue(...) //... reconcileChildren(..., workInProgress, ...) return workInProgress.child
-
enqueueUpdate内で生成したupdateオブジェクトを処理していきます。
baseQueue = queue.shared.pending
baseQueue内のupdateを処理します。
- ただし、優先度の低いupdateはスキップします。
updateを元にして、stateを更新します。
newState = getStateFromUpdate(..., update, ...)
-
- ここでは、updateContainer内でpayloadに代入された
{element}
をそのまま返しています。
- ここでは、updateContainer内でpayloadに代入された
workInProgressに更新したstateを代入します。
workInProgress.memoizedState = newState
-
- React elementからfiberを生成し、workInProgress.childに代入します。
- このelementは、元々はupdateHostRootの中で代入されたworkInProgress.memoizedState.elementです。
この際にfiberのeffectTagに副作用をセットします。
- effectTagとは後で実行する副作用の種類を表します。
これによって、後のcommitフェーズでDOMに挿入される(副作用を持つ)ようになります。
workInProgress.child = reconcileChildFibers(workInProgress, ...)
-
-
-
// このpendingPropsが次のbeginWorkで処理される。 // elementは、もともとworkInProgress.memoizedState.elementだったもの。 pendingProps = element.props
-
-
// 後で実行する副作用のタグをセットします。 newFiber.effectTag = Placement;
-
- React elementからfiberを生成し、workInProgress.childに代入します。
beginWork
(App)rootのworkInProgress.childはAppなので、次はAppに対してbeginWorkが実行されます。
// ... // 引数にworkInProgress.typeを渡す(typeにはAppが入っている) return mountIndeterminateComponent(...)
-
クラスコンポーネントまたは関数コンポーネントからReact elementを生成し、workInProgress.childに代入します。
// このpendingPropsは前のbeginWorkのなかで設定されたもの。 const props = workInProgress.pendingProps; // ... // 引数に渡されたComponentとpropsからreact elementを作る。 value = renderWithHooks(..., Component, props, ...) // ... // react elementからfiberを生成し、workInProgress.childに代入する。 reconcileChildren(..., value, ...) // ... return workInProgress.child;
-
// childrenにはreact elementが入る children = Component(props, ...) ... return children
beginWork
(div)Appの次はdivに対して実行されます。
div以降のノードに対するbeginWorkは省略します。
// ... return updateHostComponent(...)
-
// 今までと同様に、workInProgress.childを更新する。 reconcileChildren(..., workInProgress, ...) return workInProgress.child
-
completeWork
もしすでにDOMノードがあれば、差分だけ更新します。
- 初期表示時なので、ここの処理は実行されません。
-
- 新旧のpropsを比較し、差分があればworkInProgressのupdateQueueに追加します。
これは導入で紹介した差分検出処理の一つだと思います。
const updatePayload = prepareUpdate(...); workInProgress.updateQueue = (updatePayload: any); // ... markUpdate(workInProgress);
-
diffProperties
- 新旧のpropsの差分を返す。
- oldPropsはもともとcurrent.memoizedPropsだったもの。
- newPropsはもともとworkInProgress.pendingPropsだったもの。
markUpdate
effectTagをセットします。
workInProgress.effectTag |= Update;
すでにDOMノードがなければ、新しく生成します。
初期表示時なので、こっちの処理が実行されます。
// DOMノードを生成 instance = createInstance(...) // ... // workInProgressに代入 workInProgress.stateNode = instance
-
- ブラウザ・ネイティブなどの実行環境(ホストと呼ばれます)によって異なる関数が渡されます。以下はブラウザ(ReactDOM)の実装です。
createElement
document.createElement
を実行する。- これは見慣れたDOMのAPIです。
2. commitフェーズ
renderフェーズで作られたworkInProgressを元に、副作用を実行していきます。
この時、FiberノードのnextEffectを辿ることで、副作用が必要なFiberノードのみを走査します。
finishSyncRender
commitRoot
-
// root.finishedWorkはrenderフェーズのperformSyncWorkOnRootで代入されたものです。 const finishedWork = root.finishedWork; // ... let firstEffect; firstEffect = finishedWork; // ... nextEffect = firstEffect;
-
- effectTagの種類ごとに異なる関数を呼び出します。
ここでは以下の関数が呼ばれます。
commitPlacement(nextEffect);
-
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
insertOrAppendPlacementNodeIntoContainer
const stateNode = isHost ? node.stateNode : node.stateNode.instance; appendChildToContainer(parent, stateNode);
-
ここではじめてDOMノードが挿入されます。
parentNode = container; // このchildはroot.finishedWork.stateNodeだったものです。 parentNode.appendChild(child);
state変更時
せっかくなので、ボタンをクリックしてstateが変更した時の処理も追っていきます(力尽きたので、流れだけ、、、)
カウンターのボタンをクリックすると、handleClickが実行されて、countがインクリメントされます。
const handleClick = () => { setCount(count + 1); }
更新されたcountは、 dispatchAction
にactionという引数として渡されます。
dispatchActionは渡されたactionを元にupdateオブジェクトを生成し、引数の queue.pending
に代入します。
var update = {...}; queue.pending = update;
なお、このqueueは最初に useState
を実行した際に、 mountState
の中でdispatchActionに対してbindされたものです。
dispatchActionの最後では scheduleUpdateOnFiber
が呼ばれて、いろいろあって最終的にperformSyncWorkOnLoopが呼ばれます。
この関数はReactDOM.render内で呼ばれた時と同じように、rootからworkInProgressを更新していきます。
rootを処理してAppの番が来ると、 updateFunctionComponent
が呼ばれて、Appコンポーネントの関数が実行されます。
Appコンポーネントの中では、まず useState
を実行します。
const [count, setCount] = useState(0);
useState
を実行すると、なんやかんやあって updateReducer
が呼ばれます。
// この中にはdispatchActionで生成されたupdateオブジェクトが入ります。 let pendingQueue = queue.pending;
この後、初期表示時のprocessUpdateQueueと同じ要領でupdateオブジェクトから新しいstateを作って更新します。
// newStateにはインクリメントされた値が入ります。 hook.memoizedState = newState; // ... // 以下がuseStateの返り値になります。 // hook.memoizedStateがcountに、dispatchがsetCountに代入されます。 return [hook.memoizedState, dispatch];
あとは、新しくなったcountを元にReact elementを返すだけです。
// 以下のcountはインクリメントされた値になっています const handleClick = () => { setCount(count + 1); } return ( <div> <p>You clicked {count} times</p> <button onClick={handleClick}>Click me</button> </div> );
以上でAppコンポーネントの処理は終わりで、残りの流れは初期表示の際と同じです。
それぞれのFiberノードに対してcompleteWorkが呼ばれます。
テキストの更新があったpタグについては、 updateHostText
が呼ばれます。
この関数は以下のようにテキストが等しいか判定し、差分がある場合 markUpdate
を呼びます。
if (oldText !== newText) { workInProgress.stateNode = createTextInstance(...); markUpdate(workInProgress); }
初期表示のところでみたように、 markUpdate
が呼ばれるとeffectTagがセットされます。
workInProgress.effectTag |= Update;
これによって、この後のcommitフェーズでDOMが更新されます。