ReactのFiberとHookのつながりを理解するために実装を読んでみた
FiberとHookがどうつながっているのかを理解するために、React v19.3.1の実装を読んでみた。
題材にするのは、ボタンを押すと数が増えるCounterを2つ並べたAppだ。
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((n) => n + 1)}>{count}</button>;
}
function App() {
return (
<>
<Counter />
<Counter />
</>
);
}
2つのCounterはそれぞれ自分のcountを持っていて、2つ目のCounterのボタンを押すと、2つ目のcountだけが1になる。知りたかったのは、このcountの値とsetCountがReactの中のどこにあるのかと、レンダー中のuseStateがどうやってそこにたどり着くのかだ。
Fiberの中身は、React v19.3.1でこのAppをNode.jsとjsdomに描画して、実際のオブジェクトを読んで確かめた。
Fiberのツリー
Reactは、描画するツリーの要素1つにつき、Fiberというオブジェクトを作る。要素にはCounterのようなコンポーネントだけでなく、<button>のようなDOM要素も含む。Fiberは関数ごとではなく、ツリーに置いた要素ごとにできる。Counterの関数は1つでも、題材のAppは<Counter />を2か所に置いているので、CounterのFiberは2つになる。以下の図では、1つ目をCounter #1、2つ目をCounter #2と書く。
Fiber同士は、親を指すreturn、最初の子を指すchild、次の兄弟を指すsiblingでつながってツリーになる。Fiberの型の定義から抜き出すと次のようになっている。
return: Fiber | null,
// ...
child: Fiber | null,
sibling: Fiber | null,
題材のAppをマウントした後のFiberをたどると、次のようになっていた。
HostRoot
child -> App
App
child -> Counter #1
Counter #1
child -> button
sibling -> Counter #2
Counter #2
child -> button
いちばん上のHostRootは、createRootに渡したコンテナ(<div id="root">)に対応するFiberだ。Counterのreturnをたどると、Appを通ってHostRootに着き、HostRootのreturnはnullだった。
Appは<>...</>(Fragment)を返しているが、図にFragmentのFiberはなく、Appの子は直接Counterになっている。子を作る処理は、キーのないいちばん外側のFragmentを、中身の配列が直接返されたものとして扱う(reconcileChildFibersImpl)。
この図は、マウントの直後にたどったものだ。Reactはstateが更新されるたびに、処理するFiberごとに対になるもう1つのFiberを用意し、2つを交互に使うが、マウントの直後に読むと、CounterのFiberにはまだ対になるFiberはなかった。この記事では、マウントで作られるFiberだけを見ていく。
マウント時に作られるFiber
Fiberはマウントのときに作られる。createRoot(container).render(<App />)でAppを描画すると、ReactはHostRootから順にFiberを処理していく。コンポーネントのFiberではそのコンポーネントを呼び、返ってきた要素から子のFiberを作って(reconcileChildren)、子へ進む。子がなければ兄弟へ、兄弟もなければ親へ戻る(performUnitOfWork、completeUnitOfWork)。このように、Fiberを順に処理しながらコンポーネントを呼んでいく段階をレンダーと呼ぶ。レンダーで用意したものを実際のDOMに反映する段階は、コミットと呼ぶ。
題材のAppなら、レンダーで次の順に処理しながらFiberを作っていく。
HostRoot
create App fiber
App
call App()
create Counter #1, Counter #2 fibers
Counter #1
call Counter()
create button fiber
button
no child fiber
document.createElement("button")
Counter #2
call Counter()
create button fiber
button
no child fiber
document.createElement("button")
buttonのようなDOM要素のFiberには、呼ぶ関数がない。{count}のような文字だけの子はFiberにせず、<button>の中身として扱うので(updateHostComponent)、buttonのFiberには子がない。
図のdocument.createElement("button")は、Fiberではなく、ブラウザのDOMの<button>を作る処理だ。マウントでは、buttonのFiberの処理を終えるとき(completeWork)にcreateInstanceが呼ばれる。createInstanceは、コンテナのドキュメントのcreateElement(ownerDocument.createElement(type))で実際のDOMの要素を作る(createInstance)。作った時点ではまだドキュメントにはつながっておらず、コンテナにつなぐのはコミットのときだ(insertOrAppendPlacementNodeIntoContainer)。
作った要素は、buttonのFiberのstateNodeに入る。stateNodeは、Fiberに対応する実体を指すフィールドで、題材のAppを読むと、buttonのFiberでは実際のDOMの<button>、関数コンポーネントのAppとCounterのFiberではnullだった。
Hookとqueue
関数コンポーネントのCounterのFiberは、stateNodeがnullだった。countの値はそこではなく、FiberのmemoizedStateから、Hookというオブジェクトを1つたどった先にある。
ここでややこしいのは、Hookという言葉が2つの意味で使われていることだ。1つはuseStateやuseEffectのような、コンポーネントの中で呼ぶ関数のこと。もう1つは、そうした関数を1回呼ぶたびにReactが作る、内部のオブジェクトのことだ。この記事では、後者をHookと呼ぶ。Hookの型は次の5つのフィールドだけになっている。
export type Hook = {
memoizedState: any,
baseState: any,
baseQueue: Update<any, any> | null,
queue: any,
next: Hook | null,
};
Counter #1のFiberを読むと、中身は次のようになっていた。
Counter #1 fiber
memoizedState -> Hook
Hook
memoizedState -> 0
next -> null
queue -> queue
queue
dispatch -> setCount
memoizedStateという名前が2回出てくるが、FiberのmemoizedStateは1つ目のHookを指し、HookのmemoizedStateがstateの値(countの0)になっている。useStateを2回呼ぶコンポーネントなら、2つ目のHookは1つ目のHookのnextの先につながり、Hookは呼んだ順にnextでつながったリスト(Hook list)になる。
Hookのqueueは、そのHookの更新キューだ。setCountを呼ぶと、更新を表すUpdateが作られ、次のレンダーを始めるときにこのqueueのpendingにつながって適用される(finishQueueingConcurrentUpdates)。pendingは、まだ適用していないUpdateのうち最後のものを指していて、Update同士はnextで輪になってつながっている。
setCountそのものは、queueのdispatchに入っている。実際に読むと、関数の名前はbound dispatchSetStateだった。名前のboundは、JavaScriptのbindで作った関数に付く。マウントのときに作ったsetCountをqueue.dispatchに入れておき、2回目以降のレンダーではuseStateがここから同じsetCountを返す(updateReducerImpl)。そのためsetCountは、レンダーをまたいで同じ関数のままになる。
HookのbaseStateとbaseQueueは、Transitionで飛ばした更新を後で適用し直すためのもので、この記事の例では使われない。
レンダー中のcurrentlyRenderingFiber
countの値はCounterのFiberのmemoizedStateの先にあり、Fiberは2つのCounterにそれぞれある。でもuseStateはただの関数で、Counterの中でuseState(0)と呼んでも、useStateに渡るのは0だけだ。JavaScriptの関数は、普通は自分がどこから呼ばれたのかを知らないので、仮に自分でuseStateを書くとしたら、関数の中からはCounter #1とCounter #2のどちらの中で呼ばれたのかを区別できない。
function useState(initialState) {
// initialState には 0 が渡るだけで、
// どの Counter の中で呼ばれたのかは分からない
}
それでも実際のuseStateは、Counter #1の中ならCounter #1のFiberの、Counter #2の中ならCounter #2のFiberのmemoizedStateを読み書きしている。
Reactは、コンポーネントを呼ぶ前に、今レンダーしているFiberをcurrentlyRenderingFiberという変数に入れている。renderWithHooksから、開発ビルド用の分岐を省いて抜き出すと次のようになっている。
currentlyRenderingFiber = workInProgress;
// ...
let children = Component(props, secondArg);
ReactFiberHooks.js — renderWithHooks
workInProgressは、今レンダーしているFiberだ。ComponentはCounterのような関数コンポーネントそのもので、Component(props, secondArg)が、レンダーでCounterを呼ぶところになる(secondArgは、この例では使わない2つ目の引数)。Counterが返すJSXは{ type: "button", props: { ... } }のようなオブジェクトで、Reactはこの戻り値(children)から、「マウント時に作られるFiber」の節で見たように子のFiberを作る。
Counterの中で呼んだuseStateは、currentlyRenderingFiberのHook listを読み書きする。useStateが実際にどの関数を呼ぶかは、Reactがコンポーネントを呼ぶ前に差し替えておくオブジェクト(ReactSharedInternals.H)で決まる(renderWithHooks)。前回のレンダーのFiberがない(初めてのレンダー)か、あってもHookがなければマウント用のmountState、それ以外は更新用のupdateStateになる。マウント中のmountStateは、作ったHookを次のようにつなぐ。
if (workInProgressHook === null) {
// This is the first hook in the list
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
ReactFiberHooks.js — mountWorkInProgressHook
workInProgressHookは、このコンポーネントで直前に作ったHookを入れておく変数だ。1つ目のHookはcurrentlyRenderingFiberのmemoizedStateに入れ、2つ目以降は直前のHookのnextにつなぐ。「Hookとqueue」の節で見たつながりは、ここで作られている。
コンポーネントを呼び終えると、currentlyRenderingFiberはnullに戻される。
currentlyRenderingFiber = null as any;
ReactFiberHooks.js — finishRenderingHooks
「マウント時に作られるFiber」の節で見たように、Reactはコンポーネントを1つずつ順に呼ぶ。同時に2つのコンポーネントを呼ぶことはないので、変数1つで今のFiberを表せる。
まとめ
countの値とsetCountは、次のようにつながっていた。
Counter fiber
memoizedState -> Hook
Hook
memoizedState -> count
queue -> queue
queue
dispatch -> setCount
Reactはツリーの要素ごとにFiberを作り、countの値はFiberのmemoizedStateからたどるHookにある。setCountは、そのHookのqueueのdispatchに置かれている。レンダー中のuseStateは、Reactがコンポーネントを呼ぶ前に入れておくcurrentlyRenderingFiberを見て、どのFiberのHookを読み書きするかを知る。
レンダーの外で呼ぶsetCountが、どうやって自分のFiberに更新を届けるのかは、続きのReactのsetStateとFiberのつながりを理解するために実装を読んでみたで追った。
参考
- React —
useStatereference - React source —
ReactInternalTypes.js(v19.3.1) - React source —
ReactFiberWorkLoop.js(v19.3.1) - React source —
ReactFiberBeginWork.js(v19.3.1) - React source —
ReactChildFiber.js(v19.3.1) - React source —
ReactFiberCompleteWork.js(v19.3.1) - React source —
ReactFiberHooks.js(v19.3.1) - React source —
ReactFiberCommitHostEffects.js(v19.3.1) - React source —
ReactFiberConfigDOM.js(v19.3.1)