Reactを読んでまほうの正体を(少し)理解する
はじめに
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 の写経です。
React をわかりやすく理解するための、JavaScript のミニマムな実装サンプルを作ってくださっています(流れの説明もなんともオシャレ)。まず私はこのサイトをガッツリ読み込むことから始めましたが、それでもわからないので処理の流れが追えるように、ひたすら codesandbox にデバッグコードを仕込みまくって挙動確認をしました。大変お世話になりました。後続の流れにおいて実際に React を読んだ際に、関数名などが同じ(あるいは似ている)メソッドがあったりして、理解の助けになりました。
このミニマム実装では、大枠の 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 の仕組みについては多くの良質な記事が出回っています。やや古いのですが、以下はとてもわかりやすかったです。
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 を見るとイメージが湧きます。
こちらはcreateElement
の API にもちらっと書いてありました。
React を読む
だいぶ React に関する基礎・周辺知識が深まってきたところで、実際のコードリーディングに再チャレンジしてみます。
初回はコード 1 行 1 行を丁寧に読み込むようにしていたのですが、ある程度大雑把に(たとえば関数の流れは if 分岐が激しいのですが、メインで通るだろうところを追っていくようにする、すぐに理解できなさそうなところは一旦飛ばしていく、など)大枠を捉えながら読むようにしました。
今回は以下 3 つのメソッドを追っていき、シンプルに要素が画面に見えるまでの処理の流れを追ってみました。
- createElement
- createRoot
- createRoot.render
本記事では、もちろんすべてのコードを紹介することはできないので、3 つのメソッドの流れをおおまかに追いつつ、自分が読んできて「なるほど〜」と思った学びを紹介していきます。
createElement
まずはcreateElement
を読んでみます。
childrenProps
以下はcreateElement
内のとある処理なのですが、第二引数以降の要素を children という Props に渡していることがわかります。
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
を挿入しているような場所も見かけました。
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
最終的にcreateElement
は React 要素を作成して返すシンプルな関数であることがわかりました。
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;
}
export const REACT_ELEMENT_TYPE: symbol = Symbol.for('react.element');
$$typeof
プロパティは、React 要素とそうでない要素とを識別するために変更するため、一意のシンボルを付与しているのがわかります。$$typeof
については以下の記事を見つけました。
createRoot
次にcreateRoot
を読みます。
createRoot
に指定された DOM 要素(例: <div id="root"></div>
)は、React が管理する世界への入り口といったところでしょうか。この DOM をルート(根)とし、React は独自のレンダリングシステムを展開しますので、特に重要な部分です。ここでは FiberRoot
,FiberNode
,Lane
(React が管理する優先順位を示す概念)などが登場します。詳しくは流れの中で説明していきます。
createRoot
の処理は、大きく以下のメソッドに分かれます。
- createContainer
- listenToAllSupportedEvents
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
が行う主な処理は以下です。
-
FiberRoot
と呼ばれる React コンポーネントツリー全体を管理する要素を作成する -
HostRootFiber
を作成する -
HostRootFiber
の更新キューを初期化する
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;
}
ここで見かけたFiberRoot
とFiberNode(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” 実装の一部と見なすことができます。
仮想というとどこか実態のないものに思えますが、React の仮想という魔法の正体は、ReactElement
化された DOM や Fiber ツリーなど、React がその仕組みの中で管理している仕組みそのものなのかなと思います。
Lane
FiberRoot
について少し覗いてみると、Lane
といういかにも特徴的なキーワードと出会いました。
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 フィールドを置き換えます。)
上記の通り、Lane
は主に更新イベントなどに必ず含まれており、その更新イベントの緊急性や重要度に応じて更新すぐしなくてはいけない頻度ランクが与えられるイメージです。そのランクが Lane
に当たります。
具体的には、コードを読んでてわかったのは以下くらいですが、軽いイメージです。
- SyncLane: クリックイベントを始めとした即座な反応(インタラクション)が求められるイベント
- DefaultLane: Props の変更や状態更新
Lane について詳しくは、以下の記事を参考に色々と学ばせてもらいました。
長くなりましたが、ここまでがcreateContainer
です。
Internal Folder
createRoot
まで戻りまして、次に以下のコードから内部設計に着目してみたいです。
export function createRoot(
container: Element | Document | DocumentFragment,
options?: CreateRootOptions,
): RootType {
/** ~~ 省略 ~~ */
Dispatcher.current = ReactDOMClientDispatcher;
/** ~~ 省略 ~~ */
}
このDispatcher
はインターナルなオブジェクトとして別ファイルに格納されており、このインターナルオブジェクト自体は他の場所からも参照されていました。さまざまな内部の設定を共有するために使われているようです(オブジェクト指向な多言語のような公開・非公開などの情報がやりとりできることがないので、素のオブジェクトで各所から参照するやりとりはなかなかチャレンジングな気もしますが……どうなんでしょう)。
const Internals: InternalsType = ({
usingClientEntryPoint: false,
Events: null,
Dispatcher: {
current: null,
},
}: any);
また、ReactDOMClientDispatcher
はHostDispatcher
という型をとっており、このHostDispatcher
を満たす型としてファイル内をあさってみるとReactDOMFlightServerDispatcher
というものも定義されていることがわかりました。
ReactDOMClientDispatcher
もReactDOMFlightServerDispatcher
も以下のように DNS プリフェッチやプリコネクト・プリロードなどのパフォーマンス強化のための機能群です。しかし、おそらくクライアント・サーバーによって内部実装が全く異なるためこういった形で実行環境の違いによる内部実装の抽象化が行われているのでしょう。
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 はリコンサイラーを再実装します。新しいアーキテクチャをサポート (および利用) するにはレンダラーを変更する必要がありますが、主にレンダリングには関係しません。)
Event Delegation, Bubbling, Capture
最後はlistenToAllSupportedEvents
について見ていきます。
createRoot
のにおいては、ほぼ最後の方に出てくる処理となります。
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 ツリーを辿って上っていくフェーズをバブリング
と呼びます。
(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 構築時に個別要素イベントリスナーを追加するのが大変なため、こういった賢い設計になっているようです。
つまりlistenToAllSupportedEvents
には、root として指定した要素にありとあらゆるイベントリスナーを登録していくプロセスが書かれていそうです。
React のイベントデリゲーションについては、以下の記事もわかりやすかったです。
Event Priority
処理の中身を見ていった際に非常に特徴的だなと思ったのが、イベントリスナーの種類によって優先順位をつけ分けている場所でした。
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: デフォルト
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000;
export const DefaultLane: Lane = /* */ 0b0000000000000000000000000100000;
コンカレントモードの登場により、React は 1 つ 1 つの処理に優先順位をつけて処理する仕組みが可能になりました。イベントと一口に言っても、ユーザーのインタラクションに対して即座にフィードバックが求められるクリックなどのイベントから、スクロールイベントなど継続的に何度も発生するイベントまで、さまざまな種類のイベントがあります。そのため、こういった独自の処理が施されてイベントリスナーで登録がされるようになっているということですね。
ReactSyntheticEvent
イベントリスナーを登録する周辺をもう少し見てみます。以下の通り、必要な DOM イベントを登録するdispatchEventsForPlugins
があるのでこちらを見てみると、dispatchQueue
には、extractEvents
関数を経て React 共通の合成イベントと実際に処理するものが入ってきます。
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 すごいですね……。
Plugin System
イベントを登録するコード周辺では、plugin
というキーワードをたびたび見かけました。フォルダ名やファイル名だけでなく、実際のメソッドなどでも利用されています。
このプラグインというワードがなぜ使われているかは、実際の DOM イベントをReactSyntheticEvent
に丸める以下の処理を見たことにより少し理解が深まりました。
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
というイベントに抽象化されています。
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
のみです。
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render =
function (children: ReactNodeList): void {
const root = this._internalRoot;
updateContainer(children, root, null, null);
};
Micro Task
処理を追っていくと、render
で何をしているのかの想像がなんとなくついてきました。createUpdate
で更新キューを作成し、その中にrender
の引数に指定された要素を入れ、エンキューします。この動きはコンポーネントの状態が更新されて再レンダーが起こった時も同じなので、初期レンダリングと再レンダーで内部的なコードの構造が同じ(更新キューを入れているだけ)ことがわかります。
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
について見ていきます。
export function scheduleUpdateOnFiber(
root: FiberRoot,
fiber: Fiber,
lane: Lane,
) {
markRootUpdated(root, lane);
ensureRootIsScheduled(root);
}
markRootUpdated
はFiberRoot
のpendingLanes
に代入し、すべての Fiber を管理しているFiberRoot
に待機状態を知らせます。
markRootUpdated
関数による処理は、React の更新サイクルにおける非常に重要な部分を担います。レーンシステムによる優先順位付けは、React が同時に多数の更新を扱う上で、どの更新を先に処理するか(または遅らせるか)を決定する基盤となります。これは、高いパフォーマンスとユーザーエクスペリエンスを維持しつつ、効率的なレンダリングを可能にします。
ensureRootIsScheduled
は更新をスケジュールに追加し優先順位などを加味しつつタスクを実行するプロセスです。
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
の実装である以下にざっくりした流れを載せます。
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 の設計と似ているような感じがしました。
ただ、コードをよくよく追ってみますと、queueMicrotask
自体が React 内で利用されていることが、あるにはあるのですが、この処理はどちらかというと即時の実行をスケジュールとして使われているようです。先ほどのensureRootIsScheduled
の中身をよく見てみました。こんな感じです。
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 ブラウザのイベントループの中身の実装がどこにあるかと探していたら(requestIdleCallback
?setTimeout
?)ありました。メッセージループという方法を利用しているみたいです。理由はコメントアウトにある通り(We prefer MessageChannel because of the 4ms setTimeout clamping.)ループ処理が優秀みたいですね。
// 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);
};
メッセージループの仕組みについては、以下の記事がおすすめです。
おわりに
ここまで長きにわたりお付き合いいただけた方、本当にありがとうございました!
React が理解できたかというとかなり微妙なところでしたが、React の魔法に関するちょっとの理解が進んだのと、基盤ライブラリならではの設計が学べたので、とても有意義な勉強時間になったと思っています。render
がスケジューリングされた先で、一体どんなふうにレンダリングが行われるのかについては、数々のドキュメントが存在しますが、機会があったら自分でも深く読み込んでみたいなと思います。また、hooks 実装の中身なども気になるところではあるなと思いました。
いつもの
Discussion