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

こんにちは、noyanです。React内部実装アドベントカレンダーの5日目です。昨日まで3回にわけてマウント時のuseState処理を追ってきました。ですが、詳細に関数の処理を追うことに注力したため、実際に関数コンポーネントと関数の知識が結びついていない感じがします。

以上を背景に、この記事では関数コンポーネントがマウントされてから値が更新されるまでの流れを概観し、useStateの全体像をつかみます。useStateのメンタルモデルを作成して、hookの挙動を想定できるようになることがgoalです。

関数コンポーネントが更新されるまで

次のような関数コンポーネントがあるとします。

 const Counter = () => {
    const [count, setCount] = useState(0);
    if(count === 0) setCount(1); 
    
    return (<div>{count}</div>)
 }

この関数が<div>1</div>となるまでの処理を追っていきましょう。これには主にrenderWithHooksの処理をおえば良いです。

1. useStateのdispatcherを決定

renderWithHooksが呼ばれると、ReactはuseStateに紐付くdispatcherを決定します。onMountではmountState関数が呼び出されると、hookを作成し[hook.memoizedState, dispatch]を返します。

export function renderWithHooks(
  current,
  workInProgress,
  Component,
  props,
  secondArg,
  nextRanderLane
): any {
  // Attach Dispatcher
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // Render components as long as it has any updates
  let children = Component(props, secondArg);

  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    do {
      didScheduleRenderPhaseUpdateDuringThisPass = false;
      ReactCurrentDispatcher.current = HooksDispatcherOnRerender;

      children = Component(props, secondArg);
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }
  return children;
}

2. コンポーネントレンダリング - mountState編

次にコンポーネント関数を実行します。

  let children = Component(props, context);

記事冒頭のCounterコンポーネントの場合で処理を追ってみましょう。

 const Counter = (props, context) => { // 引数には ({}, undefined)が入ることを期待している。
    const [count, setCount] = useState(0);
    if(count === 0) setCount(1); 
    return (<div>{count}</div>)
 }
 
 let children = Counter(props, context)

(第2引数のcontextは非推奨のLegacy Context APIで、現在標準のcontextの表記法とは異なります。)

見通しを良くするため、実行される関数に置き換えます。

  • useStateはマウント時mountStateに
  • setStateはdispatchに
 const Counter = (props, context) => {
    // 引数には ({}, undefined)が入ることを期待している。
    const [hook.memoizedState, dispatch] = mountState(0);
    //      ↑ === 0
    if(count === 0) dispatch(1)
  ); 
    return (<div>{hook.memoizedState}</div>);
 }
 
 let children = Counter(props, context)

mountState

Counter関数で最初に呼び出されるmountState関数は[0, setCount]を返します。この際、Reactは内部でhook.memoizedStateにmountStateの呼び出し値を記録し、次回呼ばれた際に返す値とします。

dispatch

dispatch関数はバインドされたdispatchSetStateです。dispatchSetState関数は更新キューをつくり、hook.queue.pendingに循環リストのかたちで付与します。

Counter関数でもsetState関数(=dispatchSetState)が呼ばれるので、dispatch(1)によりhook.queue.pendingに更新キューが作成されます。

(この部分でわからない箇所がある。dispatchSetStateはマウント時の関数が使い回されるため、この関数はマウント時のfiberをずっと参照している。するとマウント時以外はrerender更新は行われないことになり、これは即座の反映が行われないことを意味する。この理解は正しいのだろうか。)

3. コンポーネントレンダリング - rerenderState編

実はdispatchSetStateを実行するとdidScheduleRenderPhaseUpdateDuringThisPasstrueになるので、値がfalseになるまでレンダリングを続けます。アプリケーションコードとしては、setStateが呼ばれなくなるまで実行することを意味します。

 function renderWithHooks(){
   if (didScheduleRenderPhaseUpdateDuringThisPass) {
    do {
      didScheduleRenderPhaseUpdateDuringThisPass = false;
      ReactCurrentDispatcher.current = HooksDispatcherOnRerender;

      children = Component(props, secondArg);
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }
  return children;
}

const HooksDispatcherOnRerender: Dispatcher = {
  useMemo: updateMemo,
  useReducer: rerenderReducer,
  useRef: updateRef,
  useState: rerenderState,
}

do while 構文内のDispatcher.useStateは、処理されていないqueueを順番に処理します。これが前回読んだrerenderStateの仕事です。結果的にhooksの値が更新されます。

const [count, setCount] = useState(0);
        ↑ === 1

やっと更新されました。

コンポーネントが最後まで実行され、childrenに値が代入されます。

children = <div>0</div>

さて、count === 1のときsetStateは実行されなかったため、didScheduleRenderPhaseUpdateDuringThisPassはfalseのままになり、do while文を抜けます。

最終的に更新された_jsxが最終的にReactElementを呼び出して、renderWithHooksの呼び出しが終了します。この後どうなるかは自分もわかっていないですし、急に知らなければいけない知識が多くなってしまうので今度にさせてください。

おわりに

本当はdo while文の後にcontextの最適化処理があるのですが、これは今回関係ないので省略しました。この話が今月のReact confで発表されると思われるので、楽しみにしています。

次回はすこし箸休めとしてdev-toolかjsxの変換の話をしようと思います。