🍓

Reactを読んでまほうの正体を(少し)理解する

2024/04/19に公開

はじめに

Reactパッケージのコードは難しいらしいのですが、React を利用している人なら誰でも中身を読んでみたい!と思いますよね。

日々 React を使ってコードを書いていますが、function 関数でいい感じ HTML っぽい<p>Hello, World</p>コードを書いて、その後にyarn devしてみただけで、画面にHello, Worldが現れてしまう React なのですが、その動きはまるで魔法みたいだなと思っています。

ということで、React パッケージを読むことに挑戦したお話をさせてください。
本記事では以下について書いていきます。

  • React を読むにあたって取り組んだ流れ
  • 今回の取り組みで学んだ React 内部の仕組みや API などの紹介やその所感

我々が普段 React を触っている時には全く意識しないけれど、よくよく考えたら魔法みたいに不思議なその世界について紐解いていけたらと思います。

ちなみに、React が公式ドキュメントで説明している仕組みの解像度がさらに深まったり、JavaScript そのものの仕組みが理解できたりするきっかけになってとても良かったですが、全部まるっと理解できたとは言い難いです。

これから React の中身を読んでみようと思っている方や、React の内部構造について知識を深めたい方にとって少しでも役にたつものになれば幸いです。

React を読むまで

React を読もうと思った時にまずはパッケージの中身にかじりついてみたのですが、結論 React の内部は非常に複雑で、大挫折しました。まず、初見で理解できるものではありませんでした。なんとか 1 ミリでも理解できるようになるべく、公式ドキュメントや実際に React を読んだ先人たちの知恵の結晶(つまり記事)等をかき集めて、事前知識を備えることにしました。

React の処理の流れやキーワードを改めて理解する

事前知識を備える上でたいへんお世話になったのが、以下 build-your-own-react の写経です。
https://pomb.us/build-your-own-react/

React をわかりやすく理解するための、JavaScript のミニマムな実装サンプルを作ってくださっています(流れの説明もなんともオシャレ)。まず私はこのサイトをガッツリ読み込むことから始めましたが、それでもわからないので処理の流れが追えるように、ひたすら codesandbox にデバッグコードを仕込みまくって挙動確認をしました。大変お世話になりました。後続の流れにおいて実際に React を読んだ際に、関数名などが同じ(あるいは似ている)メソッドがあったりして、理解の助けになりました。

https://codesandbox.io/p/sandbox/didact-8-21ost

このミニマム実装では、大枠の React の流れを追う以外で以下の理解をより深めることができました。

  • fiber
  • react-reconciler

この仕組みは、React16 から登場したレンダーパフォーマンスの最適化と深く関わるもので、公式ドキュメントでも紹介されています。

Fiber & Reconciler

Fiber は、React 内で用意された仮想 DOM において 1 つの小さな作業単位の要素を指します。各 Fiber ノードは、コンポーネントの型(クラスまたは関数)、対応する DOM ノード、その子や兄弟、親へのリンクなど、コンポーネントの状態を持っています。

1 つの小さな作業単位というのがどれくらいかというと<div><p>hoge</p></div>であれば、div と p の Fiber に分かれるくらいの小ささです。

この Fiber はパフォーマンスを考慮して React16 で再設計されたアーキテクチャです。どのように優れているかといえば、たとえばsetStateを利用して何らかの更新があった際には、React は更新のあった Fiber を辿りその Fiber のみを再計算してレンダー&コミットすることができるのです。

Fiber の仕組みについては多くの良質な記事が出回っています。やや古いのですが、以下はとてもわかりやすかったです。

https://github.com/acdlite/react-fiber-architecture

Reconciler は DOM(Reconciler 自体は抽象化されたアーキテクチャや概念のようなものなので、正確には Web アプのようなブラウザでもネイティブアプリのような ReactNative 環境でも動きます)の状態において差分を検知する仕組みと言えそうです。

(ただ、React のコードリーディングにおいてこの reconciler が強烈に出てくる react-reconciler パッケージ周辺は、非常に難解ですべてを理解することは到底不可能でした)

自分の理解で言うと、React における Fiber + Reconciler の内部アーキテクチャは

  • 優先順位をつけながら
  • 作業を並行して
  • いつでもキャンセルできる形で
  • 私たちが見たときに画面が遅くならないように(パフォーマンスの意識/レンダリングフェーズの最適化)

画面をレンダリングする仕組みそのものかなと。

React はおそらくこの辺りの内部構造を React を利用する開発者に理解してもらうことはあまり加味していないのか、公式こういったものを解説されているドキュメントがほぼないかなと思っています(React 公式の記事を見ていると、以前の古いものでは仕組みを説明する記事がいくつも出てきていますが、バージョン 16 以降明らかにそういった記事が出回らなくなったと感じています。また、最近のブログやリリースノート(v18)などからもそういった姿勢が伺えます)。

JSX と React の関係を理解する

少し横道に逸れましたが……、ここまで build-your-own-react で理解を深めてきましたが、いまいち釈然としなかったのが以下のような部分が内部でどうなっているか、ということについてです。

import { useState } from "react";
import { createRoot } from "react-dom/client";

function HelloMessage() {
  const [count, setCount] = useState(0);
  return (
    <div onClick={() => setState((c) => c + 1)}>
      <h1>Hello: {name}</h1>
      <h2>Count: {state}</h2>
    </div>
  );
}

render(
  // HTMLタグのようなコードがなぜReactの関数の中にスッと入っていけるのか!?
  createElement(<HelloMessage name="Taylor" />),
  document.getElementById("container")
);

そこで、次にやったことはこの JSX と React についての理解を深めることでした。

しかし、これは調べてみると非常にシンプルな話でした。トランスパイルツールである babel が、この一件 HTML に見えなくもないこの<HelloMessage name="Taylor" />という JSX じみたコードを、JavaScript コードに変換しているというだけです。

実際に最小構成で React を動かしてみると、動きがよくわかります。@babel/preset-reactを利用することで、jsx が js へ変換される際に HTML タグ要素じみたものはすべてcreateElementという関数に変わっていました。

つまりこれは

import { useState } from "react";
import { createRoot } from "react-dom/client";

function HelloMessage() {
  const [count, setCount] = useState(0);
  return (
    <div onClick={() => setState((c) => c + 1)}>
      <h1>Hello: {name}</h1>
      <h2>Count: {state}</h2>
    </div>
  );
}

render(
  createElement(<HelloMessage name="Taylor" />),
  document.getElementById("container")
);

JSX を取っ払うと、こういうことです。

import { createRoot } from "react-dom/client";
import { createElement, useState } from "react";

function HelloMessage({ name }) {
  const [state, setState] = useState(0);
  return createElement(
    "div",
    {
      onClick: () => setState((c) => c + 1),
    },
    createElement("h1", {}, "Hello: ", name),
    createElement("h2", {}, "Count: ", state)
  );
}

render(
  createElement(HelloMessage, { name: "Taylor" }),
  document.getElementById("container")
);

この構文解析と変換は、@babel/preset-reactに含まれている@babel/plugin-transform-react-jsxのドキュメントの In/Out を見るとイメージが湧きます。

https://babeljs.io/docs/babel-plugin-transform-react-jsx

こちらはcreateElementの API にもちらっと書いてありました。

https://react.dev/reference/react/createElement#usage

React を読む

だいぶ React に関する基礎・周辺知識が深まってきたところで、実際のコードリーディングに再チャレンジしてみます。
初回はコード 1 行 1 行を丁寧に読み込むようにしていたのですが、ある程度大雑把に(たとえば関数の流れは if 分岐が激しいのですが、メインで通るだろうところを追っていくようにする、すぐに理解できなさそうなところは一旦飛ばしていく、など)大枠を捉えながら読むようにしました。

今回は以下 3 つのメソッドを追っていき、シンプルに要素が画面に見えるまでの処理の流れを追ってみました。

  • createElement
  • createRoot
  • createRoot.render

本記事では、もちろんすべてのコードを紹介することはできないので、3 つのメソッドの流れをおおまかに追いつつ、自分が読んできて「なるほど〜」と思った学びを紹介していきます。

createElement

まずはcreateElementを読んでみます。

https://react.dev/reference/react/createElement#creating-an-element-without-jsx

childrenProps

以下はcreateElement内のとある処理なのですが、第二引数以降の要素を children という Props に渡していることがわかります。

react/src/ReactElementProd.js
export function createElement(type, config, children) {

  /** ~~ 省略 ~~ */

  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  /** ~~ 省略 ~~ */
}

先ほどの例で言えば、createElement には子要素となる createElement が入れ子状態で入っています。よく私たちはReact.FCの関数コンポーネントを書く時に脳死でchildrenという名前の決まった子要素を渡すことがありますが、その正体がここの実装というわけでした。

createElement(
  "div",
  {
    onClick: () => setState((c) => c + 1),
  },
  createElement("h1", {}, "Hello: ", name),
  createElement("h2", {}, "Count: ", state)
);

あまり利用することはありませんでしたが、Props 未指定の場合にdefaultPropsを挿入しているような場所も見かけました。
https://ja.react.dev/reference/react/Component#static-defaultprops

react/src/ReactElementProd.js
if (type && type.defaultProps) {
  const defaultProps = type.defaultProps;
  for (propName in defaultProps) {
    if (props[propName] === undefined) {
      props[propName] = defaultProps[propName];
    }
  }
}

最終的にcreateElementは React 要素を作成して返すシンプルな関数であることがわかりました。

react/src/ReactElementProd.js
function ReactElement(type, key, ref, owner, props) {
  const element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  };
  return element;
}
package/shared/ReactSymbols.js
export const REACT_ELEMENT_TYPE: symbol = Symbol.for('react.element');

$$typeofプロパティは、React 要素とそうでない要素とを識別するために変更するため、一意のシンボルを付与しているのがわかります。$$typeofについては以下の記事を見つけました。

https://blog.stackademic.com/why-do-react-elements-have-a-typeof-property-41359181c16c

createRoot

次にcreateRootを読みます。

createRootに指定された DOM 要素(例: <div id="root"></div>)は、React が管理する世界への入り口といったところでしょうか。この DOM をルート(根)とし、React は独自のレンダリングシステムを展開しますので、特に重要な部分です。ここでは FiberRoot,FiberNode,Lane(React が管理する優先順位を示す概念)などが登場します。詳しくは流れの中で説明していきます。

createRootの処理は、大きく以下のメソッドに分かれます。

  • createContainer
  • listenToAllSupportedEvents
react-dom/src/client/ReactDOMRoot.js
export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  const root = createContainer(
    /** ~~ 省略 ~~ */
  );

  Dispatcher.current = ReactDOMClientDispatcher;

  listenToAllSupportedEvents(rootContainerElement);

  return new ReactDOMRoot(root);
}

まずはcreateContainerが何をしているのか、コードを追っていきます。
ここで、createFiberRootという関数まで辿り着くのですが、このcreateFiberRootが行う主な処理は以下です。

  1. FiberRootと呼ばれる React コンポーネントツリー全体を管理する要素を作成する
  2. HostRootFiberを作成する
  3. HostRootFiberの更新キューを初期化する
package/react-reconciler/src/ReactFiberRoot.js
export function createFiberRoot(
  containerInfo: Container,
  tag: RootTag,
  hydrate: boolean,
  initialChildren: ReactNodeList,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  concurrentUpdatesByDefaultOverride: null | boolean,
  identifierPrefix: string,
  onRecoverableError: null | ((error: mixed) => void),
  transitionCallbacks: null | TransitionTracingCallbacks,
  formState: ReactFormState<any, any> | null,
): FiberRoot {
  // 1
  const root: FiberRoot = (new FiberRootNode(
    containerInfo,
    tag,
    hydrate,
    identifierPrefix,
    onRecoverableError,
    formState,
  ): any);

  // 2
  const uninitializedFiber = createHostRootFiber(
    tag,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
  );
  root.current = uninitializedFiber;
  uninitializedFiber.stateNode = root;

  // 3
  initializeUpdateQueue(uninitializedFiber);

  return root;
}

ここで見かけたFiberRootFiberNode(FiberHostNode)の 2 つは、同じような名前なのですが機能は全く異なるので注意が必要でした。

FiberRoot, FiberNode

FiberRootは React レンダリングプロセスにおいて"管理"を担当する存在です。色々と調べてみると Fiber ツリーの上位にいると取れるような記事や図を見かけましたが、自分が感じたのはツリーの最上位というよりはツリー全体を管理するイメージでした。

createRoot時点ではまだなんの要素も渡していないため、React がレンダリングするものはありません。ですが、React は 1 つだけ Fiber (最上位の Fiber なのでHostRootFiberと呼ばれます)を作ります。このHostRootFiberには<div id="root"></div>というcreateRoot時に指定した React 世界の入り口の要素が入ります。

HostRootFiberはあくまで Fiber という感じです。これから要素の更新をかけていくと、この Fiber が DOM 要素のようにツリー上にどんどん作成されていくというイメージです。Fiber はあくまで小さな要素の塊で、その要素に関する処理を担当します。

一方FiberRootは現在の現在の Fiber ツリーの状態、それぞれの Lane の状態、Fiber に伝達するべきコンテキストなどの状態など、さまざまな状態を持っています。FiberRootはレンダリングプロセス全体と密接に関わっていそうな感じでした。

Virtual DOM

ここで気になってきたのだが、React における仮想 DOM とはFiberNodeツリーそのものなのでは?ということです。

つまり Fiber ツリーを Reconciler の仕組みで差分検出して同期し、実際の DOM に必要な差分をコミットすることこそ、仮想 DOM の正体では?という考えです。以下記事は古いですが、少しヒントになることが書かれていました。

“仮想 DOM” は特定の技術というよりむしろ 1 つのパターンなので、時たま違う意味で使われることがあります。React の世界において “仮想 DOM” という用語は通常、ユーザインタフェースを表現するオブジェクトである React 要素 と結びつけて考えられます。React は一方で、コンポーネントツリーに関する追加情報を保持するため “ファイバー (fiber)” と呼ばれる内部オブジェクトも使用します。これらも React における “仮想 DOM” 実装の一部と見なすことができます。

https://ja.legacy.reactjs.org/docs/faq-internals.html

仮想というとどこか実態のないものに思えますが、React の仮想という魔法の正体は、ReactElement化された DOM や Fiber ツリーなど、React がその仕組みの中で管理している仕組みそのものなのかなと思います。

Lane

FiberRoot について少し覗いてみると、Laneといういかにも特徴的なキーワードと出会いました。

react-reconciler/src/ReactFiberRoot.js
function FiberRootNode(
  this: $FlowFixMe,
  containerInfo: any,
  tag,
  hydrate: any,
  identifierPrefix: any,
  onRecoverableError: any,
  formState: ReactFormState<any, any> | null,
) {
  /** ~~ 省略 ~~ */

  this.pendingLanes = NoLanes;
  this.suspendedLanes = NoLanes;
  this.pingedLanes = NoLanes;
  this.expiredLanes = NoLanes;
  this.finishedLanes = NoLanes;
  this.errorRecoveryDisabledLanes = NoLanes;

  /** ~~ 省略 ~~ */
}

In more concrete React terms, an update object scheduled by setState contains a lane field, a bitmask with a single bit enabled. This replaces the update.expirationTime field in the old model.(より具体的な React 用語では、setState によってスケジュールされた更新オブジェクトには、レーン フィールド、つまり単一ビットが有効になっているビットマスクが含まれます。これは、古いモデルの update.expirationTime フィールドを置き換えます。)

https://github.com/facebook/react/pull/18796

上記の通り、Lane は主に更新イベントなどに必ず含まれており、その更新イベントの緊急性や重要度に応じて更新すぐしなくてはいけない頻度ランクが与えられるイメージです。そのランクが Lane に当たります。

具体的には、コードを読んでてわかったのは以下くらいですが、軽いイメージです。

  • SyncLane: クリックイベントを始めとした即座な反応(インタラクション)が求められるイベント
  • DefaultLane: Props の変更や状態更新

Lane について詳しくは、以下の記事を参考に色々と学ばせてもらいました。

https://zenn.dev/okmttdhr/articles/88b554ba5854f9

長くなりましたが、ここまでがcreateContainerです。

Internal Folder

createRootまで戻りまして、次に以下のコードから内部設計に着目してみたいです。

react-dom/src/client/ReactDOMRoot.js
export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  /** ~~ 省略 ~~ */

  Dispatcher.current = ReactDOMClientDispatcher;

  /** ~~ 省略 ~~ */
}

このDispatcherはインターナルなオブジェクトとして別ファイルに格納されており、このインターナルオブジェクト自体は他の場所からも参照されていました。さまざまな内部の設定を共有するために使われているようです(オブジェクト指向な多言語のような公開・非公開などの情報がやりとりできることがないので、素のオブジェクトで各所から参照するやりとりはなかなかチャレンジングな気もしますが……どうなんでしょう)。

react-dom/src/ReactDOMSharedInternals.js
const Internals: InternalsType = ({
  usingClientEntryPoint: false,
  Events: null,
  Dispatcher: {
    current: null,
  },
}: any);

また、ReactDOMClientDispatcherHostDispatcherという型をとっており、このHostDispatcherを満たす型としてファイル内をあさってみるとReactDOMFlightServerDispatcherというものも定義されていることがわかりました。

ReactDOMClientDispatcherReactDOMFlightServerDispatcherも以下のように DNS プリフェッチやプリコネクト・プリロードなどのパフォーマンス強化のための機能群です。しかし、おそらくクライアント・サーバーによって内部実装が全く異なるためこういった形で実行環境の違いによる内部実装の抽象化が行われているのでしょう。

react-dom-buildings/src/client/ReactFiberConfigDOM.js
export const ReactDOMClientDispatcher: HostDispatcher = {
  prefetchDNS,
  preconnect,
  preload,
  preloadModule,
  preinitStyle,
  preinitScript,
  preinitModuleScript,
};

React は、こういった実装差分の抽象化を至るところ・タイミングで行っている印象でした。クライアントサイド・サーバーサイドの違い吸収もそうですが、ReactNative or React なども内部の処理を抽象化しておくことにより、Reconciler や Fiber などのコア技術はまるっと同じ仕組みで利用できるように設計されています。

以下の記事でもコア技術の分離や抽象化について紹介されていました。

The reason it can support so many targets is because React is designed so that reconciliation and rendering are separate phases. The reconciler does the work of computing which parts of a tree have changed; the renderer then uses that information to actually update the rendered app.(これほど多くのターゲットをサポートできる理由は、React が調整とレンダリングが別のフェーズになるように設計されているためです。リコンサイラーは、ツリーのどの部分が変更されたかを計算する作業を行います。次に、レンダラーはその情報を使用して、レンダリングされたアプリを実際に更新します。)

This separation means that React DOM and React Native can use their own renderers while sharing the same reconciler, provided by React core.(この分離は、React DOM と React Native が、React コアによって提供される同じリコンサイラーを共有しながら、独自のレンダラーを使用できることを意味します。)

Fiber reimplements the reconciler. It is not principally concerned with rendering, though renderers will need to change to support (and take advantage of) the new architecture.(Fiber はリコンサイラーを再実装します。新しいアーキテクチャをサポート (および利用) するにはレンダラーを変更する必要がありますが、主にレンダリングには関係しません。)

https://github.com/acdlite/react-fiber-architecture?tab=readme-ov-file#reconciliation-versus-rendering

Event Delegation, Bubbling, Capture

最後はlistenToAllSupportedEventsについて見ていきます。
createRootのにおいては、ほぼ最後の方に出てくる処理となります。

react-dom/src/client/ReactDOMRoot.js
export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  const root = createContainer(
    /** ~~ 省略 ~~ */
  );

  /** ~~ 省略 ~~ */

  Dispatcher.current = ReactDOMClientDispatcher;

  /** ~~ 省略 ~~ */

  // この部分
  listenToAllSupportedEvents(rootContainerElement);

  return new ReactDOMRoot(root);
}

listenToAllSupportedEventsは、これまでもそうでしたがこれまで以上に、処理が長く追いにくかったです。

私はこのあたりではじめて、React の魔法の 1 つである、イベントデリゲーションという仕組みについて知ったので、まずはその仕組みからお伝えします。

まず前提として、 DOM におけるイベント発生には「バブリング」「キャプチャー」という大きな 2 つの概要が関わっています。以下の記事 2 つがたいへんわかりやすかったのですが、要約するとたとえば何かの要素をクリックした時に

  • Window からその要素まで DOM ツリーを辿ってイベントが発火した要素まで降りてくるフェーズをキャプチャ
  • イベントが発火した要素から Window まで DOM ツリーを辿って上っていくフェーズをバブリング

と呼びます。

https://ja.javascript.info/bubbling-and-capturing#ref-1085

https://www.w3.org/TR/DOM-Level-3-Events/
(3.1. Event dispatch and DOM event flow 部分)

例外的にこういった状態が発生しないイベントもあるそうですが、基本的にイベントが発生した際は上記のようにして伝播していきます。

フロントエンドでコードを書いていると、以下のようなコードにおいて hi!をクリックした際に「world」と「alert」が続けて出るのはバブリングの効果であり、stopPropagationメソッドを利用する機会があると思いますので、それを思い出してもらえたら納得かなと思います。

<div onClick="alert('hello')">
  <div onClick="alert('world')">hi!</div>
</div>

イベントデリゲーションは、このキャプチャ・バブリングの仕組みを利用した設計手法の 1 つで、発生させるイベント複数の要素に対してイベントハンドラを設定するのではなく、その親要素に 1 つのイベントリスナ・イベントハンドラを設定し、子要素で発生するすべてのイベントをキャッチするという方法です。

React はこの手法で root 要素に対してすべてのイベントリスナーを付与しています。どこかでイベントが発生した際にはその情報を辿って発生源を確認していき、適切な更新処理をするようになっているという形です。私たちが実際に React を書く時にはonClickなどと書いているものですから、実際には表現したイベントリスナーを React 側で頑張って登録しているのかと思いきや、React としては DOM 構築時に個別要素イベントリスナーを追加するのが大変なため、こういった賢い設計になっているようです。

root のイベントリスナーの様子

つまりlistenToAllSupportedEventsには、root として指定した要素にありとあらゆるイベントリスナーを登録していくプロセスが書かれていそうです。

React のイベントデリゲーションについては、以下の記事もわかりやすかったです。

https://qiita.com/kyntk/items/b273760c6d27e08bf53a

Event Priority

処理の中身を見ていった際に非常に特徴的だなと思ったのが、イベントリスナーの種類によって優先順位をつけ分けている場所でした。

react-dom-buildings/src/events/ReactDOMEventListener.js
export function createEventListenerWrapperWithPriority(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
): Function {
  const eventPriority = getEventPriority(domEventName);
  let listenerWrapper;
  switch (eventPriority) {
    case DiscreteEventPriority:
      listenerWrapper = dispatchDiscreteEvent;
      break;
    case ContinuousEventPriority:
      listenerWrapper = dispatchContinuousEvent;
      break;
    case DefaultEventPriority:
    default:
      listenerWrapper = dispatchEvent;
      break;
  }
  return listenerWrapper.bind(
    null,
    domEventName,
    eventSystemFlags,
    targetContainer,
  );
}

優先順位は以下の 3 つに分かれており、それぞれに対してLaneが定義されています。

  • DiscreteEventPriority: ユーザーからの入力に対して即座に反応する必要のあるもの 例:クリック, キーダウン, マウスアップなど明確な開始と終了がある場合
  • ContinuousEventPriority: 継続的に起こるもの 例:マウスの移動やスクロールなど
  • DefaultEventPriority: デフォルト
react-reconciler/src/ReactEventPriorities.js
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
react-reconciler/src/ReactFiberLane.js
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;

コンカレントモードの登場により、React は 1 つ 1 つの処理に優先順位をつけて処理する仕組みが可能になりました。イベントと一口に言っても、ユーザーのインタラクションに対して即座にフィードバックが求められるクリックなどのイベントから、スクロールイベントなど継続的に何度も発生するイベントまで、さまざまな種類のイベントがあります。そのため、こういった独自の処理が施されてイベントリスナーで登録がされるようになっているということですね。

ReactSyntheticEvent

イベントリスナーを登録する周辺をもう少し見てみます。以下の通り、必要な DOM イベントを登録するdispatchEventsForPluginsがあるのでこちらを見てみると、dispatchQueueには、extractEvents関数を経て React 共通の合成イベントと実際に処理するものが入ってきます。

react-dom-buildings/src/events/DOMPluginEventSystem.js
function dispatchEventsForPlugins(
  domEventName: DOMEventName,  // 登録するDOMイベント
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent, // Eventオブジェクト
  targetInst: null | Fiber, // イベントが発生したコンポーネントのインスタンス
  targetContainer: EventTarget, // Reactのルートコンテナ
): void {
  const nativeEventTarget = getEventTarget(nativeEvent);
  const dispatchQueue: DispatchQueue = [];
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

つまり以下のような定義においてボタンがクリックされた時には

<div className="hoge">
  <p>hello</p>
  <button onClick={() => alert("hei!")}>ボタン</button>
</div>

こんな感じのものがキューとして入っているイメージです。

{
  "event": <SyntheticMouseEvent>,
  "listeners": [func alert('hei!')]
}

この event について見てみたいのですが、ブラウザでキャッチできるイベント名やイベントの種類はブラウザ依存で様々です。そこで、ネイティブなイベントシステムと似て非なる合成イベント(ReactSyntheticEvent)という環境による差異を抽象化した React 独自定義のイベントタイプを定義しています。こういった抽象化が、開発する側はブラウザ間の違いやイベントの低レベルな扱いについてを意識することなく、アプリケーションの開発に集中することを可能にしていると思うと、 React すごいですね……。

https://ja.react.dev/reference/react-dom/components/common#react-event-object

Plugin System

イベントを登録するコード周辺では、pluginというキーワードをたびたび見かけました。フォルダ名やファイル名だけでなく、実際のメソッドなどでも利用されています。
このプラグインというワードがなぜ使われているかは、実際の DOM イベントをReactSyntheticEventに丸める以下の処理を見たことにより少し理解が深まりました。

react-dom-buildings/src/events/DOMPluginEventSystem.js
function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
) {
  SimpleEventPlugin.extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  const shouldProcessPolyfillPlugins =
    (eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
  if (shouldProcessPolyfillPlugins) {
    EnterLeaveEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    ChangeEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    SelectEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    BeforeInputEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    if (enableFormActions) {
      FormActionEventPlugin.extractEvents(
        dispatchQueue,
        domEventName,
        targetInst,
        nativeEvent,
        nativeEventTarget,
        eventSystemFlags,
        targetContainer,
      );
    }
  }
}

この中でextractEventsメソッドを呼んでいる以下のモジュールは、DOMPluginEventSystem.jsの存在しているディレクトリ配下に作られているeventsディレクトリ内に実際の実装がありました。

  • SimpleEventPlugin
  • EnterLeaveEventPlugin
  • ChangeEventPlugin
  • SelectEventPlugin
  • BeforeInputEventPlugin
  • FormActionEventPlugin

ここでプラグインとは、利用側がメソッドを呼ぶ(アタッチする)だけで利用可能になる拡張可能な状態のモジュールを指します。React は実際にブラウザごとに微妙に挙動の異なるイベントをReactSyntheticEventに変換する地道で緻密で複雑な処理をプラグインシステムとして切り出し、コードの全体的な可読性を上げていると感じました(プラグインの中の実装は全く理解できませんでした……)。

このプラグインにおいてSimpleEventPluginは基本的なイベントタイプ(クリック、タッチなど)を処理しています。
たとえばクリックイベントなどは、SyntheticMouseEventというイベントに抽象化されています。

react-dom-buildings/src/events/plugins/SimpleEventPlugin.js
function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
): void {
  switch (domEventName) {
    case 'keypress':
    /** ~~ 省略 ~~ **/
    case 'click':
    // Firefox creates a click event on right mouse clicks. This removes the
    // unwanted click events.
    // TODO: Fixed in https://phabricator.services.mozilla.com/D26793. Can
    // probably remove.
    if (nativeEvent.button === 2) {
      return;
    }
    /** falls through **/
    case 'auxclick':
    case 'dblclick':
    case 'mousedown':
    case 'mousemove':
    case 'mouseup':
    // TODO: Disabled elements should not respond to mouse events
    /** falls through **/
    case 'mouseout':
    case 'mouseover':
    case 'contextmenu':
      SyntheticEventCtor = SyntheticMouseEvent;
      break;

SimpleEventPlugin以外のプラグインは、さらにブラウザによってイベントのタイミング等の差異がある場合でも、どのブラウザでも一貫した挙動を担保できるような機能を提供することになります。たとえばChangeEventPluginは、入力要素(特に <input><textarea><select>タグ)における値の変更を検知した際にブラウザごとの挙動の差分を吸収するための複雑な実装がコードになっています。要素に文字が入力された時のイベント発火タイミング差異や、サポートが不完全で挙動が他のブラウザと異なる場合などに備えています。

このあたりの泥くさい実装が、複雑な ブラウザ API との通信やブラウザごとの差異を吸収して細かく色々してくれるおかげで、私たちは差分を意識せずにonClickなどのイベントを使えるわけですね。

render

最後になりましたがrenderを見ていきます。
renderは、createRootで作成された React ルートに生えているメソッドです。
呼ばれている関数は 1 つでupdateContainerのみです。

react-dom/src/client/ReactDOMRoot.js
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render =
  function (children: ReactNodeList): void {
    const root = this._internalRoot;
    updateContainer(children, root, null, null);
  };

Micro Task

処理を追っていくと、renderで何をしているのかの想像がなんとなくついてきました。createUpdateで更新キューを作成し、その中にrenderの引数に指定された要素を入れ、エンキューします。この動きはコンポーネントの状態が更新されて再レンダーが起こった時も同じなので、初期レンダリングと再レンダーで内部的なコードの構造が同じ(更新キューを入れているだけ)ことがわかります。

react-reconciler/src/ReactFiberReconciler.js
export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function,
): Lane {
  const current = container.current;
  const lane = requestUpdateLane(current);

  const update = createUpdate(lane);
  update.payload = {element};

  const root = enqueueUpdate(current, update, lane);
  if (root !== null) {
    scheduleUpdateOnFiber(root, current, lane);
  }

  return lane;
}

最後のscheduleUpdateOnFiberについて見ていきます。

js
export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane,
) {
  markRootUpdated(root, lane);
  ensureRootIsScheduled(root);
}

markRootUpdatedFiberRootpendingLanesに代入し、すべての Fiber を管理しているFiberRootに待機状態を知らせます。

markRootUpdated 関数による処理は、React の更新サイクルにおける非常に重要な部分を担います。レーンシステムによる優先順位付けは、React が同時に多数の更新を扱う上で、どの更新を先に処理するか(または遅らせるか)を決定する基盤となります。これは、高いパフォーマンスとユーザーエクスペリエンスを維持しつつ、効率的なレンダリングを可能にします。

ensureRootIsScheduled は更新をスケジュールに追加し優先順位などを加味しつつタスクを実行するプロセスです。

react-reconciler/src/ReactFiberRootScheduler.js
export function ensureRootIsScheduled(root: FiberRoot): void {
  f (__DEV__ && ReactCurrentActQueue.current !== null) {
    // We're inside an `act` scope.
    if (!didScheduleMicrotask_act) {
      didScheduleMicrotask_act = true;
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  } else {
    if (!didScheduleMicrotask) {
      didScheduleMicrotask = true;
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  }
  scheduleTaskForRootDuringMicrotask(root, now());
}

スケジューリングについて、scheduleTaskForRootDuringMicrotaskの実装である以下にざっくりした流れを載せます。

react-reconciler/src/ReactFiberRootScheduler.js
function scheduleTaskForRootDuringMicrotask(
  root: FiberRoot,
  currentTime: number,
): Lane {
  // 現在進行中の作業のルートとレーンを取得
  const workInProgressRoot = getWorkInProgressRoot();
  const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();

  // 次に処理するべきレーンの決定
  const nextLanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );

  // レーンから優先順位を確認
  let schedulerPriorityLevel;
  switch (lanesToEventPriority(nextLanes)) {
    case DiscreteEventPriority:
      schedulerPriorityLevel = ImmediateSchedulerPriority;
      break;
    case ContinuousEventPriority:
      schedulerPriorityLevel = UserBlockingSchedulerPriority;
      break;
    case DefaultEventPriority:
      schedulerPriorityLevel = NormalSchedulerPriority;
      break;
    case IdleEventPriority:
      schedulerPriorityLevel = IdleSchedulerPriority;
      break;
    default:
      schedulerPriorityLevel = NormalSchedulerPriority;
      break;
  }

  // 新しいタスクをスケジュール
  const newCallbackNode = scheduleCallback(
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root),
  );

  // FiberRootの状態更新
  root.callbackPriority = newCallbackPriority;
  root.callbackNode = newCallbackNode;
  return newCallbackPriority;
}

実際に作業を実行するスタート地点はperformConcurrentWorkOnRootのように思えます(これ結構理解曖昧で、間違ってたらすみません 🙇)。
しかしあくまでもこのrenderは更新プロセスを開始するトリガーを引くだけにすぎず、どうやらスケジュール更新を待機しておいてキャッチするやつは別にいそうだなと思っています。

ここで関数においてMicrotaskという文字列を見つつ色々と調べて見た結果、他の関数実行を妨げないように小さく処理するqueueMicrotaskが JavaScript の API として用意されていることがわかりました。この公式ドキュメントをみてみると、これまでみてきたタスクやキューという見慣れたキーワードも存在し、現在の React の設計と似ているような感じがしました。

https://developer.mozilla.org/ja/docs/Web/API/HTML_DOM_API/Microtask_guide

ただ、コードをよくよく追ってみますと、queueMicrotask自体が React 内で利用されていることが、あるにはあるのですが、この処理はどちらかというと即時の実行をスケジュールとして使われているようです。先ほどのensureRootIsScheduledの中身をよく見てみました。こんな感じです。

react-reconciler/src/ReactFiberRootScheduler.js
export function ensureRootIsScheduled(root: FiberRoot): void {
  // __DEV__モード(開発モード)でのテストやデバッグ時のactブロックの操作に関わっていそう
  if (__DEV__ && ReactCurrentActQueue.current !== null) {
    // We're inside an `act` scope.
    if (!didScheduleMicrotask_act) {
      didScheduleMicrotask_act = true;
      // queueMicrotaskを利用するスケジューリング方法
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  } else {
    if (!didScheduleMicrotask) {
      didScheduleMicrotask = true;
      // queueMicrotaskを利用するスケジューリング方法
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  }
  // 本番ではもっぱらこちらを通る
  scheduleTaskForRootDuringMicrotask(root, now());
}

React がどのような状況で利用されているかにより、スケジューリング方法も変わっているみたいですね。
JavaScript が公開している APIqueueMicrotaskは本番のような環境では実行されておらず、ではどうしているかというと、React が独自にパッケージ化したreact-schedulerにてスケジューリングを実現しているみたいです。

scheduler package

scheduleTaskForRootDuringMicrotaskでスケジューリングした更新イベントは、どうやらreact-schedulerがキャッチしそうな予感がしました。

Didact でいうrequestIdleCallbackのような、スケジューリングした要素をキャッチする待機プロセスはどういう実装なのか。renderが最後のあたりで実行していたscheduleCallbackの正体はなんなのか。といった疑問から深掘りをしました)

そこで、React を読む長い旅(読んだ機能は React 全体におけるほんの 1%程度だとは思いますが……)も最後ということで、ほんの少しだけreact-schedulerをのぞいて見ました。

このschedulerはパッケージとして外部公開されており、スケジューリング用途として使えるようにされていました。逆にいえば、ここでは更新をキャッチしてコールバックを実行するプロセスしかありません。このあたりも、React 全体で責務を分離している感じがして、すごいな〜と改めて思いました。

スケジューラーもまたネイティブアプリでも Web ブラウザでも、どんなブラウザでも動くように設計しているらしく、様々な低レイヤーの API を抽象化するようになっていました。React 本当にすごい……。魔法の裏側を感じて、ヒリヒリしました。

で、Web ブラウザのイベントループの中身の実装がどこにあるかと探していたら(requestIdleCallbacksetTimeout?)ありました。メッセージループという方法を利用しているみたいです。理由はコメントアウトにある通り(We prefer MessageChannel because of the 4ms setTimeout clamping.)ループ処理が優秀みたいですね。

scheduler/src/Scheduler.js
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null);
};

メッセージループの仕組みについては、以下の記事がおすすめです。

https://javascript.plainenglish.io/scheduling-in-react-16-x-a6108db99208

おわりに

ここまで長きにわたりお付き合いいただけた方、本当にありがとうございました!

React が理解できたかというとかなり微妙なところでしたが、React の魔法に関するちょっとの理解が進んだのと、基盤ライブラリならではの設計が学べたので、とても有意義な勉強時間になったと思っています。renderがスケジューリングされた先で、一体どんなふうにレンダリングが行われるのかについては、数々のドキュメントが存在しますが、機会があったら自分でも深く読み込んでみたいなと思います。また、hooks 実装の中身なども気になるところではあるなと思いました。

いつもの

https://www.wantedly.com/projects/1130967

https://www.wantedly.com/projects/768663

https://www.wantedly.com/projects/1244229

Discussion