Reactのアーキテクチャを知る useState編2

こんにちは、noyanです。React内部実装アドベントカレンダーの2日目です。

引き続きReactのuseStateフックの内部実装を読み解いていきます。

目的

更新系を理解する準備として、dispatch関数が呼ばれたときの処理を知る。

Hookが単連結リスト構造だと知る。

準備運動

Reactのアーキテクチャに特徴的な概念を、ここで予告的に提示しておきます。どうせコードを見るうちに覚えるので覚えなくていいですが、わからなくなったらここに戻って参照してください。

ダブルバッファリング

Reactはレンダリングツリーのインスタンスを二重に管理しています。1インスタンスを書き換えつつ表示すると、書き換え処理が重くなるにつれ変更反映途中の画面が表示されてしまうことがあります。これに対し2つのインスタンスをもち高速に入れ替え続けることで、画面のちらつきをなくすことができます。ユーザーからは常に完成された画面だけが見えるようにするこの手法をダブルバッファリングと呼びます。

Fiber

FiberとはReactの各コンポーネントに対応するデータを保持する内部インスタンスです。各Fiberは自身のchildren / parentへの参照をもっているため、これを辿ることでDOMツリー全体をレンダリングすることができます。

ダブルバッファリングのため、各コンポーネントは対応する2つのFiberを持ちます。ひとつは(webであれば)DOMに反映済みの情報をもつFiberで、もうひとつはこれからDOMを反映されるFiberです。Reactの内部ではこのふたつのFiberの名前を、それぞれcurrent, workInProgressと呼称します。

ただし、hooksのデータを更新する文脈ではworkInProgresscurrentlyRenderingFiberと呼ばれます。

Hookのデータ構造

Hookには5つのプロパティがあります。(今回はmemoizedStateとnextが出てきます)

hook.memoizedState: メモリに保持されているローカルな状態。

hook.baseState: hook.baseQueue内のすべてのアップデートオブジェクトがマージされた後の状態。

hook.baseQueue: 現在のレンダリング優先度よりも高いものだけを含む、アップデートオブジェクトの循環的なチェーン。

hook.queue: 優先度の高いすべてのアップデートオブジェクトを含む、アップデートオブジェクトの循環的なチェーン。

hook.next: 次のポインタ、チェーンの次のフックを指します。

https://github.com/7kms/react-illustration-series/blob/5f6c3d002480a0bafd2a7690ac67b72fae71ab36/docs/main/hook-summary.md

最初のものが状態、次の3つが更新キュー、最後が次のhookへの参照です。

さて、hookを作成する関数が今回の記事のスコープなので、先に見ておきましょう。この関数はhookを新規作成し、現在更新中のhook(workInProgressHook)に代入します。

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

 // 現在作成中のFiber: currentlyRenderingFiber
 // 現在作成中のHook : workInProgressHook
  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;
  }
  return workInProgressHook;
}

これは以下の処理と同じです。

if (workInProgressHook === null) {
  currentlyRenderingFiber.memoizedState = workInProgressHook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };
} else {
  workInProgressHook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: hook, //前のhookに自分をつなげている。
  };
}

return workInProgress

この関数はuseStateが呼ばれるたびに実行されます。すでにworkInProgressが存在する場合には、.nextに自身をつなげるため、数珠つなぎ状にhookが連結されます。この繋がり方を連結リスト(単連結リスト)と呼ぶこともあります。

たとえば、以下のコードでcount1のnextはcount2となります。

const Count = () => {
    const [count1, setCount1] = useState(0);
    const [count2, setCount2] = useState(0);
    
    return (<div/>)
}

「ReactのHooksは連結リストだ!」という主張の根拠がどこにあるか、わかったのではないでしょうか。

コードリーディング

さて、体もあったまったところで、コードリーディングを再開しましょう。前回の記事ではuseStateのマウント時のコールスタックを辿りました。最終的に、useStateが間接的に呼び出したmountStatereturn [hook.memoizedState, dispatch];という部分がuseStateの返り値になっていました。

前回はmountStateの返り値を見たところで終わってしまって、関数の中身については精査できていませんでした。今回はmountStateの関数から辿り、dispatch関数、すなわちsetStateがどのような処理を行うのか理解します。

MountState

MountStateは長いので、まず概略コードを見ていきましょう。hookの5プロパティを埋めて、dispatch関数をつくります。useState()の返り値として、配列を返します。配列の中身はinitialStateとbindされた関数です。

function mountState(initialState) {
    const queue =  //
    const hook = {
        memoizedState:initialState,
        baseState: initialState,
        baseQueue: null
        queue: queue
        next: null
    }
    const dispatch =dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  return [hook.memoizedState, dispatch];
}

さて実装の詳細を見ていきましょう。hookのデータ構造のセクションで登場したmountWorkInProgressHook関数で初期化されたhookを取得します。dispatchSetState関数にqueueとfiberをbindします。

概要をぱっと掴むのは難しいですが、hookの5つあるプロパティを準備しているだけのようです。

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  
  // memoizedState, baseState
  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState(); // Lazy initial state mountWorkInProgressHook
  }
  hook.memoizedState = hook.baseState = initialState;
  
  // queue
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  
  // dispatch
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  
  return [hook.memoizedState, dispatch];
}

dispatchSetStateをみれば、マウント時の挙動はすべてわかりそうです。

dispatchSetState

updateオブジェクトを作成し、enqueueしているだけです。

この前の記事によるとsetState(action)dispatchSetState(fiber, queue, action)と同値だったので、第三引数のactionは1prev => prev + 1が入ることになります。これがupdate.actionに格納されて、queueが処理される際に実行されることになります。

laneやら、renderPhaseUpdateなど謎の概念はいずれ説明するので無視しましょう。

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
    lane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    enqueueUpdate(fiber, queue, update, lane);

    // ... some optimization
    // これ以降は無視してよい
    const eventTime = requestEventTime();
    const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
    if (root !== null) {
      entangleTransitionUpdate(root, queue, lane);
    }
  }

  markUpdateInDevTools(fiber, lane, action);
}

バインドするのは、関数をFiberと紐つけることで各コンポーネントの更新を区別できるようにするためです。

enqueueRenderPhaseUpdateとenqueueUpdate

今後更新系を見ていくうえで、queueの中身がどうなっているか知っておく必要があるので、これだけ確認しておきましょう。

コンセプトとしては、updateで循環参照のリストをつくり、それをqueueに入れています。

 const update: Update<S, A> = {
    // made by dispatchSetState
      lane,
      action,
      hasEagerState: false,
      eagerState: null,
      next: (null: any),
  };
  
 function enqueueRenderPhaseUpdate(queue, update){
     didScheduleRenderPhaseUpdateDuringThisPass = true
     if(firstRender){
         update.next = update
     } else {
         update.next = pending.next;
         pneding.next = update;
         // append it to list, and make it circular!
     }
         queue.pending = update
 }
  1. レンダリングフェーズに再度コンポーネントを描画するようフラグをオンにする。

  2. queue.pendingにupdateを代入する。

    の2つです。if-else節で循環参照を作っていることに気づかれたかもしれません。

    イメージとしては、queueの連結リストが[3,1,2]でupdateが1であれば[update,1,2,3]となるイメージです。循環リストなので3.nextはupdate, update.nextは1にになります。queue.pendingが一番新しく、queue.nextが一番古いqueueであることだけ覚えておきましょう。

    このqueueはhook.queueと同じアドレスなので、hook.queueから辿ることができます。

    enqueueUpdateはこれとだいたい同じなので省略します。

まとめ

最終的にenqueueの処理がわかりました。基本的にqueueにupdateを追加しているだけです。

ここまで呼んで、hookの値更新queueが積まれたときの処理は出てきませんでした。queue処理を担当するコードを見れば、値を反映するために起きている処理がわかるはずです。次回はそれを探していきましょう。