説明#
-
この記事では以下の 2 点を分析します
- useRef がどのようにデータを保存するか
- Refs がどのように実際の DOM を参照するか
-
この記事を読む前に、React Hooks: hooks リストを先に読んでください
-
バージョンは v18.1.0 に基づいています
-
分析は以下のコードに基づいています
import { useRef } from "react";
function UseRef() {
const ref1 = useRef(null);
const ref2 = useRef({
a: 1,
});
const handleClick = () => {
ref2.current = {
a: 3
}
}
return (
<div id="refTest" ref={ref1} onClick = {handleClick}>123</div>
)
}
export default UseRef;
TLNR#
- useRef のデータはその対応する hook オブジェクト内(memoizedState)に存在し、データの変更はページの更新を引き起こさない
- Refs と DOM のバインディングプロセスは commit フェーズの layout サブフェーズ内で発生します。
useRef 解析#
mount シーンでの useRef#
mount シーンで useRef にポイントを打つと、mountRef という関数に入ります。
function mountRef<T>(initialValue: T): {|current: T|} {
// hooksリストの内容を使用してhookオブジェクトを作成し、hooksリストにマウントします
const hook = mountWorkInProgressHook();
if (enableUseRefAccessWarning) {
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
} else {
// 初期値を保存するオブジェクトを作成し、初期値はuseRef内で渡された引数です
const ref = {current: initialValue};
// そしてそのオブジェクトをhookオブジェクトのmemoizedState属性に保存します
hook.memoizedState = ref;
return ref;
}
}
物が少なく、分析することはほとんどありません。
update シーンでの useRef#
他の hooks と同様に、update フェーズも存在します。コードも非常にシンプルなので、そのまま貼り付けました。
function updateRef<T>(initialValue: T): {|current: T|} {
// updateWorkInProgressHookはcurrentツリー上の対応するhookオブジェクトに基づいて
// 新しいhookオブジェクトを作成し、refの参照アドレス(memoizedStateに保存)も新しいhookのmemoizedStateに割り当てられます
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
一目瞭然のコードです。当然、useRef 内の値の変更は useState のように dispatch を呼び出す必要がないため、scheduleUpdateOnFiber も呼び出されず、この変更は React のレンダリングを引き起こしません。
まとめ#
useRef 内に渡された値は ref.current に割り当てられ、ref は useRef に対応する hook オブジェクトの memoizedState 属性にマウントされます。毎回 update フェーズで新しい hook オブジェクトが作成される際に、古い hook オブジェクトの memoizedState が新しい hook オブジェクトに割り当てられるため、ref の参照アドレスが変わらず、保存された値も変わらないことが保証されます(つまり、毎回レンダリングで返される ref オブジェクトは同じオブジェクトです)。また、ref 内に保存された値の変更は React のレンダリングを引き起こしません。
Refs#
useRef の内容が非常に少ないため、例の中の ref についてもう少し話したいと思います。
ただし、ref のデバッグの考え方はかなり複雑です。JSX は Babel によって React.createElement に変換されます。この関数はpackages/react/src/ReactElement.js
に存在します。この関数が返す型の宣言は次のとおりです。
{
// このタグはこれをReact要素として一意に識別することを可能にします
$$typeof: REACT_ELEMENT_TYPE,
// 要素に属する組み込みプロパティ
type: type,
key: key,
ref: ref,
props: props,
// この要素を作成したコンポーネントを記録します。
_owner: owner,
};
明らかに ref は単独で分離されていますが、デバッグを行う必要がある場合は、ReactElement に基づいて fiber を作成するプロセス、つまり beginWork に目を向ける必要があります。しかし、ここでは 2 つのフェーズに分かれています ——1. UseRef の beginWork;2. div#refTest の beginWork。
UseRef の beginWork#
UseRef の beginWork に入ると、mountIndeterminateComponent が実行されます。
/**
* 不要なコードを多く削除しました
*/
function mountIndeterminateComponent(...) {
...
/**
* renderWithHooksはUseRef内のreturnの結果を返します
* つまり、このfunction componentが返すReactElementです
* */
value = renderWithHooks(...)
...
/**
* reconcileChildrenはReactElementを渡し、diffとfiberの生成に使用します
*/
reconcileChildren(null, workInProgress, value, renderLanes);
}
function renderWithHooks(...) {
...
let children = Component(props, secondArg);
...
return children;
}
reconcileChildren という関数は面接の老舗です。diff のエントリはそれです。しかし、ここでは diff については触れず、ref に関係する部分(reconcileChildren -> ChildReconciler -> reconcileSingleElement)を直接見ていきます。
新しい fiber ノードを作成した後、明らかにその fiber.ref に値が割り当てられます(coerceRef が返すのは実際には ReactElement 上の ref です)。
div#refTest の beginWork#
ブレークポイントの記録に基づいて updateHostComponent に入ります。この関数内で ref に関連するコードは markRef に存在します。
function markRef(current: Fiber | null, workInProgress: Fiber) {
// ここでのrefは例内のref1です
const ref = workInProgress.ref;
/**
* 以下の2つの条件のいずれかが満たされると、実行体に入ります
* 1. 初回更新時、refがnullでない
* 2. 非初回更新時、WIP上のrefとcurrent上のrefが異なる、つまり変更が発生した
*/
if (
(current === null && ref !== null) ||
(current !== null && current.ref !== ref)
) {
// Ref効果をスケジュールします
/**
* ビット操作を使用してWIPのflagsにRefをマークし、更新が発生したことを示します
*/
workInProgress.flags |= Ref;
if (enableSuspenseLayoutEffectSemantics) {
workInProgress.flags |= RefStatic;
}
}
}
markRef はその名前の通り、主に fiber に ref が存在するマークを付けるためのものです。
フェーズ的まとめ 1#
beginWork 内で ref に関連するロジックは実際に 2 つのことを行います。
- 新しい fiber ノードを作成した後、ReactElement 上の ref 属性を fiber.ref に割り当てます
- 現在の遍歴中の fiber ノード上の ref が null でないか、またはその値が変更された場合、その fiber ノードにマークを付けます(workInProgress.flags |= Ref;)
refs がどのように dom にバインドされるか#
明らかに refs のロジックはここで終わっていません。refs の初期化を完了しましたが、割り当てはどうでしょうか?どのように対応する DOM を refs にバインドするのでしょうか?
この問題を説明するために、公式サイトの refs に関する説明を参考にする必要があります。
React はコンポーネントがマウントされるときに current 属性に DOM 要素を渡し、コンポーネントがアンマウントされるときに null 値を渡します。ref は componentDidMount または componentDidUpdate ライフサイクルフックがトリガーされる前に更新されます。
説明がライフサイクルに関連しているので、クラスの例を書いてみる必要があります。
import React from "react";
class UseRef extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
console.log(this.myRef);
}
render() {
return <div ref={this.myRef} />;
}
}
export default UseRef;
componentDidMount にブレークポイントを打つと、次のようなコールスタックが見えます。
componentDidMount の呼び出しが commit フェーズで発生していることが明らかにわかります(commit フェーズのエントリは commitRootImpl です)。commit フェーズはさらに 3 つのサブフェーズに分かれます。
- beforeMutation エントリは commitBeforeMutationEffects
- mutation エントリは commitMutationEffects
- layout エントリは commitLayoutEffects
これらの 3 つのフェーズの中で、layout フェーズでのみ完全に処理された DOM 構造を取得できます。したがって、refs と DOM のバインディングのステップもここで処理するのが適しています。
実際にそうです。
refs の割り当ては commitLayoutEffects の commitLayoutEffectOnFiber 内に隠れています。
function commitLayoutEffectOnFiber(...) {
...
if (!enableSuspenseLayoutEffectSemantics || !offscreenSubtreeWasHidden) {
if (enableScopeAPI) {
// TODO: これは一時的な解決策で、wwwでReact Flareから移行することを可能にしました。
/**
* finishedWork.flags & Ref
* &演算子は特徴を含むかどうかを示します。含まれていない場合、結果は0になります。
* 例えば、0110はすべての特徴を持っており、0010は特定の特徴を示します。
* 0110が0010を持っているかどうかを判断する必要がある場合、0110 & 0010を実行して結果を得ます。結果が非0であれば、0110は0010を含んでいると判断できます。
*/
if (finishedWork.flags & Ref && finishedWork.tag !== ScopeComponent) {
commitAttachRef(finishedWork);
}
} else {
if (finishedWork.flags & Ref) {
commitAttachRef(finishedWork);
}
}
}
...
}
beginWork の際に fiber に付けたマークを覚えていますか?この関数はそれを使用します。flags が Ref のマークを含む場合、commitAttachRef を実行します。
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref; // fiber上のref、ここでref.currentはnullです
if (ref !== null) {
const instance = finishedWork.stateNode; // stateNodeにはDOMオブジェクトが保存されています
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
...
if (typeof ref === 'function') {
let retVal;
...
retVal = ref(instanceToUse); // refsはコールバックにもなり得ます。。。
} else {
ref.current = instanceToUse; // ここがDOMをバインドするロジックで、割り当て後はrefsのDOMが最新であることが保証されます。
}
}
}
フェーズ的まとめ 2#
refs と DOM のバインディングは commit の layout フェーズで発生します。