Reactは更新のあるコンポーネントをどう見つけるのか
ReactのsetStateとFiberのつながりを理解するために実装を読んでみたでは、setCountが自分のFiberから親をたどってFiberRoot(createRootで作る、ツリー全体を管理するオブジェクト)を見つけ、レンダーを予約するところまでを見た。予約したレンダーは、Fiberのツリーのいちばん上にあるHostRootというFiberから始まる。今回は、そこから先を追ってみた。
題材は、深いところにCounterがある次のコードにする。MemoDisplayは、Displayと同じ中身のコンポーネントをmemoで包んだものだ。
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は省いている)。
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は呼ばれない。
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)。
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はビットの和を取る関数だ。
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のボタンを押した後のレンダーを始めた時点では、次のように印が付いている。
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の確認などを省いて単純化すると、次のようになっている。
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の変化を調べる処理を省いて抜き出すと、次のようになっている。
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を作っている。
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をたどって兄弟もすべて同じように作るからだ。
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の子は次の節で見る。
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を作る部分を抜き出すと次のようになっている。
function useFiber(fiber, pendingProps) {
const clone = createWorkInProgress(fiber, pendingProps);
// ...
return clone;
}
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だけを複製して通る、になる。
参考
- React —
memoreference - React source —
ReactFiberConcurrentUpdates.js(v19.3.1) - React source —
ReactFiberBeginWork.js(v19.3.1) - React source —
ReactChildFiber.js(v19.3.1) - React source —
ReactFiber.js(v19.3.1) - React source —
ReactFiberCompleteWork.js(v19.3.1) - React source —
ReactFiberWorkLoop.js(v19.3.1) - React source —
ReactFiberLane.js(v19.3.1)