React ソースコード読み - ReactDOM.render

はじめに

ReactとはUIを記述するためのjsライブラリです。

Reactは仮想DOMと呼ばれる内部オブジェクトによってアプリケーションの状態を保持します。

アプリケーションのデータが変更された時、Reactは仮想DOMを丸ごと作り直して、 必要な箇所のみ実際のDOMを更新します(この処理は差分検出処理と呼ばれています)。

そのため開発者は自分でDOMを操作することなく、宣言的にUIを記述することができます。

この差分検出処理などの内部処理がソースコード上でどう実装されているのか、気になって調べたものをブログにまとめていきます。

この記事では ReactDOM.render の実装を追っていきます。

バージョン

この記事で扱うReactのバージョンは16.13.1です。

参考文献

この記事を書くにあたって、ソースコードの他に以下の資料を参考にしました。

なお、以下の内容は独学で調べたもので、またかなり端折っているので正確でない部分が多々あると思います。間違いなどありましたらご指摘いただければ幸いです。

概要

ReactDOM.render は以下のように用いられ、初期表示処理のエントリーポイントとなります。

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

ReactDOM.render は以下のように定義されます。

ReactDOM.render(element, container[, callback])

1個目の引数でコンポーネント、2個目の引数でDOMノードを受け取ります。

このメソッドが呼ばれると、Reactは以下のような流れでコンポーネントからDOMを生成します。

React element -> Fiber node -> DOM node

DOMノードの生成が終わると、それをcontainerに挿入します。

React element

React elementはコンポーネントを表す内部オブジェクトで、通常jsxから生成されます。

Reactでは普通jsxを使ってコンポーネントを記述しますが、jsxはコンパイル時に React.createElement の呼び出しに変換されます。

React.createElementReact element を返す関数です。

React elementはプレーンなjsオブジェクトで、コンポーネントのタグ名やpropsを保持します。

{
  $$typeof: REACT_ELEMENT_TYPE,
  type: type,
  key: key,
  ref: ref,
  props: props,
  _owner: owner,
}

これによってReactは(DOMを触ることなく)アプリケーションを記述します。

Fiber

React elementはrender時にただちにDOMノードへと変換されるのではなく、 Fiberノードと呼ばれる別の内部オブジェクトに変換されます。

FiberノードはReact elementと同様にコンポーネントを表すjsオブジェクトですが、もっと多くの情報を含んでいます。

  • 子・兄弟・親ノードへの参照
  • すでにrenderされたprops
  • これからrenderされるprops
  • 後で実行する副作用

以下が Fiberノードの定義 です。

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  // Effects
  this.effectTag = NoEffect;
  this.nextEffect = null;

  this.firstEffect = null;
  this.lastEffect = null;

  this.expirationTime = NoWork;
  this.childExpirationTime = NoWork;

  this.alternate = null;
  // ...
}

コンポーネントと同様に、Fiberノードは木を構成します(ここではFiberツリーと呼びます)。

Fiberの設計資料の中で、Fiberは「コンポーネントインスタンス、また仮想的なスタックフレーム」に対応すると説明されています。

コンポーネントインスタンスはともかく、スタックフレームというのはどこから出てきたのでしょうか?

ここからは推測混じりになってしまうのですが、Fiberによる実装を導入する前、Reactはコンポーネントのツリーを再帰的、かつ同期的に走査していたというのが背景にあると思います。

再帰的に関数を呼び出すと、スタックフレームがコールスタックに積まれていきます。jsはスタックが空でないとイベントキューを処理しないため、複雑なツリーを処理するとUIがブロックされてしまい、ユーザー体験がよくありませんでした。

一方、Fiberでは木はwhileループによって走査され、各コンポーネントインスタンスはヒープに存在します。また、Fiberでは優先度の低い処理を中断して後回しにするという風に非同期的に処理を行うことができます。

このようなFiberと以前の実装の比較が念頭にあって、スタックにないけどスタックフレームと同様に処理の一単位になっている、という意味を込めて「仮想的なスタックフレーム」と言っているのだと考えています。

なお、この辺りの背景についてはこの記事がとても参考になります。

実装に移ると、Reactはrender時に二種類のFiberツリーを保持します。

一つ目がcurrentと呼ばれるFiberツリーで、現在のアプリケーションの状態を表します。

二つ目がworkInProgressと呼ばれるFiberツリーで、これから描画されるアプリケーションの状態を表します。

render時に、Reactは基本的に全てのコンポーネントに対してFiberノードを生成して、workInProgressを一から作り直します(この処理はDOMを作り直すのに比べて軽量だとされています)。

そして、currentとworkInProgressを比較して、差分がある(DOMを更新するという副作用が必要である)部分についてのみ、副作用を実行します。

render

上述したように、render時にReactはFiberツリーの生成とDOMの更新を行います。

この処理は以下の2ステップに分けて行われます。

  1. renderフェーズ
    • 新しい状態を反映したFiberツリー(workInProgress)を生成します。
  2. commitフェーズ
    • workInProgressの中で、副作用が必要なノードについてのみ、副作用を実行します。

ソースコード

サンプルアプリケーションを見ながら、ソースコード上の処理を追っていきます。

サンプルアプリケーション

以下のカウンターアプリケーションを例として処理を追っていきます。

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

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
}

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

Appコンポーネントは以下のような木構造になっています。

App
└──div
   ├──p
   └──button

ReactDOM.render

初期表示時の処理を追っていきます。

1. renderフェーズ

Fiberツリーを生成し、DOMノードに紐付けます(これをマウントと呼びます)。

  • legacyRenderSubtreeIntoContainer

    root = container._reactRootContainer = legacyCreateRootFromDOMContainer(...)
    // このfiberRootは後でperformSyncWorkOnRootに渡されます。
    fiberRoot = root._internalRoot
    // ...
    updateContainer(children, fiberRoot, parentComponent, callback);
    
    • サンプルアプリケーションの場合、containerはid=rootのDOMノードです。
    • なので、マウントされたツリーは以下のようにしてブラウザのコンソールから眺めることができます。

      document.getElementById('root')._reactRootContainer
      
  • legacyCreateRootFromDOMContainer

    • Fiberツリーのルートノードを作ります。

      const uninitializedFiber = createHostRootFiber(tag);
      // ...
      initializeUpdateQueue(uninitializedFiber);
      
    • createHostRootFiber

    • initializeUpdateQueue
      • updateQueueを初期化します。
        • updateQueueはupdateオブジェクトのキューで、updateオブジェクトには後でrenderするReact elementが入ります。
        • この時点では、updateQueueのフィールドはすべてnullになっています。
  • updateContainer

    const update = createUpdate(...)
    // renderに渡されたelementをupdate.payloadに代入します。
    update.payload = {element};
    // ...
    enqueueUpdate(...)
    scheduleWork(...)
    
  • enqueueUpdate

    // current.updateQueue.shared.pending.payloadの中に、renderに渡されたelementが入ります。
    // これが後のprocessUpdateQueueで処理されます。
    current.updateQueue.shared.pending = update
    
  • scheduleUpdateOnFiber

    • これは updateContainer 内の scheduleWork の実体です。

      // このrootはlegacyCreateRootFromDOMContainerで生成されたfiberRoot.currentです。
      performSyncWorkOnRoot(root)
      
  • performSyncWorkOnRoot

    prepareFreshStack(...)
    // ...
    do {
      try {
        workLoopSync();
        break;
      } catch (thrownValue) {
        handleError(root, thrownValue);
      }
    } while (true)
    // ...
    // このfinishedWorkが後のcommitフェーズでDOMに挿入されます。
    // current.alternateはworkInProgressを参照しています。
    root.finishedWork = (root.current.alternate: any);
    
  • prepareFreshStack

    • workInProgressを生成します。

      // workInProgressはモジュール内のトップレベルスコープに属します。
      workInProgress = createWorkInProgress(...)
      
    • createWorkInProgress

      • currentを元に、workInProgressを作成します。

        workInProgress = createFiber(...)
        workInProgress.updateQueue = current.updateQueue
        // currentとworkInProgressはalternateによってお互いを参照します。
        current.alternate = workInProgress
        workInProgress.alternate = current
        // ...
        return workInProgress
        
  • workLoopSync

    while (workInProgress !== null) {
      workInProgress = performUnitOfWork(workInProgress);
    }
    
    • elementツリーの各ノードに対して繰り返しperformUnitOfWorkを実行します。
      • performUnitOfWorkはworkInProgressの子を返します。
    • このようにReactはwhileループによって木を走査しています。
      • 概要のところでチラッと触れた部分です。
  • performUnitOfWork

    // このnextはunitOfWorkの子です。
    next = beginWork(current, unitOfWork, renderExpirationTime);
    // ...
    if (next === null) {
      next = completeUnitOfWork(unitOfWork);
    }
    // ...
    return next
    
    • workInProgressに対してbeginWorkを実行します。
    • beginWorkは、渡されたコンポーネントについて必要な処理を行った後、そのコンポーネントの子を返します。
    • 葉まで到達した場合は、completeUnitOfWork(後述)を実行します。

    • beginWork (root)

      • 最初にrootについてbeginWorkを実行します。
      • beginWorkの中には長いswitch文があって、workInProgressの種類ごとに異なるupdate関数を実行します。

        • ここで実行されるのはupdateHostRootです。

          // ...
          return updateHostRoot(...)
          
      • updateHostRoot

        processUpdateQueue(...)
        //...
        reconcileChildren(..., workInProgress, ...)
        return workInProgress.child
        
      • processUpdateQueue

        • enqueueUpdate内で生成したupdateオブジェクトを処理していきます。

          baseQueue = queue.shared.pending
          
        • baseQueue内のupdateを処理します。

          • ただし、優先度の低いupdateはスキップします。
        • updateを元にして、stateを更新します。

          newState = getStateFromUpdate(..., update, ...)
          
        • getStateFromUpdate

          • ここでは、updateContainer内でpayloadに代入された {element} をそのまま返しています。
        • workInProgressに更新したstateを代入します。

          workInProgress.memoizedState = newState
          
      • reconcileChildren

        • React elementからfiberを生成し、workInProgress.childに代入します。
          • このelementは、元々はupdateHostRootの中で代入されたworkInProgress.memoizedState.elementです。
        • この際にfiberのeffectTagに副作用をセットします。

          • effectTagとは後で実行する副作用の種類を表します。
          • これによって、後のcommitフェーズでDOMに挿入される(副作用を持つ)ようになります。

            workInProgress.child = reconcileChildFibers(workInProgress, ...)
            
        • reconcileChildFibers

          • reconcileSingleElement

            • createFiberFromElement

              // このpendingPropsが次のbeginWorkで処理される。
              // elementは、もともとworkInProgress.memoizedState.elementだったもの。
              pendingProps = element.props
              
          • placeSingleChild

            // 後で実行する副作用のタグをセットします。
            newFiber.effectTag = Placement;
            
    • beginWork (App)

      • rootのworkInProgress.childはAppなので、次はAppに対してbeginWorkが実行されます。

        // ...
        // 引数にworkInProgress.typeを渡す(typeにはAppが入っている)
        return mountIndeterminateComponent(...)
        
      • mountIndeterminateComponent

        • クラスコンポーネントまたは関数コンポーネントからReact elementを生成し、workInProgress.childに代入します。

          // このpendingPropsは前のbeginWorkのなかで設定されたもの。
          const props = workInProgress.pendingProps;
          // ...
          // 引数に渡されたComponentとpropsからreact elementを作る。
          value = renderWithHooks(..., Component, props, ...)
          // ...
          // react elementからfiberを生成し、workInProgress.childに代入する。
          reconcileChildren(..., value, ...)
          // ...
          return workInProgress.child;
          
      • renderWithHooks

        // childrenにはreact elementが入る
        children = Component(props, ...)
        ...
        return children
        
    • beginWork (div)

      • Appの次はdivに対して実行されます。

        • div以降のノードに対するbeginWorkは省略します。

          // ...
          return updateHostComponent(...)
          
      • updateHostComponent

        // 今までと同様に、workInProgress.childを更新する。
        reconcileChildren(..., workInProgress, ...)
        return workInProgress.child
        
    • completeUnitOfWork

      • completeWork
        • もしすでにDOMノードがあれば、差分だけ更新します。

          • 初期表示時なので、ここの処理は実行されません。
          • updateHostComponent

            • 新旧のpropsを比較し、差分があればworkInProgressのupdateQueueに追加します。
            • これは導入で紹介した差分検出処理の一つだと思います。

              const updatePayload = prepareUpdate(...);
              workInProgress.updateQueue = (updatePayload: any);
              // ...
              markUpdate(workInProgress);
              
          • prepareUpdate

            • diffProperties
              • 新旧のpropsの差分を返す。
              • oldPropsはもともとcurrent.memoizedPropsだったもの。
              • newPropsはもともとworkInProgress.pendingPropsだったもの。
          • markUpdate
            • effectTagをセットします。

              workInProgress.effectTag |= Update;
              
        • すでにDOMノードがなければ、新しく生成します。

          • 初期表示時なので、こっちの処理が実行されます。

            // DOMノードを生成
            instance = createInstance(...)
            // ...
            // workInProgressに代入
            workInProgress.stateNode = instance
            
          • createInstance

            • ブラウザ・ネイティブなどの実行環境(ホストと呼ばれます)によって異なる関数が渡されます。以下はブラウザ(ReactDOM)の実装です。
            • createElement

2. commitフェーズ

renderフェーズで作られたworkInProgressを元に、副作用を実行していきます。

この時、FiberノードのnextEffectを辿ることで、副作用が必要なFiberノードのみを走査します。

  • finishSyncRender
  • commitRoot
  • commitRootImpl

    // root.finishedWorkはrenderフェーズのperformSyncWorkOnRootで代入されたものです。
    const finishedWork = root.finishedWork;
    // ...
    let firstEffect;
    firstEffect = finishedWork;
    // ...
    nextEffect = firstEffect;
    
  • commitMutationEffects

    • effectTagの種類ごとに異なる関数を呼び出します。
    • ここでは以下の関数が呼ばれます。

      commitPlacement(nextEffect);
      
  • commitPlacement

    insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
    
  • insertOrAppendPlacementNodeIntoContainer

    const stateNode = isHost ? node.stateNode : node.stateNode.instance;
    appendChildToContainer(parent, stateNode);
    
  • appendChildToContainer

    • ここではじめてDOMノードが挿入されます。

      parentNode = container;
      // このchildはroot.finishedWork.stateNodeだったものです。
      parentNode.appendChild(child);
      

state変更時

せっかくなので、ボタンをクリックしてstateが変更した時の処理も追っていきます(力尽きたので、流れだけ、、、)

カウンターのボタンをクリックすると、handleClickが実行されて、countがインクリメントされます。

const handleClick = () => {
  setCount(count + 1);
}

更新されたcountは、 dispatchAction にactionという引数として渡されます。

dispatchActionは渡されたactionを元にupdateオブジェクトを生成し、引数の queue.pending に代入します。

var update = {...};
queue.pending = update;

なお、このqueueは最初に useState を実行した際に、 mountState の中でdispatchActionに対してbindされたものです。

dispatchActionの最後では scheduleUpdateOnFiber が呼ばれて、いろいろあって最終的にperformSyncWorkOnLoopが呼ばれます。

この関数はReactDOM.render内で呼ばれた時と同じように、rootからworkInProgressを更新していきます。

rootを処理してAppの番が来ると、 updateFunctionComponent が呼ばれて、Appコンポーネントの関数が実行されます。

Appコンポーネントの中では、まず useState を実行します。

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

useState を実行すると、なんやかんやあって updateReducer が呼ばれます。

// この中にはdispatchActionで生成されたupdateオブジェクトが入ります。
let pendingQueue = queue.pending;

この後、初期表示時のprocessUpdateQueueと同じ要領でupdateオブジェクトから新しいstateを作って更新します。

// newStateにはインクリメントされた値が入ります。
hook.memoizedState = newState;
// ...
// 以下がuseStateの返り値になります。
// hook.memoizedStateがcountに、dispatchがsetCountに代入されます。
return [hook.memoizedState, dispatch];

あとは、新しくなったcountを元にReact elementを返すだけです。

// 以下のcountはインクリメントされた値になっています
const handleClick = () => {
  setCount(count + 1);
}

return (
  <div>
    <p>You clicked {count} times</p>
    <button onClick={handleClick}>Click me</button>
  </div>
);

以上でAppコンポーネントの処理は終わりで、残りの流れは初期表示の際と同じです。

それぞれのFiberノードに対してcompleteWorkが呼ばれます。

テキストの更新があったpタグについては、 updateHostText が呼ばれます。

この関数は以下のようにテキストが等しいか判定し、差分がある場合 markUpdate を呼びます。

if (oldText !== newText) {
  workInProgress.stateNode = createTextInstance(...);
  markUpdate(workInProgress);
}

初期表示のところでみたように、 markUpdate が呼ばれるとeffectTagがセットされます。

workInProgress.effectTag |= Update;

これによって、この後のcommitフェーズでDOMが更新されます。