memo.nnenn0.com

ReactのFiberとHookのつながりを理解するために実装を読んでみた

FiberとHookがどうつながっているのかを理解するために、React v19.3.1の実装を読んでみた。

題材にするのは、ボタンを押すと数が増えるCounterを2つ並べたAppだ。

tsx
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の型の定義から抜き出すと次のようになっている。

js
return: Fiber | null,
// ...
child: Fiber | null,
sibling: Fiber | null,

ReactInternalTypes.js — Fiber

題材のAppをマウントした後のFiberをたどると、次のようになっていた。

text
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を作っていく。

text
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つのフィールドだけになっている。

js
export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

ReactFiberHooks.js — Hook

Counter #1のFiberを読むと、中身は次のようになっていた。

text
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のどちらの中で呼ばれたのかを区別できない。

js
function useState(initialState) {
  // initialState には 0 が渡るだけで、
  // どの Counter の中で呼ばれたのかは分からない
}

それでも実際のuseStateは、Counter #1の中ならCounter #1のFiberの、Counter #2の中ならCounter #2のFiberのmemoizedStateを読み書きしている。

Reactは、コンポーネントを呼ぶ前に、今レンダーしているFiberをcurrentlyRenderingFiberという変数に入れている。renderWithHooksから、開発ビルド用の分岐を省いて抜き出すと次のようになっている。

js
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を次のようにつなぐ。

js
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に戻される。

js
currentlyRenderingFiber = null as any;

ReactFiberHooks.js — finishRenderingHooks

「マウント時に作られるFiber」の節で見たように、Reactはコンポーネントを1つずつ順に呼ぶ。同時に2つのコンポーネントを呼ぶことはないので、変数1つで今のFiberを表せる。

まとめ

countの値とsetCountは、次のようにつながっていた。

text
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のつながりを理解するために実装を読んでみたで追った。

参考