banner
IWSR

IWSR

我永远喜欢志喜屋梦子!

React Hooks: useRef と Refs

説明#

  1. この記事では以下の 2 点を分析します

    1. useRef がどのようにデータを保存するか
    2. Refs がどのように実際の DOM を参照するか
  2. この記事を読む前に、React Hooks: hooks リストを先に読んでください

  3. バージョンは v18.1.0 に基づいています

  4. 分析は以下のコードに基づいています

  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#

  1. useRef のデータはその対応する hook オブジェクト内(memoizedState)に存在し、データの変更はページの更新を引き起こさない
  2. 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 が実行されます。

image

  /**
   * 不要なコードを多く削除しました
  */
  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)を直接見ていきます。

image

新しい 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 つのことを行います。

  1. 新しい fiber ノードを作成した後、ReactElement 上の ref 属性を fiber.ref に割り当てます
  2. 現在の遍歴中の 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 にブレークポイントを打つと、次のようなコールスタックが見えます。

image

componentDidMount の呼び出しが commit フェーズで発生していることが明らかにわかります(commit フェーズのエントリは commitRootImpl です)。commit フェーズはさらに 3 つのサブフェーズに分かれます。

  1. beforeMutation エントリは commitBeforeMutationEffects
  2. mutation エントリは commitMutationEffects
  3. layout エントリは commitLayoutEffects

これらの 3 つのフェーズの中で、layout フェーズでのみ完全に処理された DOM 構造を取得できます。したがって、refs と DOM のバインディングのステップもここで処理するのが適しています。

実際にそうです。

image

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 フェーズで発生します。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。