🎉

Reactが動く原理 didactプロジェクトから学ぶ

32 min read 1

今回はReactについての内容です。最近の業務で使うことになり、だいぶ前に勉強していたdidactをもう一度読み通しました。オリジナルソースはこちら。著者本人はdidactと命名しているが、分かりやすくするために以下はmini-Reactと言います。

始まる前に、JSとReactについてある程度の基礎知識が必要です。また、Reactを使って何か書いたことがあった方が理解に役立つと思います。もし下記の言葉について聞いたこともない・聞いたことがあるけど分からない・他の人にもわかるように説明できない、となれば、一回調べておいた方がおすすめです。

JS、ウェブ関連:

  • 関数定義の方法のアロー関数(arrow function)、関数エクスプレッション(functional expression)
  • 高次的関数(higher order function)
  • クロージャー(closure)
  • Web APIs
  • DOM (Document Object Model)
  • ブラウザーにおけるJSの実行環境、シングルスレッド、イベントループなど

React関連:

  • JSX
  • コンポーネント(component)
  • ステート(state)
  • ライフサイクル(life cycle)

結構長めなので一気にやり抜ける猛者がいらっしゃれば是非コメントを。

Reactのコア原理

Didactは何をやっているかというと、Reactの実現原理を理解・説明するために、スクラッチから一つ機能するmini-Reactを実装していくこと。下記の内容には、上記のオリジナルソースをベースに、筆者のコメントとメモが含まれる。英語のソースが読める方は直接そちらがおすすめ。

JSXがなぜ使えるのか

実際にJSXのコードは、Reactでどんなふうに変換(transpile)されているかというと、次の例で説明する。

const element = <h1 title="foo">Hello</h1> // ここはvanilla JSではなく、JSXでHTMLを直接書いている
const container = document.getElementById("root")
ReactDOM.render(element, container)

ここのelementをvanilla JSに変換するには、Reactの中でcreateElement関数が呼び出されている。

// まずはcreateElement関数で、要素のタイプh1, 属性title, 子テキストHelloを引数として渡す
const element = React.createElement(
  "h1",
  { title: "foo" },
  "Hello"
)

// 結果的に、createElementは次のようにjsのオブジェクトを作り出す・リターン
const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello", // ノードは通常他のノードも含むことが多いので、ここは文字列ではなく配列になる場合が多い
  },
}

次に、ReactDOM.render関数をVanilla JSに変換してみると:

// まずはh1のノードを作り、ノードのtitle属性にelementオブジェクトから取得
const node = document.createElement(element.type)
node["title"] = element.props.title
// 次にテキストのノードを作り、テキストの内容をelementオブジェクトから取得
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
// 最後にテキストノードを、h1の要素に子要素としてアペンドし、容器にこのノードをアペンド
node.appendChild(text)
container.appendChild(node)

createElement関数

ここでcreateElement関数で何が行われているのかを説明する。仮に次のようなネストされている要素があるとする:

const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

となると、先の例を適応して、Reactでは次のように呼び出される:

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
)

これを抽象化していくと、createElement関数は大体このようなものになる:

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children,
    },
  }
}

先ほどの例のchildrenは文字列だったが、ここは配列になるため、実際はchildrenに対してmap関数でcreateElementを呼び出す必要がある。ただし、ノードは要素だけでなく、ただのテキストの可能性もあるため、ここはノードのタイプチェックも必要となる。

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map(child =>
        typeof child === "object"
          ? child
          : createTextElement(child)
      ),
    },
  }
}

ここでcreateTextElement関数でテキストノードを作るため、実装してみる:

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT", // 特別なテキストノード
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

これでcreateElementは完結する。createElement関数は常に1つの要素しか作らないので、これもなぜJSXで書く時に、並列要素が書けず、どうしても<></>といった空のタグがいるわけだ。また、注意して欲しいのは、ここのcreateElementcreateTextElement関数がリターンするのは、あくまでも、Web APIのdocument.createElementdocument.createTextNode関数のために、必要とする要素・ノードの種類と属性をまとめたjsのオブジェクトにすぎない。DOM要素そのものではない。以降の節では、便宜上elementと名付けているが、実際のdocument.createElementで作られたDOM要素ではないことを忘れないように。

render関数

ここで実際にrender関数はどう動いているか説明する。render関数は根本的に、ノードを作りだし、その親となるノードにくっつけるだけが仕事となる。

そのため、必要とする引数は少なくとも、DOM要素のオブジェクトと、親となるコンテナーDOM要素。

function render(element, container) {
  const dom = document.createElement(element.type)
  container.appendChild(dom)
}

ただ、ノードに子ノードがあるため、再帰の形で子ノードに対してrenderを呼び出す必要がある。

function render(element, container) {
  const dom = document.createElement(element.type)
  element.props.children.forEach(child =>
    render(child, dom)
  )
  container.appendChild(dom)
}

続いて、先ほどのノードタイプのチェックも必要となる。

function render(element, container) {
  const dom =
   element.type == "TEXT_ELEMENT"
     ? document.createTextNode("")
     : document.createElement(element.type)
  element.props.children.forEach(child =>
    render(child, dom)
  )
  container.appendChild(dom)
}

最後に、作られた要素に属性を当てていく。ここは子要素を除外するためにフィルターをかける必要がある。

function render(element, container) {
  const dom =
   element.type == "TEXT_ELEMENT"
     ? document.createTextNode("")
     : document.createElement(element.type)

  const isProperty = key => key !== "children"
  Object.keys(element.props)
     .filter(isProperty)
     .forEach(name => {
       dom[name] = element.props[name]
     })
 
  element.props.children.forEach(child =>
    render(child, dom)
  )
  container.appendChild(dom)
}

これで、createElementrender関数が完結し、JSXで書いた内容をレンダリングすることが可能となる。実際に使うには、@jsxノーテーションで、自作の関数を使うことが可能となる(サンドボックス)。

const Didact = {
  createElement,
  render
};

/** @jsx Didact.createElement */
const element = (
  <div style="background: salmon">
    <h1>Hello World</h1>
    <h2 style="text-align:right">from Didact</h2>
  </div>
);
const container = document.getElementById("root");
Didact.render(element, container);

並行性(Concurrency)

上記の実装には一つ大きな問題がある。それは、再帰の呼び出しが全てのノード・要素のレンダリングが終わるまで、スレッドをブロックするところだ。

// ここが他の処理をブロック
element.props.children.forEach(child =>
  render(child, el)
)

そのため、この再帰ループを「邪魔」するために、コードの再築が必要となる。目的としては、レンダリングのプロセスを一つ一つのユニットに分割し、レンダリングより優先すべき処理があればそちらを先に実行し、なければレンダリングを続ける、というふうにブラウザーに決めてもらう。

// 次に実行するタスク・レンダリングをnullに初期化
let nextUnitOfWork = null

// ここでワークループの関数を作り、requestIdleCallbackに渡す
function workLoop(deadline) {
  // shouldYieldがflaseかつ待機中のタスクがあれば、レンダリング処理が始まる
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    // 一つの処理ユニットが終われば、次に必要な処理をリターンする
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    // もうすぐにアイドリングが終わる目印
    // IdleDeadlineのこと: https://developer.mozilla.org/en-US/docs/Web/API/IdleDeadline
    shouldYield = deadline.timeRemaining() < 1
  }
  // スレッドをブロックしないために、次のアイドリング期間を待つ
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

ここで、requestIdleCallback関数について簡単に説明する。

  • ウェブページを開くときに、常にメインスレッドが何か処理をしているわけではない。例えば、ユーザーがただ画面を見ているだけで、スクロール、クリック、マウスムーブなどの操作何もしていない場合がある。
  • この「何もしていない」、いわばブラウザーにとって、「空白」の間、もしくは「アイドリング中」の間に、何か重要でない処理をすれば、ウェブパフォーマンスが向上する。例えば、画像のレージロード、スクロール先の内容を先に取得するなど。
  • requestIdleCallback関数は、このアイドリング中に、引数として渡された関数を実行することができる。つまり、優先度の低い処理を、requestIdleCallbackに渡せば、ブロック問題の解決に繋がる。

requestIdleCallbackについて理解した上で、IdleDeadline.timeRemaining(コード中のdeadline)の機能として、予測された現時点のアイドリングの残り時間をリターンすること。そのため、もしリターン値が0もしくは0に近いのであれば、もうすぐアイドリングが終わる、とのシグナルとなる。もしアイドリングが終わりそうになると、shouldYieldフラグがtrueとなり、仮に次のレンダリングがあるとしても、whileループが中止され、次のアイドリング期間を待たなければならない。

最後に、whileループの中で、performUnitOfWork関数で、一つのレンダリング処理だけを実行し、次の処理をリターンするように実装する。

ファイバーツリー(Fiber Tree)

ファイバツリーは、この処理ユニットを整理するためのデータ構造である。ファイバーとは、DOMに存在するHTML要素をJSで表現するオブジェクトにすぎない。一つのHTML要素を、一つのファイバーと対応し(例外はあるが、また関数型コンポーネントの節で説明とする)、一つのファイバーが一つの処理ユニットとなる。

例えば、次の例で考えてみると:

render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)

ここで、render関数では、まずrootというファイバーを作り、次に実行するタスクユニットとしてセットする。残りのdivからa要素までの処理は、一つずつ、performUnitOfWorkに任せる。

それぞれのタスクユニットには、次の処理が必要だと想定できる。

  • ファイバーからDOM要素を作り、作られた要素をDOMに追加
  • ファイバーの子ファイバーを作る(子ファイバーの順番になるとまたDOM要素が作られる)
  • 次に実行するタスクユニットを決める

図で表現すると、次のような構造となる。全てのファイバーは自分の子ファイバー、親ファイバー、兄弟ファイバーとリンクづけされている。これらのファイバーはぞれぞれのDOM要素と対応する。処理の順番として、div要素の処理が終われば、次は子要素のh1となる。h1が終われば、次は子要素のpとなる。pには子要素がないため、兄弟要素のaが次の処理となる。a要素には子要素も兄弟要素もないため、一階層上のh1へ戻る。h1の子要素が処理済みのため、h1の兄弟要素h2へ移行。同じ順番大、h2が終われば、divへ戻り、最後はrootと戻る。

この処理をコードに実現するには、前節で実装された下記のrender関数を分割する必要がある。

function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}

まずはファイバーからDOM要素を作るようにcreateDom関数を作り、作られたDOM要素をリターンする。

function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type)

  const isProperty = key => key !== "children"
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })
  //ここは子要素の処理はせず、一旦現時点で作った要素をリターン
  return dom
}

次に、render関数の中身を、次に処理するタスクユニットnextUnitOfWorkの設定に変更する。ここはrootとなるcontainerが初めてのタスクユニット。

function render(element, container) {
  nextUnitOfWork = {
    dom: container, // ここのdomは、ファイバーと対応するDOM要素自分自身
    props: {
      children: [element],
    },
  }
}

そして、performUnitOfWork関数の中身で考えれば、先ほどの3つの機能から順次に実装していく。

まずは現在処理中のファイバーを元に、対応するDOM要素を作り、その要素を親要素に追加する。

function performUnitOfWork(fiber) {
  // step 1: ファイバーからDOM要素を作り、作られた要素をDOMに追加
  // dom属性がなければ、ファイバーからDOM要素を作る
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  // もし親ファイバーがあれば、親ファイバーのDOM要素にアペンドする
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

  // step 2: ファイバーの子ファイバーを作る

  // step 3: 次に実行するタスクを決める
}

次に、全ての子要素を対象に、新しいファイバーを作る。

// step 2: ファイバーの子ファイバーを作る
// 要注意:ここのelementsは実際のDOM要素の配列ではなく、
// 前節のcreateElementで作られたtypeとpropsのみ持っているオブジェクトの配列となる
const elements = fiber.props.children
let index = 0
let prevSibling = null

while (index < elements.length) {
  const element = elements[index]
  // elementオブジェクトにparent, domなどの属性を追加することでファイバーとなるため
  // 下記のnewFiberではelementからtypeとpropsを取得している
  const newFiber = {
    type: element.type,
    props: element.props,
    parent: fiber,
    dom: null,
  }

  // インデックスが0の場合は初回となるため、まず子ファイバーをセット
  if (index === 0) {
    // ここのchild属性は、あくまでも子要素と対応するファイバーオブジェクト
    fiber.child = newFiber
  } else {
    // index > 0の場合は、子要素の兄弟要素を見ているため、「兄弟ファイバーの兄弟ファイバー」をセット
    // 初回の実行はここにヒットしないため一旦スキップして、次のprevSibling = newFiberを見てから分かりやすい
    prevSibling.sibling = newFiber
  }
  // インデックスの増加につれて次のファイバーにいくため、
  // 新しく作られたファイバーが、兄弟ファイバーのチェインでみると一個前のファイバーとなる
  prevSibling = newFiber
  index++
}

最後のステップ3ではタスクユニットの対象となるファイバーをリターン。

// step 3: 次に実行するタスクを決める
// 子ファイバーがある場合は、子ファイバーが対象となる
if (fiber.child) {
  return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
  // 子ファイバーがない場合は、まず兄弟ファイバーを探す
  if (nextFiber.sibling) {
    return nextFiber.sibling
  }
  // 兄弟ファイバーもない場合は、親ファイバーへ戻る(それで親ファイバーの兄弟ファイバーをまた探す)
  nextFiber = nextFiber.parent
}

これでperformUnitOfWork関数が完成:

function performUnitOfWork(fiber) {
  // step 1: ファイバーからDOM要素を作り、作られた要素をDOMに追加
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

  // step 2: ファイバーの子ファイバーを作る
  const elements = fiber.props.children
  let index = 0
  let prevSibling = null

  while (index < elements.length) {
    const element = elements[index]

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }

    if (index === 0) {
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }

  // step 3: 次に実行するタスクを決める
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

ここで注意してほしいのは:

  • ファイバーはDOM要素と違い、dom, parent, child, sibling, type, propsといった属性を持つJSオブジェクト
  • ファイバーはcreateElementで作られたtypeとpropsを持つJSオブジェクト(element)と違うが、そちらから拡張されたとも言える
  • ファイバーのdom属性は、実際に作られた、DOMへ追加可能な「DOM要素」そのもの
  • ファイバーのtypeとpropsは、elementから受け取ったため、DOM要素のtypeとpropsと対応する
  • parent、child、sibling属性は、また別のファイバーオブジェクトへポイントする、これはファイバーをツリー構造にする鍵となる
  • ファイバーツリーは最終的に、DOM要素を管理するデータ構造にすぎなく、DOMそのものではないが、DOMを操作するための参照基準となるため、バーチャルドムとも言われる

レンダリングのタイミング

上記の実装には、もう一つの問題がある。それは、DOMに要素を追加する、appendChildの操作のところ。

何が問題かというと、これらの処理は、アイドリング期間しか進められないので、もし実際にDOMに要素を追加して、それでアイドリングが終わって、また残りのレンダリング処理があるとなると、未完成の画面が見られてしまう。

そのため、実際にDOMに要素を追加するタイミングを考え直さなければならない。これは、DB操作のトランザクションと似ているかもしれないが、最後にコミットのタイミングが実際にDOM操作をする目印となるだろう。

まずは、下記のDOMに要素を追加する操作をperformUnitOfWork関数から削除する。

// if (fiber.parent) {
//   fiber.parent.dom.appendChild(fiber.dom)
// }

その代わりに、ファイバーツリーのrootファイバーを追跡するように変更:

function render(element, container) {
  wipRoot = { // wip => work in progress、現在更新中のファイバーツリー
    dom: container,
    props: {
      children: [element],
    },
  }
  nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
let wipRoot = null

そして、workLoop関数を作り、次に処理必要なタスクユニットがなければ、ファバイーツリーをDOMへ反映するようにコミットする。

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }

  if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }

  requestIdleCallback(workLoop)
}

コミット関数の中身として、rootから再帰でファイバーツリーからファイバーのDOM要素をDOMツリーに反映していく。

function commitRoot() {
  commitWork(wipRoot.child)
  wipRoot = null // 次回のレンダリングとコミットを干渉しないようにnullへリセット
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom) // fiber.domはDOM要素そのもの
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

調和(Reconciliation)

今までは、DOMに新しい要素の追加だけであったが、更新と削除の場合はどうだろうか。

ここで、現時点のファイバーツリーと、更新後のファイバーツリーの比較が必要となってくる。その現時点のファイバーツリーを、仮にcurrentRootとして名付けよう。そして、新しいファイバーツリーのrootファイバーに、現時点のファイバーツリーにポイントする属性、alternateを追加する。勿論、このalternate属性は、他のファイバーにも追加し、現時点のファイバーと比較するソースとなる。

function commitRoot() {
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
let currentRoot = null // 現在実際にDOMに反映されている更新前のroot
let wipRoot = null // 更新中のroot

そして、performUnitOfWork関数から、ステップ2の子要素のファイバーを作る部分をreconcileChildrenとして抽出する。

function performUnitOfWork(fiber) {
  // step 1: ファイバーからDOM要素を作り、作られた要素をDOMに追加
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  // 既存のステップ2の部分を抽出
  const elements = fiber.props.children
  reconcileChildren(fiber, elements)
  // if (fiber.parent) {
  //   fiber.parent.dom.appendChild(fiber.dom)
  // }
  //
  // // step 2: ファバーの子ファイバーを作る
  // const elements = fiber.props.children
  // let index = 0
  // let prevSibling = null
  //
  // while (index < elements.length) {
  //   const element = elements[index]
  //
  //   const newFiber = {
  //     type: element.type,
  //     props: element.props,
  //     parent: fiber,
  //     dom: null,
  //   }
  //
  //   if (index === 0) {
  //     fiber.child = newFiber
  //   } else {
  //     prevSibling.sibling = newFiber
  //   }
  //
  //   prevSibling = newFiber
  //   index++
  // }

  // step 3: 次に実行するタスクを決める
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let prevSibling = null

  while (index < elements.length) {
    const element = elements[index]

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: wipFiber,
      dom: null,
    }

    if (index === 0) {
      wipFiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
}

さらに、reconcileChildren関数の中身にも変更が必要。つまり、以前のファイバーツリーと比較する:

function reconcileChildren(wipFiber, elements) {
  let index = 0
  // wipFiber.alternate?.child に相当、現時点のファイバーの子ファイバーとなる
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null

  while (
    index < elements.length ||
    oldFiber != null
  ) {
    const element = elements[index]
    let newFiber = null

    // TODO compare oldFiber to element
    // ...
  }
}

ここのoldFiberとは、現時点のファイバーツリーにある子ファイバーのこと。whileループで取得したelements[0]とかは、新しく変更となった子ファイバー(厳密に言えば、createElement関数で作られたtypeとpropsを持つオブジェクトであり、ファイバーに変換される前の状態)。この子ファイバーが、現時点の子ファイバーと同じタイプかどうかをまずチェックする。

// 現時点のファイバーツリーにファイバー要素が存在
// かつ新しいファイバーツリーにも存在
// かつ新しいファイバーと現時点のファイバーのタイプが一致
const sameType =
  oldFiber &&
  element &&
  element.type == oldFiber.type

if (sameType) {
  // TODO update the node
}
if (element && !sameType) {
  // TODO add this node
}
if (oldFiber && !sameType) {
  // TODO delete the oldFiber's node
}

すると、3つの状況が考えられる:

  • 新しいファイバーが、現時点のファイバーと同じタイプ、つまり更新が必要となる
  • 新しいファイバーがあるが、現時点でその位置にファイバーがない、つまりファイバーを新規追加する必要がある
  • 現時点でのファイバーがあるが、新しいファイバーツリーにはそれがない、つまりそのファイバーを削除する必要がある

DOMノード更新の場合、現時点のファイバーからDOM要素とタイプをそのまま付与し、新しいファイバーのpropsを付与する。ここでalternate属性を、現時点でのoldFiberへポイントする。後でコミットするときにこの三つの状況をわかりやすくするために、新しい属性のeffectTagを追加する。

if (sameType) {
  newFiber = {
    type: oldFiber.type,
    props: element.props,
    dom: oldFiber.dom,
    parent: wipFiber,
    alternate: oldFiber,
    effectTag: "UPDATE",
  }
}

DOMノード追加の場合、新しいファイバーからタイプとpropsをとり、現時点にファイバツリーに該当ファイバーがないため、domalternatenullとする。effectTagは新規。

if (element && !sameType) {
  newFiber = {
    type: element.type,
    props: element.props,
    dom: null,
    parent: wipFiber,
    alternate: null,
    effectTag: "PLACEMENT",
  }
}

DOMノード削除の場合、newFiberを作る必要がないが、削除するファイバーを一括管理するために、一旦deletionsとの配列に入れておく。そして、oldFiberに対して、effectTagを削除と更新する。

if (oldFiber && !sameType) {
  oldFiber.effectTag = "DELETION"
  deletions.push(oldFiber)
}

この削除のファイバーを管理するために、新しいグローバル変数を作り、コミットの関数で処理を進める:

let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = null

function commitRoot() {
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot // レンダリングのコミットが終わったら現在のrootを更新
  wipRoot = null // するとまたwipRootをnullへリセットし、次のレンダリングを待つ
}

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  deletions = []
  nextUnitOfWork = wipRoot
}

次に、コミット関数の中身を、上記の3つの状況に対応するように変更:

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  // 注意:ファイバー自身ではなく、親ファイバーからアクセスできる親要素からの操作となる
  const domParent = fiber.parent.dom
  if (
    fiber.effectTag === "PLACEMENT" &&
    fiber.dom != null
  ) {
    domParent.appendChild(fiber.dom)
  } else if (
    fiber.effectTag === "UPDATE" &&
    fiber.dom != null
    ) {
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
  } else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom)
  }

  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

ただ、アップデートのほうが直接操作できるWeb APIがないので、こちらで実装する必要がある:

// DOM要素更新のためのチェック関数
const isProperty = key => key !== "children"
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  // なくなった属性を空文字列へ
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // 新しい属性を追加
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })
}

isNewisGoneは若干わかりにくいかもしれないが、HOFとクロージャーへの理解があれば問題ないだろう。関数をリターンしていること、そしてリターンの関数にprevnextへのアクセスがあることを忘れずに。現時点の要素と、新しい要素を比べて、isNewはプロパティが存在するが、値が変わっていること、isGoneはプロパティがもう新しい要素のプロパティに存在しないこと、をそれぞれチェックしている。

ここで一つ特殊なプロパティが見落とされている。DOM要素には、イベントリスナー(event listener)が付けられる場合が多い。そういった属性は、全て名前がonから始まっている。例えば、onClickonSubmitなど。ただ、リスナーのイベントタイプとしては、onが付いていなく、clicksubmitなどとなっている(いわばクリックイベント、サブミットイベントなど)。ここでイベント関連の属性チェックも入れる:

const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)

// ...

// 変わったevent listenerを一旦削除
Object.keys(prevProps)
  .filter(isEvent)
  .filter(
    key =>
      !(key in nextProps) || // 新しい要素にはこのイベント属性がない
      isNew(prevProps, nextProps)(key) // もしくは、要素はあるが中身は変わった
  )
  .forEach(name => {
    const eventType = name
      .toLowerCase()
      .substring(2) // onClick => click
    dom.removeEventListener(
      eventType,
      prevProps[name]
    )
  })

// 新規の属性にあるevent listenerを追加
Object.keys(nextProps)
  .filter(isEvent)
  .filter(isNew(prevProps, nextProps))
  .forEach(name => {
    const eventType = name
      .toLowerCase()
      .substring(2)
    dom.addEventListener(
      eventType,
      nextProps[name]
    )
  })

これでupdateDom関数が完成:

const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  // 変わったevent listenerを一旦削除
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(
      key =>
        !(key in nextProps) ||
        isNew(prevProps, nextProps)(key)
    )
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.removeEventListener(
        eventType,
        prevProps[name]
      )
    })

  // なくなった属性を空文字列へ
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // 新しい属性を追加
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })

  // 新規の属性にあるevent listenerを追加
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}

この段階でできたreactを試すサンドボックス

関数型コンポーネント(Functional Components)

だいぶ完成されていたが、ここで関数型コンポーネントの対応を追加する。

例えば次の例で考えてみると:

function App(props) {
  return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
render(element, container)

このjsxコードをjsに変換すると、次のようになる。

function App(props) {
  return createElement( // createElement関数は、typeとpropsだけ含まれるオブジェクトをリターンするため、domがない
    "h1",
    null,
    "Hi ",
    props.name
  )
}
const element = createElement(App, {
  name: "foo",
  // ここに...childrenがないが、App(props)を実行することで、Appの子ファイバーh1がリターンされる
})
// ...

関数型コンポーネントのどこが違うかというと、主に下記の2点となる:

  • 関数型コンポーネントから作ったファイバーは、現実のDOMツリーに対応する要素がないため、dom属性も勿論存在しない
  • 子要素は関数型コンポーネントの実行で取得し、直接propsから取得するわけではない
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber) // dom属性がないため、ここのチェックは不要
  }

  const elements = fiber.props.children // 直接ファイバーのpropsから取得できない
  reconcileChildren(fiber, elements)
  // ...
}

となれば、performUnitOfWork関数で、ファイバーのタイプをチェックする必要があり、関数かどうかによって別処理を行う。

function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function
  if (isFunctionComponent) {
    updateFunctionComponent(fiber)
  } else {
    updateHostComponent(fiber)
  }
  // ...
}


function updateFunctionComponent(fiber) {
  // TODO
}

function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  reconcileChildren(fiber, fiber.props.children)
}

もちろん、updateHostComponent関数では、今まで通りで良い。関数の場合は、その関数を実行してから、子要素(children)を取得。

function updateFunctionComponent(fiber) {
  // 注意:fiber.typeは関数のAppため()つけることで実行可能: App({name:"foo"})
  // 実行結果はh1要素のファイバー、Appそのものもファイバーなので、h1はAppの子ファイバーとなる
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

ここで一つ問題が出て、もしdom属性がない、つまりDOM要素そのものがなければ、何をDOMツリーにコミットするのか。dom属性のないファイバーも対応できるように、commitWork関数には修正が必要だ。

function commitWork(fiber) {
  if (!fiber) {
    return
  }

  let domParentFiber = fiber.parent
  // dom属性の持っているファイバーを見つけるために順次に親ファイバーをチェック
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent
  }
  // dom属性の持つ親ファイバーからDOM要素を取得
  const domParent = domParentFiber.dom

  // ...

  } else if (fiber.effectTag === "DELETION") {
    //domParent.removeChild(fiber.dom)
    //関数型の場合はfiber.domがないため単純にremoveChildできない、一旦他の関数に抽出
    commitDeletion(fiber, domParent)
  }
  
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    // ここは前節通り
    domParent.removeChild(fiber.dom)
  } else {
    // ここはfiber.domが見つかるまで、子ファイバーに再帰処理
    commitDeletion(fiber.child, domParent)
  }
}

ここで再帰処理に疑問があるかもしれないが、例で考えた方が分かりやすい。本節冒頭の例で言えば、JSXでは<h1>...</h1>ではなく、<App name="foo" />と書く。ただし、実際のDOMツリーにはもちろんAppといった要素種類がない(実際はh1が反映される)。ファイバーツリーを作る時に、Appに対してファイバーは作られるが、DOMツリーに対応する要素がないため、現実のDOMツリーではroot->App->h1ではなく、root->h1となっている。そのため、現実のDOMツリーの操作も、root要素に対してのremoveChildなどが行われて、Appファイバーとは関係がない。結果的に、関数型コンポーネントの場合は、そのファイバーの親、つまり現実のDOMツリーに対応要素のあるファイバーが操作対象となる

function App(props) {
  return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
render(element, container)

関数型コンポーネントの導入で、今まであった(かもしれない)暗黙のルール:DOMツリーとファイバーツリーは一対一の関係、にfalseを付けた。これは、バーチャルドムと現実のドムと比べて一つ大きな違いでもある。

フック(Hooks)

関数型コンポーネントでは、クラス型コンポーネントと異なり、それぞれのライフサイクルメソッドに処理をすることではなく、フックでステートの変更などを行っている。この節では、フックの実現について説明する。

ここはuseState関数を見てみよう。次の例で簡単なカウンターアプリが作られている。h1要素をクリックするたびに、カウンターが1からプラスされていく。

function Counter() {
  const [state, setState] = useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}>
      Count: {state}
    </h1>
  )
}
const element = <Counter />
const container = document.getElementById("root")
render(element, container)

useStateは、初期値を引数として、現時点のステートと、ステートを変更する方法(getter/setterのペアとも見られる)をリターンする。フック関数は1つだけではないため、関数型コンポーネントの変更をする時に、一つの配列で管理する必要がある。

let wipFiber = null
let hookIndex = null

function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

function useState(initial) {
  // wipFiber.alternate?.hooks?.[hookIndex]に相当、既存のファイバーにフックがあるかどうかを確認
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
  const hook = {
    // 既存のファイバーにフックがあれば、そのステートを取得、または初期値を付与
    state: oldHook ? oldHook.state : initial,
    // queueはセッター関数、つまりステートを変更する操作をキューイング
    queue: []
  }
  // ファイバーのhooksに現在のフックを追加
  wipFiber.hooks.push(hook)
  hookIndex++

  // セッター関数を定義、一つのaction/ステート変更操作を引数とする
  const setState = action => {
    hook.queue.push(action)
    // ここはrender関数と似たような操作で、新しいレンダリングタスクを始める、つまり、ステートの変更
    // ここはrootからやり直しになるが、実際のReactとは異なる
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }

  // 最後に、ステート変更操作の配列を取得し、順次に実行していくことで、ステートが更新される
  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    hook.state = action(hook.state)
  })
  return [hook.state, setState]
}

ここまでみると分かるかもしれないが、フック関数は、その名前通り、レンダリングまたはコミットのフェーズに、対象ファイバーに引っかけていくことだ。コードベース自体に、大きな変更がいらない。最後にできたものを試すサンドボックスはこちら

終わりに

このmini-Reactの実装はreactの仕組みへの理解に役立つだろう。非常に混乱しやすい箇所があったため(例えばelementのオブジェクトとファイバーのオブジェクト、DOMツリーとファイバーツリーなど)、自分なりに解説と注釈を追加した。

実際のreactソースコードと比べて、下記のようにいろいろな違う点があるが、reactの背後にある設計理念、コア機能の実現の一部が窺えるのではないかと。

  • レンダリングフェーズでファイバーツリーをrootから最後までチェックしているが、実際にReactではヒントと目印をつけることで、変更なしのサブツリーをスキップしている。
  • コミットフェーズにも、rootから最後までチェックしているが、Reactでは連結リストで変更されたファイバーのみを管理し、それらのファイバーに対してDOM操作を行っている。
  • 毎回新しい処理を始める時に、要素に対して新しいファイバーを作っているが、Reactでは前のファイバーツリー(現時点更新前の)からファイバーをリサイクルしている。
  • 新しい更新をレンダーフェーズで受け取った時、現在進行中の処理をやめて、rootからやり直しているが、Reactでは、それぞれの更新操作に対して、期限切れのタイムスタンプをタッグとしてつけて、優先順位を判断している。
  • 他にも色々...

また、ReactはJSをベースとするライブラリーなので、JSに対してある程度の理解がなしには、このプロジェクトを飲み込むことに無理があるだろう。これは、JSを適当に勉強し、すぐにReactを始める人にとっての落とし穴でもある。

筆者がこのプロジェクトを勉強する中でも、多くの疑問が出ていたが、ネットで調査しながら、何回も前後の文脈確認、実際のコンソール・サンドボックスなどでテストして、ようやく理解できたことが多々あった。もしコードと説明だけ読んでもよく分からない、のであれば、実際に自分で弄った方が鍵となるかもしれない。

ではでは、良きコーディングライフを。

Discussion

非常に勉強になる記事をありがとうございます。
一つ質問をさせてください🙏

ファイバーツリーの章のソースコードにおいて、

初回ページ読み込み後

  1. render 関数内で nextUnitOfWork に中身が空のルート要素 <div id='root'></div> が渡される
  2. Idle 時に performUnitOfWork が初めて呼び出される

という流れだと思うのですが、2 の時点で引数である nextUnitOfWork を関数の中でコンソールに出力すると中身に色々入っている

<div id="root">
  <div style="background: salmon;"></div>
</div>

が入っていました。1 と 2 の処理の間で子要素を appendChild するような処理は入っていない(気がする)ので原因がわからず...

自分の不勉強で申し訳ないのですが、ご教授いただけると非常に助かります。

確認しやすいよう sandbox のリンクを貼っておきます。(コンソールに関数名とその引数などを出力しています)

https://codesandbox.io/s/didact-2-forked-4jdcn?file=/src/index.js
ログインするとコメントできます