Subterranean Flower

React HooksのuseStateがどういう原理で実現されてるのかさっぱりわからなかったので調べてみた

Author
古都こと
ふよんとえんよぷよぐやま!!!

Reactのhooks、すごいですよね。関数コンポーネントの自由度があがって色々実現できそうです。

私はReactについては2年ぐらい知識止まってるので、かなり衝撃的でした。ContextとかSuspenseとかいろいろ増えてて今更追いつくのは難しいけど、hooksはちょっと使ってみたいなーと感じました。

そんなhooksですが、使い方はそこそこわかるけど動作原理がさっぱりわからなかったので、ちょっと調べてみました。

そもそもHooksってなに

hooksは関数コンポーネントからReactのいろいろな機能をフックでき、自分で汚く実装するんじゃなくてReactが面倒見てくれるよーってやつです。useStateで状態を持たせることができ、useEffectでcomponentDidMountみたいなことを実現、あとほかにもuseXXX系がいろいろ、という感じです。

useStateを例に見てみましょう。useStateを使うと関数コンポーネントに状態を持たせることができます。正確に言うと、関数が直接状態を持てるわけではなく、どこかに保存して毎回そこから状態取ってきてるだけです。これを使うとクラスコンポーネントのstateと同じようなことが実現できます。

関数コンポーネントの中でuseStateという関数を呼び出すと、現在の状態と、状態更新用の関数を返してくれます。状態がまだ存在しない場合はuseStateに渡した値が初期値として使われます。

import React, { useState } from 'react';
import ReactDOM from 'react-dom';

function TestComponent() {
  // hooksで現在の値と更新用関数を入手
  const [value, setValue] = useState(10); // 初期値10

  // クリックされた時に更新する
  return (<button onClick={() => setValue(value+1)}>
            {value}
         </button>);
}

// レンダリング
ReactDOM.render(
  <TestComponent/>,
  document.querySelector('#app')
);

useStateを実行すると[value, updateFunc]という配列が帰ってくるので、適当な名前をつけて受けます。updateFuncを通して値を更新すれば変更が反映されます。

これで初期値10の、クリックすると1ずつ数が増えるコンポーネントができます。やったね。

どうやって動いてんの?

使い方は簡単ですね。でもひとつ疑問が残ると思います。「これどうやって実現してんの……?」と。いや、賢い人ならすぐわかるんだろうけど、少なくとも私は賢くないので全く想像できませんでした。

updateFuncがなんかうまいことやってくれてるのはわかるのですが、クロージャではこれ動かないし、Functionのcallerは非標準だし、各コンポーネントの処理時にuseStateの見てる先変えるとか……?

などといろいろ考えたけど結局何も思いつかず、もう面倒だからReactのソース読むことにしました。

ソースから攻める

というわけでReactのリポジトリにレッツゴー!

https://github.com/facebook/react

全体的にFlowで書かれてるっぽいですね。まあFacebookですからね。といってもTypeScriptとそんなに変わらんと思うので読めると思います。

hooksはこの辺ですね。

https://github.com/facebook/react/blob/c21c41ecfad46de0a718d059374e48d13cf08ced/packages/react/src/ReactHooks.js

全部見ていくのは面倒なのでuseStateだけ追っていきましょう。他のフックもまあだいたい同じでしょ。たぶん。ではuseStateのコードは……。

export function useState<S>(initialState: (() => S) | S) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

えっ、短っ。dispatcherというのを取得して初期値渡してreturnしてるだけですね。でも処理中コンポーネントによって別々の場所見てるのは間違いないっぽいです。

同じファイル内にあるresolveDispatcher関数を見てみると、ReactCurrentDispatcher.currentというのを取得してるみたいですね。

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  invariant(
    dispatcher !== null,
    'Hooks can only be called inside the body of a function component. ' +
      '(https://fb.me/react-invalid-hook-call)',
  );
  return dispatcher;
}

じゃあ次はReactCurrentDispatcherファイルに移動して……

https://github.com/facebook/react/blob/c21c41ecfad46de0a718d059374e48d13cf08ced/packages/react/src/ReactCurrentDispatcher.js

中身無いやん。まあこのあたりはReactがcurrentをうまいことセットしてくれるんでしょうね。reconcilerと関係あるっぽいですね。reconcilerはVirtualDOMの差分検出とか更新とかそのあたりの大きな枠組みです。今度はそっち行ってみましょー。

https://github.com/facebook/react/blob/c21c41ecfad46de0a718d059374e48d13cf08ced/packages/react-reconciler/src/ReactFiberHooks.js

hooksのfiberですかね。fiberはReactにだいぶ前に導入された処理単位で、それまでは変更をチェックするときなどにコンポーネントの根元から順番に全部を一度に見ていたのですが、fiberという単位に小分けにして効率よく扱えるようになりました。fiberはそれ自体が連結リストになっていて、コンポーネントを連結できます。

このファイルをあさっていくとReactCurrentDispatcherに具体的にdispatcherを割り当ててる部分があります。

export function renderWithHooks(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  // 省略
  if (__DEV__) {
    ReactCurrentDispatcher.current =
      nextCurrentHook === null
        ? HooksDispatcherOnMountInDEV
        : HooksDispatcherOnUpdateInDEV;
  } else {
    ReactCurrentDispatcher.current =
      nextCurrentHook === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
  // 省略
}

特に小分けにしていないので、ひとつのfiberにつきひとつのdispatcherですかね。コンポーネントひとつごとにdispatcherがあると考えていいと思います。初回呼び出し時はHooksDispatcherOnMount、2回目からはHooksDispatcherOnUpdateがdispatcherとして使われます。

これらのdispatcherは同じファイルの中に定義があって、それぞれのdispatcher.useStateの実態はmountStateとupdateStateになります。

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
};

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
};

まず初回実行時のdispatcher.useStateであるmountStateから見ていきましょう。

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    eagerReducer: basicStateReducer,
    eagerState: (initialState: any),
  });
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    // Flow doesn't know this is non-null, but we do.
    ((currentlyRenderingFiber: any): Fiber),
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

渡された初期値はhook.memoizedStateに放り込まれます。関数を初期値として渡した場合、ここで実行して値を取り出しています。

注目するところはdispatchです。値の更新を適用するdispatchAction関数に今のfiberとqueueをバインドしています。dispatchActionも同じファイル内に書かれているので興味ある人は見てみてください(かなり長いのでここでは掲載しません)。

dispatchActionはfiber, queue, actionを受け取る関数で、actionは普通の値か関数かのどっちかです。fiberとqueueはバインドされてるので、ここでのdispatchはdispatch(action)になります。そしてmountStateが最終的にreturnするのは[初期値, dispatch]になります。

ここで原点に戻りましょう。まず関数コンポーネントからuseState(initialValue)を呼び出します。すると:

  1. useState(initialValue)は現在のdispatcherを取得する
  2. 初回呼び出しなら、dispatcherはHooksDispatcherOnMountとなる
  3. HooksDispatcherOnMountのuseState(initialValue)を呼び出す
  4. HooksDispatcherOnMount.useState(initialValue)の実体はmountState(initialValue)である
  5. mountStateは[initialValue, dispatch]を返す。ここでdispatchはfiberに紐づいた更新用関数である

と、なります。最終的に帰ってくるのは当然 [初期値, 更新用関数] です。やっと最初まで戻ってきた……疲れた。const [value, setValue] = useState(10);とするだけでこれだけのことが起こっているんですね。

2回目以降のuseStateで呼び出されるupdateStateの中身はこんな感じ:

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch>BasicStateAction6lt;S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

シンプル!!updateReducerは長いので中身は省略しますが、[今の値, 更新用関数]を返す関数です。

ちなみにこのjsファイルに面白いことが書いてありますね。

// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.

hooksはfiberにLinkedListとして格納されるようです。実はuseStateには「毎回同じ順番で同じ回数呼び出さないとデータがずれる」という仕様があるのですが、管理が単なるLinkedListだからですね。

要するに

useStateを呼び出したときは、今処理中のfiberに格納されている状態を取ってきて、更新用関数を生成してもらって、状態と更新用関数を返してもらう、という流れになってますね。更新用関数にはfiberとqueueがバインド済みなので、あとは値を渡すだけでなんとなくうまいことやってくれます。これがfiber(=コンポーネントと考えて問題ない)ごとに行われるというわけです。

また、useStateを必ず同じ順番で取り出さないと値がズレる現象の理由もハッキリしました。内部的にはLinkedListを順番に見ているだけなんですね。

他のhookとか

他のhookも同じように辿っていけますね。useEffectはmountEffectとupdateEffectをみていけばいけばいいし、useMemoはmountMemoとupdateMemoをみていけば動作がわかるでしょう。

私はかなり疲れたんでこのあたりでやめです。あとは各自好きにしてください。