memo.nnenn0.com

Reactは更新のあるコンポーネントをどう見つけるのか

ReactのsetStateとFiberのつながりを理解するために実装を読んでみたでは、setCountが自分のFiberから親をたどってFiberRoot(createRootで作る、ツリー全体を管理するオブジェクト)を見つけ、レンダーを予約するところまでを見た。予約したレンダーは、Fiberのツリーのいちばん上にあるHostRootというFiberから始まる。今回は、そこから先を追ってみた。

題材は、深いところにCounterがある次のコードにする。MemoDisplayは、Displayと同じ中身のコンポーネントをmemoで包んだものだ。

tsx
function Logo() {
  return <span>logo</span>;
}

function Header() {
  return <Logo />;
}

function Display({ label }: { label: string }) {
  return <span>{label}</span>;
}

const MemoDisplay = memo(function MemoDisplay({ label }: { label: string }) {
  return <span>{label}</span>;
});

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount((n) => n + 1)}>{count}</button>
      <Display label="count" />
      <MemoDisplay label="count" />
    </>
  );
}

function Main() {
  return <Counter />;
}

function App() {
  return (
    <>
      <Header />
      <Main />
    </>
  );
}

コンポーネントのFiberだけを並べると、ツリーは次のようになる(buttonやspanのFiberは省いている)。

text
HostRoot
└─ App
   ├─ Header
   │  └─ Logo
   └─ Main
      └─ Counter
         ├─ Display
         └─ MemoDisplay

React v19.3.1でこの例をNode.jsとjsdomに描画し(StrictModeは使わない)、Counterのボタンを2回押して、関数が呼ばれたコンポーネントを記録すると次のようになった。ボタンを押した後に呼ばれるのはCounterとDisplayだけで、App、Header、Logo、Main、MemoDisplayは呼ばれない。

text
mount
  App, Header, Logo, Main,
  Counter, Display, MemoDisplay
click 1
  Counter, Display
click 2
  Counter, Display

この結果は、よく聞く「Reactは変わったところだけ再レンダーする」という説明どおりに見える。でも、レンダーはHostRootから始まり、Counterにたどり着くまでにAppとMainの下を通るのに、その途中のコンポーネントは1つも呼ばれていない。Reactは更新のあるコンポーネントをどう見つけるのか。

React v19.3.1の実装を読み、同じ例でFiberの中身も読んで確かめた。この記事では、ボタンのonClickのような通常のイベントハンドラから呼ぶsetStateを扱う。

Fiberのlanesだけでは足りない

setCountを呼ぶと、Reactは更新元のCounterのFiberのlanes(そのFiberに保留中の更新があることを示す印)に、この更新のlaneを足す(enqueueUpdate)。laneは更新の優先度を表すビットで、startTransitionの記事で書いたように、更新ごとに1つ付く。この例のクリックの更新はSyncLane(0b10、10進数で2)になる。

レンダーでは、ツリーを降りながらFiberを1つずつbeginWorkという関数で処理する。beginWorkが受け取るのは処理するFiberとrenderLanesだけで、更新元のFiberがどれかは渡されない(beginWork)。renderLanesは、FiberRootのpendingLanes(そのツリーで保留中の更新のlane)から今回処理するlaneを選んだものだ。beginWorkは、そのFiber自身に今回の更新があるかを、FiberのlanesとrenderLanesのビットの積(&)で調べる(checkScheduledUpdateOrContext)。

js
export function includesSomeLane(a, b) {
  return (a & b) !== NoLanes;
}

ReactFiberLane.js — includesSomeLane

もし印がCounterのlanesにしかなかったら、HostRootから降りてきたReactがAppの前で調べられるのは、App自身に更新があるかどうかだけになる。App.lanesとrenderLanesの積が0でも、Appの下にCounterがいるかどうかは分からないので、Counterを見つけるにはchildとsiblingをたどってツリー全体を見ることになる。同じイベントの中で別のコンポーネントもsetStateしていれば更新のあるFiberは複数あるので、1つ見つけたところで止めることもできない。

親に付けるchildLanes

そこでReactは、更新元のFiberからreturnで親をたどり、HostRootまでの各FiberのchildLanesにもlaneを足す。childLanesは、そのFiberの子孫に保留中の更新があることを示す印だ。

この印を付けるのはsetCountの中ではなく、レンダーを始めるときになっている。setCountの中のenqueueUpdateは、Fiberのlanesにlaneを足した後、更新元のFiberと更新を配列にいったん積んでおく。レンダーを始めるときに呼ばれるfinishQueueingConcurrentUpdatesが、積んだ更新をHookのqueueへ移しながら、更新元のFiberごとにmarkUpdateLaneFromFiberToRootを呼ぶ(prepareFreshStack、finishQueueingConcurrentUpdates)。

markUpdateLaneFromFiberToRootから、Offscreen(非表示の部分木)を扱う分岐と、たどり着いたHostRootからFiberRootを返す部分を省いて抜き出すと、次のようになっている。mergeLanesはビットの和を取る関数だ。

js
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
let alternate = sourceFiber.alternate;
if (alternate !== null) {
  alternate.lanes = mergeLanes(alternate.lanes, lane);
}
let parent = sourceFiber.return;
while (parent !== null) {
  parent.childLanes = mergeLanes(parent.childLanes, lane);
  alternate = parent.alternate;
  if (alternate !== null) {
    alternate.childLanes = mergeLanes(alternate.childLanes, lane);
  }
  parent = parent.return;
}

ReactFiberConcurrentUpdates.js — markUpdateLaneFromFiberToRoot

sourceFiberはsetCountが持っているCounterのFiberで、そのlanesにはenqueueUpdateで同じlaneを足してあるので、ここで足しても値は変わらない。新しく付くのは、親のchildLanesの印だ。alternateがあればそちらにも同じ印を付けているのは、Fiberが画面に出ているツリー(current)とレンダー中に作るツリー(workInProgress)の2つで対になっていて、setCountが持っているFiberがcurrentとは限らないからだ(currentとalternate)。

Counterのボタンを押した後のレンダーを始めた時点では、次のように印が付いている。

text
HostRoot      childLanes=2
└─ App        childLanes=2
   ├─ Header  childLanes=0
   │  └─ Logo
   └─ Main    childLanes=2
      └─ Counter  lanes=2

印が付くのはCounterからreturnでたどった道の上だけなので、HeaderのchildLanesは0のままになる。childLanesに入るのは自分の部分木の更新だけで、Mainの下の更新は、兄弟のHeaderではなく親のAppのchildLanesに入る。

この印があれば、降りるときに各Fiberの前で判断できる。Headerの前ではlanesもchildLanesも0なので、Header自身にもその下にも今回の更新はない。Mainの前では、lanesは0だがchildLanesにSyncLaneがあるので、Main自身に更新はないが、子孫のどこかにはある。

付けた印は、そのレンダーの中で消える。Counterのlanesは関数を呼ぶ前に空にし(beginWork)、道の上のchildLanesは、子から親へ戻るときにbubblePropertiesが子のlanesとchildLanesから計算し直して上書きする(bubbleProperties)。この例では今回のlaneの更新をすべて処理するので、どちらも0に戻る。処理を後回しにした更新が残る場合は、startTransitionの記事で書いたように、そのlaneが印として残る。

ただし、0に戻るのはレンダー中に作っているworkInProgressのFiberだけで、alternateにも付けた印は、コミットの後も古い方のFiberに残る。実際にクリックの後に読むと、古い方のHostRoot、App、MainにはchildLanes=2、Counterにはlanes=2が残っていた。古い方は、次のレンダーでworkInProgressとして使い回すときにcreateWorkInProgressがcurrentのlanesとchildLanesで上書きするので(createWorkInProgress)、この記事で追った経路では、残った印をレンダーで読むことはない。

関数を呼ばずに降りる

では、Mainの前でReactは実際に何をしているのか。最初の節で見たbeginWorkは、1つの要素について2つのFiberを受け取る。前回のレンダーの結果として画面に出ているツリーにあるcurrentと、今回のレンダーで作っているworkInProgressだ。beginWorkの最初の部分から、開発用の分岐やレガシーなContextの確認などを省いて単純化すると、次のようになっている。

js
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps === newProps) {
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes,
  );
  if (!hasScheduledUpdateOrContext) {
    return attemptEarlyBailoutIfNoScheduledUpdate(
      current,
      workInProgress,
      renderLanes,
    );
  }
}
// ここから先で、Fiberの種類ごとの処理
// (関数コンポーネントなら関数を呼ぶ)へ進む

ReactFiberBeginWork.js — beginWork

memoizedPropsは前回のレンダーで使ったprops、pendingPropsは今回のレンダーで使うpropsだ。checkScheduledUpdateOrContextは、最初の節で見たようにcurrent.lanesとrenderLanesの積でそのFiber自身に今回の更新があるかを調べる(読んでいるContextが変わったかも調べるが、この例には関係しない)。propsが前回と同じオブジェクトで、lanesに今回の更新がなければ、コンポーネントの関数を呼ばずにattemptEarlyBailoutIfNoScheduledUpdateへ進む。この関数は、Fiberの種類ごとの後始末をしてからbailoutOnAlreadyFinishedWorkを呼ぶ(attemptEarlyBailoutIfNoScheduledUpdate)。

bailoutOnAlreadyFinishedWorkでchildLanesを見る部分を、子孫のContextの変化を調べる処理を省いて抜き出すと、次のようになっている。

js
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
  return null;
}
cloneChildFibers(current, workInProgress);
return workInProgress.child;

ReactFiberBeginWork.js — bailoutOnAlreadyFinishedWork

beginWorkが返したFiberが、次に処理するFiberになる。childLanesに今回のlaneがなければnullを返すので、ReactはそのFiberの子には入らず、兄弟へ進むか、兄弟がなければ親へ戻る(completeUnitOfWork)。childLanesに今回のlaneがあれば、cloneChildFibersで子のworkInProgressのFiberを作り、最初の子を返して降りていく。

ここで、Mainのpropsが前回と同じオブジェクトになっているのは、親のAppが呼ばれていないからだ。cloneChildFibersは、次のように子のFiberを作っている。

js
let currentChild = workInProgress.child;
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
workInProgress.child = newChild;

ReactChildFiber.js — cloneChildFibers

currentChildは、この時点ではcurrentのMainを指している。createWorkInProgress(current, pendingProps)は、currentのFiberをもとにworkInProgressのFiberを用意して第2引数をpendingPropsに入れ、lanes、childLanes、child、memoizedPropsはcurrentのFiberから写す(createWorkInProgress)。親のAppが呼ばれていないので、MainのpropsにはcurrentのMainのpendingProps、つまり前回のレンダーで使ったpropsのオブジェクトがそのまま渡る。実際に、クリックの後のcurrentとalternateのMainのmemoizedPropsを比べると、同じオブジェクトだった。

親が呼ばれなければ子のpropsは前回と同じオブジェクトのままなので、子もlanesが空なら関数を呼ばずに抜ける。この例ではHostRootからCounterまで親が1つも呼ばれないので、どのFiberもpropsは前回と同じオブジェクトになり、前の節の図の印だけで処理が決まる。HostRoot、App、MainはchildLanesに印があるので子を作って降り、Headerは子に入らない。Counterもpropsは前回と同じオブジェクトだが、lanesにSyncLaneがあるので、checkScheduledUpdateOrContextがtrueを返し、関数を呼ぶ側へ進む。

飛ばした部分木のFiber

Headerは、関数が呼ばれないだけでなく、子に入らない。では、Headerや、その下のLogoのFiberは今回のレンダーで作られるのか。

HeaderのworkInProgressのFiberは作られる。cloneChildFibersは、最初の子を作った後、siblingをたどって兄弟もすべて同じように作るからだ。

js
while (currentChild.sibling !== null) {
  currentChild = currentChild.sibling;
  newChild = newChild.sibling = createWorkInProgress(
    currentChild,
    currentChild.pendingProps,
  );
  newChild.return = workInProgress;
}

ReactChildFiber.js — cloneChildFibers

Appの最初の子はHeader、その兄弟がMainなので、Appの子を作るときには、印のあるMainだけでなくHeaderも作られる。

一方、LogoのFiberは作られない。HeaderのbeginWorkは関数を呼ばずにbailoutOnAlreadyFinishedWorkへ進み、HeaderのchildLanesは0なので、cloneChildFibersを呼ぶ前にnullを返す。workInProgressのHeaderのchildは、createWorkInProgressがcurrentのHeaderから写したまま、currentのLogoを指している。

実際にFiberを読んでも、ボタンを2回押した後もLogoのFiberはマウントで作られたものが1つだけで、alternateはnullのままだった。Headerは押すたびにcurrentとalternateの2つのFiberが入れ替わるが、どちらのchildも同じLogoのFiberを指していた。

レンダーで各Fiberに何をするかをまとめると、次のようになる。図の「複製」は、createWorkInProgressでworkInProgressのFiberを用意することを指す。Counterも複製してから関数を呼ぶ。Counterの子は次の節で見る。

text
HostRoot      複製して子へ
└─ App        複製して子へ
   ├─ Header  複製だけ
   │  └─ Logo  触らない
   └─ Main    複製して子へ
      └─ Counter  関数を呼ぶ

関数を呼ばずに通るところでReactが手を付けるのは、childLanesの印が付いた道の上のFiberと、その直接の子までで、印のない部分木の中には入らない。

呼ばれたCounterの子

Counterが呼ばれた後のDisplayは、自分ではstateを更新していないのでlanesもchildLanesも0で、propsのlabelも前回と同じ"count"だ。それでも、最初に見たログのとおりDisplayは呼ばれている。

Counterが呼ばれると、返したJSXから子のFiberを作り直す。このとき、前回と同じ位置に同じ種類の要素があれば、currentのFiberをもとにuseFiberでworkInProgressのFiberを作る(updateElement)。useFiberから、Fiberを作る部分を抜き出すと次のようになっている。

js
function useFiber(fiber, pendingProps) {
  const clone = createWorkInProgress(fiber, pendingProps);
  // ...
  return clone;
}

ReactChildFiber.js — useFiber

fiberはcurrentのDisplay、pendingPropsは今回のJSXのpropsだ。親が呼ばれなかったMainでは、cloneChildFibersがcurrentのpendingPropsをそのまま渡していたが、親が呼ばれたDisplayには、今回のJSXのpropsが渡る。

<Display label="count" />は、Counterが呼ばれるたびに{ label: "count" }というpropsのオブジェクトを新しく作る。中身は前回と同じでも別のオブジェクトなので、DisplayのbeginWorkではoldProps !== newPropsが成り立ち、lanesを見る前に関数を呼ぶ側へ進む。実際に、クリックの後のcurrentとalternateのDisplayのmemoizedPropsを比べると、別のオブジェクトだった。

MemoDisplayも同じように新しいpropsのオブジェクトを受け取るが、呼ばれていない。memoの公式ドキュメントによると、memoは比べ方を渡さなければ、新しいpropsと前回のpropsを1つずつObject.isで比べる。labelはどちらも"count"なので、関数は呼ばれない。memoがどこで比べているかの実装は確かめていない。

beginWorkの最初で関数を呼ばずに抜けられるのは、親が呼ばれず、propsが前回と同じオブジェクトのまま渡ってくるFiberのうち、lanesに印のないものだけだ。呼ばれたコンポーネントの子は、propsの中身が同じでも別のオブジェクトを受け取るので呼ばれる。中身を比べて止めたいときに使うのがmemoになる。

まとめ

Reactは、setStateの後のレンダーで更新のあるコンポーネントを探し回っているわけではない。レンダーを始めるときに、更新元のFiberからHostRootまでの道のchildLanesに印を付けておき、HostRootから降りながら各Fiberの前で印を見る。propsが前回と同じオブジェクトでlanesに印がなければ関数を呼ばず、childLanesに印があれば子を複製して降り、なければその部分木には入らない。

「Reactは変わったところだけ再レンダーする」は、呼ばれる関数がsetStateしたコンポーネントとその下だけになるという意味では合っている。省いているのは、そこまでの道にあるAppやMain、その兄弟のHeaderも、関数は呼ばないもののFiberは複製していることと、呼ばれたコンポーネントの子はpropsの中身が変わっていなくても呼ばれることだ。より正確に言うなら、setStateしたコンポーネントと、memoなどで止めない限りその子孫を呼び直し、そこまでの道は関数を呼ばずにFiberだけを複製して通る、になる。

参考