🛠️

Reactを自作しよう

2020/11/21に公開

この記事は Build your own React を翻訳したものです。

Reactを1から書き直していきます。 実際のReactコードのアーキテクチャに従いますが、最適化機能と必須ではない機能は今回は実装しません。

Step 0 復習

最初にいくつかの基本的な概念を確認しましょう。

React、JSX、およびDOM要素がどのように機能するかをすでに理解している場合は、この章はスキップしても構いません。

今回は、次のわずか3行のコードをReactアプリの例として使用します。

const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)

最初の行ではReact要素を定義します。

次の行ではDOMからノードを取得します。

最後の行では、React要素をコンテナにレンダリングします。

React固有のコードをすべて削除して、生のJavaScriptに置き換えましょう。

最初の行には、JSXで定義された要素があります。

有効なJavaScriptでないため、バニラJSに置き換えるには、まず有効なJSに置き換える必要があります。

JSXは、BabelなどのビルドツールによってJSに変換されます。

変換は基本的に簡単です。タグ内のコードをcreateElement関数の呼び出しに置き換え、タグ名、props、子をパラメーターとして渡します。

const element = React.createElement(
  "h1",
  { title: "foo" },
  "Hello"
)

React.createElementは、引数からオブジェクトを作成します。

作成の際に、引数の検証が行われていますが、メインの処理はオブジェクトの作成だけです。

したがって、関数呼び出しをその結果に置き換えてしまっても問題ありません。

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}

そして、これが要素であり、typepropsの2つのプロパティを持つオブジェクトです。(本当はもっとありますが、今回はこれら2つだけを考えます)

typeは、作成するDOMノードのタイプを指定する文字列です。これは、HTML要素を作成するときにdocument.createElementに渡すtagNameです。 関数にすることもできますが、それはStep7で行います。

propsは別のオブジェクトであり、JSX属性のすべてのキーと値を持っています。 また、childrenという特別なプロパティもあります。

今回のchildrenは文字列ですが、通常はより多くの要素を含む配列です。 そのため、要素も木構造です。

もう1つ置き換える必要のあるReactコードがあります。ReactDOM.renderの呼び出しです。

ReactDOM.render(element, container)

レンダリングはReactがDOMを変更する場所です。 生のJSで置き換えるためには、DOM APIを使って自分で更新を行う必要があります。

const node = document.createElement(element.type)
node["title"] = element.props.title

まず、elementtype(この場合はh1)を使用してノードを作成します。

次に、elementのすべてのpropsをそのノードに割り当てます。 これは単なるタイトルです。

*混乱を避けるために、今後、要素(element)と言ったときはReact要素を参照し、ノードと言った時は実際のDOM要素を参照します。

次に、childrenのノードを作成します。 今回は文字列しかないため、テキストノードを作成します。

const text = document.createTextNode("")
text["nodeValue"] = element.props.children

innerTextを設定する代わりにtextNodeを使用すると、後ですべての要素を同じように扱うことができます。

h1titleで行ったようにnodeValueを設定する方法にも注意してください。これは、文字列にprops: {nodeValue: "hello"}があるかのようです。

const container = document.getElementById("root")
​
node.appendChild(text)
container.appendChild(node)

最後に、textNodeh1に追加し、h1containerに追加します。

これで、Reactを使わずにReactアプリと同じことができました。

Step 1 createElement関数

今回のサンプルコードは次のようになっています。

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

今回は、このReactコードを自作Reactのコードで置き換えます。

まず、独自のcreateElementを作成します。

JSXJSに変換して、createElementの呼び出しを確認しましょう。

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

前のステップで見たように、elementtypepropsを持つオブジェクトです。

createElementの役割は、そのオブジェクトを作成することだけです。

propsにはスプレッド演算子を使用し、childrenにはRestパラメータを使用します。これにより、childrenpropsは常に配列になります。

function createElement(type, props, ...children) { // ...children => Restパラメータ
  return {
    type,
    props: {
      ...props, // ...props => spread演算子
      children,
    },
  }
}

実行例を以下に上げます。

> createElement("div")
{
  "type": "div",
  "props": { "children": [] }
}

> createElement("div", null, a)
{
  "type": "div",
  "props": { "children": [a] }
}

> createElement("div", null, a, b)
{
  "type": "div",
  "props": { "children": [a, b] }
}

childrenの配列には、文字列や数値などのプリミティブ値を含めることもできます。

そのため、オブジェクトではないものはすべて独自の要素内にラップし、それらを表す特別なタイプTEXT_ELEMENTを作成します。

Reactは、childrenがない場合にプリミティブ値をラップしたり、空の配列を作成したりしませんが、今回はコードを単純化するためにこれを行います。(ここでは、パフォーマンスの高いコードよりも単純なコードを優先するようにしています。)

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

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

まだReactのcreateElementを使用している部分があります。

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

置き換えるために、ライブラリ(自作Reactのこと)に名前を付けましょう。 Reactのように聞こえるだけでなく、あくまで教育的な目的を示唆する名前を意図してDidactと名前を付けました。

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

ただし、ここではJSXを使用したいと思います。 Reactの代わりにDidactcreateElementを使用するようにbabelに指示するにはどうすればよいでしょうか?

このようなコメントがある場合、babelがJSXをトランスパイルすると、定義した関数が使用されます。

/** @jsx Didact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b/>
  </div>
)

Step 2 Render関数

次に自作するのはReactDOM.render関数(に相当する関数)です。

ReactDOM.render(element, container)

今の段階ではDOMに何かを追加することだけを考えます。

更新と削除は後のステップで実装します。

function render(element, container) {
  // TODO create dom nodes
}const Didact = {
  createElement,
  render,
}Didact.render(element, container)

element.typeを使用してDOMノードを作成して、その後、作成したノードをcontainerに追加します。

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

それぞれの子要素に対して同じことを再帰的に行います。

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

テキスト要素も処理する必要があります。element.typeがTEXT_ELEMENTの場合、通常のノードではなくテキストノードを作成します。

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

最後に、element.propsをノードに割り当てる必要があります。

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

以上です。 これでJSXをDOMにレンダリングできるライブラリができました。

ここまでの内容をぜひ codesandbox で試してみてください。

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map(child =>
        typeof child === "object"
          ? child
          : createTextElement(child)
      ),
    },
  }
}function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}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)
}const Didact = {
  createElement,
  render,
}/** @jsx Didact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
const container = document.getElementById("root")
Didact.render(element, container)

Step 3 並列モード

ここまで順調に進んできましたが、これ以上コードを追加する前に、リファクタリングが必要です。

この再帰呼び出しには問題があります。

レンダリングを開始すると、1つの要素ツリーを完全にレンダリングするまで停止しません。

要素ツリーが大きい場合、メインスレッドを長時間ブロックする可能性があります。

また、ブラウザがユーザー入力の処理やアニメーションのスムーズな維持などの優先度の高い処理を実行する必要がある場合は、レンダリングが終了するまでそれらの処理を行うことができずユーザー体験を損なうことになります。

function render(element, container) {
  // ...
​
  element.props.children.forEach(child =>
    render(child, dom)
  )// ...
}

そのため、作業を小さなユニット(作業単位)に分割し、各作業単位の処理が終了した後、他に実行すべきことがあれば、ブラウザにレンダリングを中断させます。

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

requestIdleCallbackを使用してループを作成します。

requestIdleCallbacksetTimeoutと考えることができますが、いつ実行するかを指示する代わりに、メインスレッドがアイドル状態のときにブラウザがコールバックを実行します。

ReactはrequestIdleCallbackを使用しなくなりました。 現在はスケジューラパッケージを使用しています。 ただし、このユースケースでは、概念的には同じです。

function workLoop(deadline) {
  // ...
  requestIdleCallback(workLoop)
}requestIdleCallback(workLoop)

requestIdleCallbackは、タイムアウトパラメータも提供します。 これを使用して、ブラウザに再び制御を戻すまでどれくらい猶予があるのかをコールバックから確認できます。

workLoopの使用を開始するには、最初の作業単位を設定してから、作業を実行するだけでなく、次の作業単位を返すperformUnitOfWork関数を作成する必要があります。

最初の作業単位の設定とperformUnitOfWork関数の作成は次のステップで行います。

Step 4 ファイバー

作業単位を整理するには、ファイバーツリーというデータ構造が必要です。

ファイバーは、通常、非常に軽量な実行スレッドを表しますが、Reactのファイバーは更新処理に優先度を付けられるように設定された作業単位のことです。

elementごとに1本のファイバーがあり、上で述べたように各ファイバーが作業単位になります。

サンプルコードを使った例を挙げましょう。

次のような要素ツリーをレンダリングするとします。

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

renderでは、ルートファイバーを作成し、それをnextUnitOfWorkとして設定します。

残りの作業はperformUnitOfWork関数で行われ、ファイバーごとに3つのことを行います。

  1. 要素をDOMに追加する
  2. 子要素のためのファイバーを作成
  3. 次の作業単位を選択

このデータ構造の目標の1つは、次の作業単位つまり次に作業を行う要素を簡単に見つけられるようにすることです。

そのため、各ファイバーには、最初の子、次の兄弟、および親へのリンクがあります。

fiber

また、ファイバーに子も兄弟もいない場合は、「おじ」、つまり親の兄弟に移動します。上の例ではaファイバー -> h2ファイバーが該当します。

また、親に兄弟がいない場合は、兄弟がいる人が見つかるまで、またはルートに到達するまで、親を調べ続けます。

ルートに到達した場合は、このrenderのすべての作業の実行が終了したことを意味します。

ここまで説明したことをコードに落とし込んでいきましょう。

まず、render関数からcreateDOM関数に一部のコードを移動しましょう。

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
}function render(element, container) {
  // TODO set next unit of work
}

let nextUnitOfWork = null

render関数で、nextUnitOfWorkをファイバーツリーのルートに設定します。

function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  }
}let nextUnitOfWork = null

次に、ブラウザの準備ができると、workLoopが呼び出され、ルートでの作業が開始されます。

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}requestIdleCallback(workLoop)function performUnitOfWork(fiber) {
  // TODO add dom node
  // TODO create new fibers
  // TODO return next unit of work
}

さて、performUnitOfWorkの肉付けを行っていきましょう。

まず、新しいノードを作成し、それをDOMに追加します。

fiber.domプロパティでDOMノードを追跡します。

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }// TODO create new fibers
  // TODO return next unit of work
}

次に、子要素ごとに新しいファイバーを作成します。

function performUnitOfWork(fiber) {
  // ...const elements = fiber.props.children
  let index = 0
  let prevSibling = nullwhile (index < elements.length) {
    const element = elements[index]const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
  }

  // TODO return next unit of work
}

そして、それが最初の子要素であるかどうかに応じて、子または兄弟として設定するファイバーツリーに追加します。

function performUnitOfWork(fiber) {
  // ...
  
  const elements = fiber.props.children
  let index = 0
  let prevSibling = null

  while (index < elements.length) {
    // ...

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

  // TODO return next unit of work
}

最後に、次の作業単位を検索します。 最初に子要素、次に兄弟、次におじというように試します。

function performUnitOfWork(fiber) {
  // ...

  if (fiber.child) {
      return fiber.child
 }
 let nextFiber = fiber
 while (nextFiber) {
    if (nextFiber.sibling) {
     return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
 }
}

これでperformUnitOfWork関数の完成です!

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }const elements = fiber.props.children
  let index = 0
  let prevSibling = nullwhile (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++
  }if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

Step 5 Render Phase と Commit Phase

Render Phase と Commit Phase についてあまり馴染みがない人もいるでしょう。

ページをレンダリングするとき(通常はthis.setStateを呼び出すことによって発生します)、ReactはWebページを更新するために2段階に分けて処理を実行します。

最初のフェーズは、"Render Phase"(レンダリングフェーズ)と呼ばれます。

レンダリングフェーズでは、Reactは仮想DOMを作成しています。つまり、実際にページを変更することなく、ページがどのように表示されるかを決定します。

Reactは最上位のコンポーネントでRenderを呼び出し、それが何を返したかを調べ、子要素ごとにRenderを呼び出します。これはページ全体がどのように表示されるかがわかるまで再帰的に行われます。

2番目のフェーズは、"Commit Phase"(コミットフェーズ)と呼ばれます。

ページがどのように表示されるかがわかったので、仮想DOMと一致するように実際のDOMを更新する必要があります。

そのため、レンダリングフェーズから取得した最新の仮想DOMを、最後にレンダリングしたときに取得した仮想DOMと比較し、ページを最新の状態にするために行うべき更新処理を(最小限になるように)計算します。

これが Render Phase と Commit Phase です。

さて続きに戻りましょう。

step5では Render Phase と Commit Phase の実装を必要とするような問題があります。

要素で作業するたびに、DOMに新しいノードを追加しています。

また、ツリー全体のレンダリングが完了する前に、ブラウザが作業を中断する可能性があることを忘れないでください。

その場合、ユーザーには不完全なUIが表示されます。それは望ましいことではありません。

したがって、ここからDOMを変更する部分を削除する必要があります。

  // performUnitOfWork からこの部分を削除
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

代わりに、ファイバーツリーのルートを追跡します。 これをprogress rootまたはwipRootと呼びます。

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

let wipRoot = null

そして次の作業単位がnull、つまりすべての作業が終了したら、ファイバーツリー全体をDOMにコミットします。

function commitRoot() {
  // TODO add nodes to dom
}

function workLoop(deadline) {
  // ...if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }requestIdleCallback(workLoop)
}

コミット作業はcommitRoot関数で行います。 ここでは、すべてのノードをDOMに再帰的に追加します。

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

Step 6 差分検出

これまではDOMにノードを追加しただけですが、ノードの更新や削除についてはどうでしょうか。

これがこのステップでやろうとしていることです。

render関数で受け取ったelementを、DOMにコミットした最後のファイバーツリーと比較する必要があります。

したがって、コミットが完了したら、その"DOMにコミットした最後のファイバーツリー"への参照を保存する必要があります。 これをcurrentRootと呼びます。

また、すべてのファイバーにalternateプロパティを追加します。 このプロパティは、前のコミットフェーズでDOMにコミットした古いファイバーへのリンクです。

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

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

let currentRoot = null

それでは、以前作成した新しいファイバーを作成するperformUnitOfWorkから子要素のファイバーを作成する部分のコードを、新たに作成した関数reconcileChildrenに移動しましょう。

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }const elements = fiber.props.children
  reconcileChildren(fiber, elements)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 = nullwhile (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++
  }
}

ここでは、古いファイバーと新しい要素を比較します。

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = nullwhile (index < elements.length || oldFiber != null) {
    const element = elements[index]
    let newFiber = null// TODO compare oldFiber to elementif (oldFiber) {
      oldFiber = oldFiber.sibling
    }

古いファイバーの子要素(wipFiber.alternate)と、調整する要素の配列を同時に繰り返し処理します。

配列(elements)とリンクリスト(oldFibersiblingを通したリンクリストになっています)を同時にループ処理するために必要なコードをすべて無視すると、oldFiberelementというこの処理で最も重要なものが残ります。

elementはDOMにレンダリングしたいものであり、oldFiberは前回レンダリングしたものです。

それらを比較して、DOMに適用すべき変更があるかどうかを確認する必要があります。

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = nullwhile (index < elements.length || oldFiber != null) {
    const element = elements[index]
    let newFiber = nullconst sameType = oldFiber && element && element.type == oldFiber.typeif (sameType) {
      // TODO ノードの更新
    }
    if (element && !sameType) {
      // TODO ノードの追加
    }
    if (oldFiber && !sameType) {
      // TODO 古いファイバーノードを削除
    }if (oldFiber) {
      oldFiber = oldFiber.sibling
    }

比較した結果は次の3つのどれかになります。

  • 古いファイバーと新しい要素が同じタイプの場合、DOMノードを保持し、新しいpropsで更新するだけです。
  • タイプが異なり、新しい要素がある場合は、新しいDOMノードを作成する必要があることを意味します
  • タイプが異なり、古いファイバーがある場合は、古いノードを削除する必要があります

ここでReactはkeyも使用するため、差分検出の効率が向上します。 たとえば、子要素が要素配列内の位置を変更したことを検出します。Didactではkeyの実装を行いません。

古いファイバーとelementが同じタイプの場合、DOMノードを古いファイバーからpropselementから保持するように、新しいファイバーを作成します。

また、ファイバーに新しいプロパティeffectTagを追加します。 このプロパティは、後でコミットのときに使用します。

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

次に、要素に新しいDOMノードが必要な場合は、新しいファイバーにPLACEMENTをタグ付けします。

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

また、ノードを削除する必要がある場合は、新しいファイバーがないため、古いファイバーにエフェクトタグを追加します。

ただし、ファイバーツリーをDOMにコミットするときは、古いファイバーがない進行中のprogress rootからコミットします。

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

したがって、削除するノードを追跡するための配列が必要です。

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

そして、DOMに変更をコミットするときは、配列deletionsのファイバーも使用します。

それでは、新しく追加したeffectTagを処理するようにcommitWork関数を変更しましょう。

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

ファイバーのeffectTagPLACEMENTがある場合は、前と同じように、DOMノードを親ファイバーのノードに追加します。

DELETIONの場合は、逆の操作を行い、子要素を削除します。

また、それがUPDATEの場合は、変更されたpropsで既存のDOMノードを更新する必要があります。

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)
}

DOMノードの更新作業はupdateDom関数で行います。

function updateDom(dom, prevProps, nextProps) {
  // TODO
}

古いファイバーのpropsを新しいファイバーのpropsと比較し、なくなったpropsを削除して、新しいまたは変更されたpropsを設定します。

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) {
  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })// Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })
}

更新する必要がある特別な種類のpropsの1つはイベントリスナーです。

したがって、props名がonで始まる場合は、それらを異なる方法で処理します。

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

イベントハンドラが変更された場合は、ノードから削除します。

  //Remove old or changed event listeners
  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]
      )
    })

そして新しいハンドラを追加します。

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })

codesandboxで 差分検出処理 を実装したバージョンを試してください。

Step 7 関数コンポーネント

次に実装する必要があるのは、関数コンポーネントのサポートです。

まず、サンプルとして使うコードを変更しましょう。 h1要素を返すこの単純な関数コンポーネントを使用します。

/** @jsx Didact.createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>
}

const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)

jsxをjsに変換すると、次のようになることに注意してください。

function App(props) {
  return Didact.createElement(
    "h1",
    null,
    "Hi ",
    props.name
  )
}

const element = Didact.createElement(App, {
  name: "foo",
})
const container = document.getElementById("root")
Didact.render(element, container)

関数コンポーネントは、次の2つの点でクラスコンポーネントと異なっています。

  • 関数コンポーネントからのファイバーにはDOMノードがありません
  • 子要素は、propsから直接取得するのではなく、関数を実行することから来ます

performUnitOfWorkでは、ファイバーの型が関数であるかどうかを確認し、それに応じて別の更新関数に処理を移します。

以前と同じ処理は、updateHostComponentで実行するようにし、関数コンポーネントの場合は、updateFunctionComponentで処理を実行します。

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)
}

updateFunctionComponentでは、関数を実行して子を取得します。

この例では、fiber.typeApp関数であり、実行するとh1要素を返します。

その後、子要素ができたら、差分検出機能は同じように機能します。差分検出の部分は何も変更する必要はありません。

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

変更する必要があるのはcommitWork関数です。

DOMノードのないファイバーができたので、2つ変更すべき箇所があります。

まず、DOMノードの親を見つけるには、DOMノードを持つファイバーが見つかるまでファイバーツリーを上に移動する必要があります。

そして、ノードを削除するときは、DOMノードを持つ子が見つかるまで続行する必要もあります。

function commitWork(fiber) {
  if (!fiber) {
    return
  }// DOMノードを持つファイバーが見つかるまでファイバーツリーを上に移動
  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) { domParentFiber = domParentFiber.parent }
  const domParent = domParentFiber.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") {
    commitDeletion(fiber, domParent)  // ノードを削除するときは、DOMノードを持つ子が見つかるまで探索を続行
  }commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}

Step 8 Hooks

最後のステップです。

関数コンポーネントができたので、stateも追加しましょう。

サンプルコードをhooksの例としてありがちな、カウンターを持ったコンポーネントに変更しましょう。 クリックするたびに、stateのカウンター値が1つ増えます。

Didact.useStateを使用して、カウンター値を取得および更新していることに注意してください。

const Didact = {
  createElement,
  render,
  useState,
}/** @jsx Didact.createElement */
function Counter() {
  const [state, setState] = Didact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}>
      Count: {state}
    </h1>
  )
}

const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container)

ここでは、例で紹介した関数コンポーネントのCounter関数を呼び出します。 そして、その関数内でuseStateを呼び出します。

function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}function useState(initial) {
  // TODO
}

useState関数内で使用できるように、関数コンポーネントを呼び出す前にいくつかのグローバル変数を初期化する必要があります。

まず、作業中のファイバーを格納する変数wipFiberを設定します。

また、同じコンポーネントでuseStateを複数回呼び出すことをサポートするために、hooksの配列(wipFiber.hooks)をファイバーに追加します。 そして、現在のフックインデックス(hookIndex)を追跡します。

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

関数コンポーネントがuseStateを呼び出すとき、古いフックがあるかどうかを確認します。 フックインデックスを使用して、wipFiber.alternateをチェックします。

古いフックがある場合は、stateを古いフックから新しいフックにコピーします。そうでない場合は、stateを初期化します。

次に、新しいフックをファイバーに追加し、フックインデックスを1つインクリメントして、stateを返します。

function useState(initial) {
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
  }
​
  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state]
}

useStateはstateを更新する関数も返す必要があるため、アクションを受け取るsetState関数を定義します(カウンターの例では、このアクションはstateを1つインクリメントする関数です)。

そのアクションを、フックに追加したキューにPushします。

次に、render関数で行ったのと同様のことを行い、作業ループが新しいレンダリングフェーズを開始できるように、新しいwipRootを次の作業単位として設定します。

function useState(initial) {
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  }const setState = action => {
    hook.queue.push(action)
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }
​
  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state, setState]
}

しかし、まだアクションを実行していません。

次回コンポーネントをレンダリングするときにこれを行い、古いフックキューからすべてのアクションを取得し、それらを1つずつ新しいフックのstateに適用して、stateを返すときに更新されます。

function useState(initial) {
  // ... 

  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    hook.state = action(hook.state)
  })

  // ...
}

これで自作Reactは完成です! ここまでお疲れ様でした!

ソースコードの全文は以下のようになっています。

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

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
}

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

  updateDom(dom, {}, fiber.props);

  return dom;
}

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) {
  //Remove old or changed event listeners
  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]);
    });

  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = "";
    });

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = nextProps[name];
    });

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

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

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

  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber.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") {
    commitDeletion(fiber, domParent);
  }

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

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}

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

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

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

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

  requestIdleCallback(workLoop);
}

requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function;
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

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) {
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex];
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  };

  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => {
    hook.state = action(hook.state);
  });

  const setState = (action) => {
    hook.queue.push(action);
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  };

  wipFiber.hooks.push(hook);
  hookIndex++;
  return [hook.state, setState];
}

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

function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
  let prevSibling = null;

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

    const sameType = oldFiber && element && element.type == oldFiber.type;

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

    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

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

    prevSibling = newFiber;
    index++;
  }
}

const Didact = {
  createElement,
  render,
  useState,
};

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

完成品を、codesandboxGithub で遊ぶことができます!

エピローグ

この記事の目標は、Reactがどう機能するかを理解してもらうことに加えて、React本体のコードベースをより深く掘り下げる手助けをすることです。

そのため、Reactのソースコードをみた時に違和感を感じないように、なるべくReactと同じ変数名と関数名を使用しました。

実際にReactアプリの関数コンポーネントの1つにブレークポイントを追加すると、コールスタックに次のようなものが見えるでしょう。

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

これらはDidactで作ったAPIと全く同じ名前です!

しかし、今回作ったDidactにはReactの機能や最適化手法で実装していないものがたくさんありました。

  • Didactでは、レンダリングフェーズ中にツリー全体を歩いています。 代わりに、Reactはいくつかのヒントと経験則に従って、何も変更されていないサブツリー全体をスキップします。
  • また、コミットフェーズではツリー全体を歩いています。 Reactは、変更点のあるファイバーのみを含むリンクリストを保持し、それらのファイバーのみにアクセスします。
  • wipな作業ツリーを構築するたびに、ファイバーごとに新しいオブジェクトを作成します。 Reactは前の木からファイバーをリサイクルします。
  • Didactは、レンダリングフェーズ中に新しい更新を検知すると、構築中の作業ツリーを破棄し、ルートから再開します。 Reactは、各更新に有効期限のタイムスタンプでタグを付け、それを使用して、どの更新の優先度が高いかを判断します。

他にも異なる点はありますがこれ以上は割愛します。

また、実装していない機能のうち、比較的簡単な機能もいくつかあります。例えば、

  • styleプロパティにオブジェクトを使用する
  • 子要素の配列をflatにする
  • useEffect
  • keyを用いた差分検出

などがあります。よかったら自分で実装してみましょう。

これで終わりです。

ここまで読んでくれてありがとうございます!

References

Discussion